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:
Kemal Yaylali
2026-09-12 08:30:44 +01:00
parent abde5ec6e4
commit 07a01715fd
47 changed files with 2159 additions and 539 deletions
+206
View File
@@ -0,0 +1,206 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { api, type Candidate, type CandidatePage, type Case, type Job, type VariantDetail } from '$lib/api';
import { poll } from '$lib/poll';
import { formatElapsed, latestStep } from '$lib/progress';
import CandidateRow from '$lib/components/CandidateRow.svelte';
import Funnel from '$lib/components/Funnel.svelte';
import VariantPanel from '$lib/components/VariantPanel.svelte';
const POLL_MS = 3000;
const finished = (j: Job) => j.status === 'succeeded' || j.status === 'failed';
let { data } = $props();
let kase = $state<Case | null>(null);
let job = $state<Job | null>(null);
let page = $state<CandidatePage | null>(null);
let detail = $state<VariantDetail | null>(null);
let gene = $state('');
let impact = $state('');
let maxAf = $state<number | null>(null);
let decisionFilter = $state('');
let busy = $state(false);
let scoring = $state(false);
let error = $state<string | null>(null);
let now = $state(Date.now());
let stopPolling: (() => void) | null = null;
const running = $derived((!!job && !finished(job)) || scoring);
const elapsedMs = $derived(job ? now - Date.parse(job.created_at) : 0);
const step = $derived(scoring ? 'scoring variants with the model' : latestStep(job?.log ?? null));
const termCount = $derived(kase?.phenotypes.length ?? 0);
// Tick the elapsed time while a run is in flight; polling refreshes the step itself.
$effect(() => {
if (!running) return;
const tick = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(tick);
});
onMount(async () => {
try {
kase = await api.case(data.caseId);
job = kase.latest_job;
if (job && !finished(job)) follow(job);
else await loadCandidates();
} catch (e) {
error = (e as Error).message;
}
});
onDestroy(() => stopPolling?.());
function follow(j: Job) {
stopPolling?.();
stopPolling = poll(() => api.job(j.id), {
intervalMs: POLL_MS,
done: finished,
onValue: (next) => {
job = next;
if (next.status === 'succeeded') scoreThenLoad();
},
onError: (e) => { error = `Lost track of the run: ${(e as Error).message}`; }
});
}
async function analyse() {
busy = true;
error = null;
page = null;
detail = null;
try {
job = await api.annotate(data.caseId);
if (!finished(job)) follow(job);
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
// Annotation and scoring are one action to the user; the API keeps them separate.
async function scoreThenLoad() {
scoring = true;
try {
await api.score(data.caseId);
} catch (e) {
error = `Scoring failed: ${(e as Error).message}`;
} finally {
scoring = false;
}
await loadCandidates();
}
async function loadCandidates() {
try {
page = await api.candidates(data.caseId, {
gene,
impact,
maxAf,
state: decisionFilter as never,
limit: 100
});
} catch (e) {
error = (e as Error).message;
}
}
async function select(candidate: Candidate) {
try {
detail = await api.variant(candidate.variant.id);
} catch (e) {
error = (e as Error).message;
}
}
async function decided(updated: VariantDetail) {
detail = updated;
await loadCandidates();
}
</script>
<a href="/">All cases</a>
<div class="casehead">
<h1>{kase?.name ?? 'Case'}</h1>
<span class="chips">
{#each kase?.phenotypes ?? [] as term (term.hpo_id)}
<span class="chip match">{term.label}</span>
{/each}
{#if kase && kase.phenotypes.length === 0}
<span class="chip muted">no phenotype recorded — ranking falls back to rarity and consequence</span>
{/if}
</span>
<p class="muted provenance">
{kase?.assembly}
{#if job?.vep_version}· VEP {job.vep_version}{/if}
{#if page?.items[0]?.variant.prediction}· model {page.items[0].variant.prediction.model_version}{/if}
{#if job?.finished_at}· analysed {new Date(job.finished_at).toLocaleString('en-GB')}{/if}
</p>
</div>
{#if error}<p role="alert">{error}</p>{/if}
{#if running}
<p class="progress" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
Analysing for {formatElapsed(elapsedMs)}{#if step} · <span class="hgvs">{step}</span>{/if}
</p>
{:else}
<p class="actions">
<button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button>
{#if page && page.funnel.total > 0}
<a class="reportlink" href="/cases/{data.caseId}/report">Case report →</a>
{/if}
</p>
{/if}
{#if job?.status === 'failed' && job.log}
<pre class="log hgvs">{job.log}</pre>
{/if}
{#if page && page.funnel.total > 0}
<Funnel funnel={page.funnel} />
<div class="filters">
<input placeholder="Gene" bind:value={gene} aria-label="Gene" />
<select bind:value={impact} aria-label="Impact">
<option value="">Any impact</option><option>HIGH</option><option>MODERATE</option>
</select>
<label>Max AF <input type="number" step="0.0001" min="0" max="1" bind:value={maxAf} style="width:6rem" /></label>
<select bind:value={decisionFilter} aria-label="Decision">
<option value="">All decisions</option>
<option value="undecided">Undecided</option>
<option value="shortlisted">Shortlisted</option>
<option value="dismissed">Dismissed</option>
</select>
<button class="quiet" onclick={loadCandidates}>Apply</button>
</div>
<div class="triage">
<div class="candidates">
<p class="muted">{page.total} candidates ranked by phenotype fit, rarity, consequence and model score</p>
{#each page.items as candidate, i (candidate.variant.id)}
<CandidateRow
{candidate}
rank={i + 1}
{termCount}
selected={detail?.variant.id === candidate.variant.id}
onselect={() => select(candidate)} />
{:else}
<div class="empty">No candidates match these filters.</div>
{/each}
</div>
{#if detail && page}
<VariantPanel
{detail}
{termCount}
weights={page.weights}
onclose={() => (detail = null)}
ondecided={decided} />
{/if}
</div>
{:else if page && !running}
<div class="empty">
No results yet. "Analyse case" runs VEP over the VCF, scores each variant, then ranks what is
left against the phenotype.
</div>
{/if}