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:
@@ -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}
|
||||
Reference in New Issue
Block a user