From clicking through the redesigned UI: - scoring a case without a model registry painted a red failure across a case that had in fact analysed fine. It is now a quiet note saying the model term contributes 0, because scoring is an optional fourth of the rank, not the analysis. - the MLflow default moves to port 5001. On macOS, AirPlay Receiver owns 5000, which is why the registry answered "403" rather than refusing the connection; docker-compose publishes 5001 to match. - a funnel step that kept nothing drew a visible bar. Zero now draws zero. - "1 candidates". - the funnel's fixed grid columns forced a horizontal scrollbar on the report. The report also lists the top undecided candidates now: the first thing anyone opens has no decisions in it, and "Shortlisted (0)" alone said nothing about what the tool found. Tests: api 77, web 32; ruff, mypy, svelte-check clean.
110 lines
4.0 KiB
TypeScript
110 lines
4.0 KiB
TypeScript
// Dynamic, not static: the same image serves /api behind the ingress and a full URL elsewhere.
|
|
import { env } from '$env/dynamic/public';
|
|
import { candidateQuery, type CandidateFilters } from './candidates';
|
|
|
|
export type Assembly = 'GRCh38' | 'GRCh37';
|
|
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed';
|
|
export type PhenotypeTerm = { hpo_id: string; label: string };
|
|
|
|
export type Job = {
|
|
id: string; case_id: string; status: JobStatus;
|
|
vep_version: string | null; log: string | null; created_at: string; finished_at: string | null;
|
|
};
|
|
|
|
export type Case = {
|
|
id: string; name: string; vcf_uri: string; assembly: Assembly; created_at: string;
|
|
phenotypes: PhenotypeTerm[]; latest_job: Job | null; shortlisted: number;
|
|
};
|
|
|
|
export type Variant = {
|
|
id: number; chrom: string; pos: number; ref: string; alt: string; gene: string | null;
|
|
consequence: string | null; impact: string | null; hgvsc: string | null; hgvsp: string | null;
|
|
gnomad_af: number | null; clinvar_sig: string | null;
|
|
prediction: { score: number; model_name: string; model_version: string } | null;
|
|
};
|
|
|
|
export type DecisionState = 'shortlisted' | 'dismissed';
|
|
export type Decision = {
|
|
state: DecisionState; reason: string | null; note: string | null; decided_at: string;
|
|
};
|
|
|
|
export type Candidate = {
|
|
variant: Variant;
|
|
score: number;
|
|
components: Record<string, number>;
|
|
matched_terms: PhenotypeTerm[];
|
|
scored: boolean;
|
|
decision: Decision | null;
|
|
};
|
|
|
|
export type VariantDetail = Candidate & { annotations: Record<string, string> };
|
|
export type Funnel = { total: number; rare: number; candidates: number; phenotype_matched: number };
|
|
|
|
export type CandidatePage = {
|
|
funnel: Funnel;
|
|
weights: Record<string, number>;
|
|
items: Candidate[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
};
|
|
|
|
export type Report = {
|
|
case: Case;
|
|
funnel: Funnel;
|
|
generated_at: string;
|
|
provenance: {
|
|
job_id: string | null; vep_version: string | null; finished_at: string | null;
|
|
model_name: string | null; model_version: string | null;
|
|
};
|
|
shortlisted: Candidate[];
|
|
dismissed: Candidate[];
|
|
top: Candidate[];
|
|
};
|
|
|
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const r = await fetch(`${env.PUBLIC_API_URL ?? '/api'}${path}`, {
|
|
headers: { 'content-type': 'application/json' },
|
|
...init
|
|
});
|
|
if (!r.ok) throw new Error(await errorReason(r));
|
|
return r.json() as Promise<T>;
|
|
}
|
|
|
|
// FastAPI puts the reason in `detail`: a string, or a list of validation errors.
|
|
async function errorReason(r: Response): Promise<string> {
|
|
const fallback = `${r.status} ${r.statusText}`;
|
|
try {
|
|
const { detail } = await r.json();
|
|
if (typeof detail === 'string') return detail;
|
|
if (Array.isArray(detail)) return detail.map((d: { msg?: string }) => d.msg ?? '').join('; ') || fallback;
|
|
} catch {
|
|
// not JSON (e.g. a proxy error page)
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
const post = (body?: unknown): RequestInit => ({
|
|
method: 'POST',
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
});
|
|
|
|
export const api = {
|
|
cases: () => req<Case[]>('/cases'),
|
|
createCase: (body: { name: string; vcf_uri: string; assembly: Assembly; phenotypes: PhenotypeTerm[] }) =>
|
|
req<Case>('/cases', post(body)),
|
|
case: (id: string) => req<Case>(`/cases/${id}`),
|
|
annotate: (id: string) => req<Job>(`/cases/${id}/annotate`, post()),
|
|
score: (id: string) => req<{ scored: number; model_version: string }>(`/cases/${id}/score`, post()),
|
|
job: (id: string) => req<Job>(`/jobs/${id}`),
|
|
candidates: (caseId: string, filters: CandidateFilters = {}) => {
|
|
const query = candidateQuery(filters);
|
|
return req<CandidatePage>(`/cases/${caseId}/candidates${query ? `?${query}` : ''}`);
|
|
},
|
|
variant: (id: number) => req<VariantDetail>(`/variants/${id}`),
|
|
decide: (id: number, body: { state: DecisionState; reason?: string; note?: string }) =>
|
|
req<Decision>(`/variants/${id}/decision`, post(body)),
|
|
report: (caseId: string) => req<Report>(`/cases/${caseId}/report`),
|
|
phenotypes: (q: string) => req<PhenotypeTerm[]>(`/phenotypes?q=${encodeURIComponent(q)}`)
|
|
};
|