feat: redesign around phenotype-driven triage, not variant filtering
A table with filters made the user do the work. Rare disease triage is a different task:
which few variants could explain *this* patient's phenotype, and why. The app now answers
that, and lets a reviewer act on the answer.
Domain
- a case is a proband: a VCF plus the HPO terms observed in the patient (samples -> cases)
- HPO's gene-to-phenotype annotations are loaded as reference data (scripts/load-hpo.py)
- each candidate can be shortlisted or dismissed with a reason and a note
Ranking (app/services/triage.py, 21 tests)
- weighted sum of phenotype match, rarity, consequence severity and the model's score,
with every component shown next to the candidate
- rarity and consequence filter; phenotype only ranks, because a real diagnosis can sit in
a gene nobody has annotated yet and filtering on it would hide exactly that case
- ClinVar is deliberately not an input: it appears beside the result as independent
confirmation, so nothing ranks highly merely because ClinVar already said pathogenic
UI
- the funnel is the headline: variants called -> rare -> coding candidates -> phenotype-matched
- ranked candidates with evidence chips, not a grid of everything; filters are demoted
- a variant panel showing the score breakdown, the matched HPO terms, the raw VEP record and
links out to Ensembl/gnomAD/ClinVar, with the decision controls
- a printable case report: phenotype, funnel, shortlisted variants with reasons, provenance
API: /cases with phenotypes, /cases/{id}/candidates (funnel + ranked + weights),
/variants/{id}, /variants/{id}/decision, /cases/{id}/report, /phenotypes for the picker.
Scoring moved under the case and now answers 503 with the reason when no model registry is
reachable, instead of a 500.
Verified end to end on a simulated proband (scripts/make-demo-case.sh: real GIAB HG002
background + one real ClinVar 2-star pathogenic NF2 variant). 13 variants called -> 1 coding
candidate, and the planted variant ranks first at 0.80 on phenotype 1.00, rarity 1.00 and
consequence 1.00, with ClinVar agreeing afterwards.
Tests: api 75, ml 18, loader 16, web 27; ruff, mypy, svelte-check, terraform validate, both
kustomize overlays and the Nextflow stub run all clean.
This commit is contained in:
+76
-13
@@ -1,22 +1,71 @@
|
||||
// Dynamic, not static: the same image serves /api behind the ingress and a full URL elsewhere.
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { variantQuery, type VariantFilters } from './query';
|
||||
import { candidateQuery, type CandidateFilters } from './candidates';
|
||||
|
||||
export type Assembly = 'GRCh38' | 'GRCh37';
|
||||
export type Sample = { id: string; name: string; vcf_uri: string; assembly: Assembly; created_at: string };
|
||||
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
export type PhenotypeTerm = { hpo_id: string; label: string };
|
||||
|
||||
export type Job = {
|
||||
id: string; sample_id: string; status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
log: string | null; created_at: string; finished_at: string | null;
|
||||
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_version: 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[];
|
||||
};
|
||||
export type VariantPage = { items: Variant[]; total: number; limit: number; offset: number };
|
||||
|
||||
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 });
|
||||
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>;
|
||||
}
|
||||
@@ -34,12 +83,26 @@ async function errorReason(r: Response): Promise<string> {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const post = (body?: unknown): RequestInit => ({
|
||||
method: 'POST',
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
|
||||
export const api = {
|
||||
samples: () => req<Sample[]>('/samples'),
|
||||
createSample: (body: Pick<Sample, 'name' | 'vcf_uri' | 'assembly'>) =>
|
||||
req<Sample>('/samples', { method: 'POST', body: JSON.stringify(body) }),
|
||||
jobsForSample: (sampleId: string) => req<Job[]>(`/samples/${sampleId}/jobs`),
|
||||
annotate: (sampleId: string) => req<Job>(`/samples/${sampleId}/annotate`, { method: 'POST' }),
|
||||
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}`),
|
||||
variants: (filters: VariantFilters) => req<VariantPage>(`/variants?${variantQuery(filters)}`)
|
||||
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)}`)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user