Files
rarelens/web/src/lib/api.ts
T
Kemal Yaylali 3ab404ebe5 fix(web): stop presenting an unscored case as a failed analysis, and four smaller things
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.
2026-09-12 08:49:00 +01:00

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)}`)
};