diff --git a/api/app/config.py b/api/app/config.py index aaa7a9c..3b16569 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -10,7 +10,8 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") database_url: str = "postgresql+asyncpg://rarelens:rarelens@localhost:5432/rarelens" - mlflow_tracking_uri: str = "http://localhost:5000" + # docker-compose publishes MLflow on 5001; macOS AirPlay Receiver owns 5000. + mlflow_tracking_uri: str = "http://localhost:5001" model_name: str = "rarelens-pathogenicity" # Registry alias set by `rarelens_ml.train --register` (stages are deprecated in MLflow 3). model_alias: str = "production" diff --git a/api/app/routers/cases.py b/api/app/routers/cases.py index 297e901..a7a79e4 100644 --- a/api/app/routers/cases.py +++ b/api/app/routers/cases.py @@ -36,6 +36,8 @@ from app.services.scoring import score_job logger = logging.getLogger(__name__) router = APIRouter() +REPORT_TOP = 5 + async def _case_or_404(session: SessionDep, case_id: uuid.UUID) -> Case: case = await case_view.get_case(session, case_id) @@ -208,6 +210,11 @@ async def report(case_id: uuid.UUID, session: SessionDep) -> ReportOut: ] shortlisted, dismissed = decided(DecisionState.shortlisted), decided(DecisionState.dismissed) + top = [ + CandidateOut.from_candidate(c, labels) + for c in view.candidates + if c.variant.decision is None + ][:REPORT_TOP] prediction = next((c.variant.prediction for c in view.candidates if c.variant.prediction), None) return ReportOut( case=_as_case_out(case, view.job, len(shortlisted)), @@ -222,4 +229,5 @@ async def report(case_id: uuid.UUID, session: SessionDep) -> ReportOut: ), shortlisted=shortlisted, dismissed=dismissed, + top=top, ) diff --git a/api/app/schemas.py b/api/app/schemas.py index 2ddd9f8..825b3b1 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -170,6 +170,8 @@ class ReportOut(BaseModel): provenance: ProvenanceOut shortlisted: list[CandidateOut] dismissed: list[CandidateOut] + # What a reviewer would look at next; a report with no decisions yet still says something. + top: list[CandidateOut] class ScoreOut(BaseModel): diff --git a/api/tests/test_cases.py b/api/tests/test_cases.py index 8e1ab72..1298ffd 100644 --- a/api/tests/test_cases.py +++ b/api/tests/test_cases.py @@ -113,6 +113,30 @@ async def test_the_report_is_the_decision_trail(client: AsyncClient) -> None: assert report["provenance"]["vep_version"] == "113.0" +@pytest.mark.usefixtures("db") +async def test_the_report_shows_the_top_candidates_before_anyone_decides( + client: AsyncClient, +) -> None: + """The first thing anyone opens is a report with no decisions in it; it must still say something.""" + case_id, _ = await a_case_with_candidates() + + report = (await client.get(f"/api/cases/{case_id}/report")).json() + assert report["shortlisted"] == [] + assert [c["variant"]["gene"] for c in report["top"]] == ["NF2", "CHEK2"] + + +@pytest.mark.usefixtures("db") +async def test_the_report_drops_decided_variants_from_the_top_list(client: AsyncClient) -> None: + case_id, _ = await a_case_with_candidates() + items = (await client.get(f"/api/cases/{case_id}/candidates")).json()["items"] + await client.post(f"/api/variants/{items[0]['variant']['id']}/decision", + json={"state": "shortlisted", "reason": "fits"}) + + report = (await client.get(f"/api/cases/{case_id}/report")).json() + assert [c["variant"]["gene"] for c in report["shortlisted"]] == ["NF2"] + assert [c["variant"]["gene"] for c in report["top"]] == ["CHEK2"] + + @pytest.mark.usefixtures("db") async def test_candidates_can_still_be_filtered(client: AsyncClient) -> None: case_id, _ = await a_case_with_candidates() diff --git a/docker-compose.yml b/docker-compose.yml index 3559bea..850beb0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,8 @@ services: mlflow server --host 0.0.0.0 --backend-store-uri sqlite:////mlruns/mlflow.db --artifacts-destination /mlruns - ports: ["5000:5000"] + # 5001 on the host: macOS AirPlay Receiver owns 5000 and answers 403. + ports: ["5001:5000"] volumes: [mlruns:/mlruns] volumes: diff --git a/web/src/app.css b/web/src/app.css index 45f4e73..3fc2f43 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -63,7 +63,8 @@ td.coord, td.hgvs { font-family: var(--mono); font-size: 0.85rem; } /* aligned /* The funnel is the headline: thousands of variants down to a handful. */ .funnel { list-style: none; padding: 0; margin: 0 0 1.5rem; display: grid; gap: 0.35rem; } -.funnel li { display: grid; grid-template-columns: 5rem 16rem 1fr; align-items: center; gap: 0.75rem; } +.funnel li { display: grid; grid-template-columns: 4.5rem minmax(6rem, 14rem) minmax(0, 1fr); + align-items: center; gap: 0.75rem; } .funnel-value { font-family: var(--mono); font-size: 1.05rem; text-align: right; font-variant-numeric: tabular-nums; } .funnel-label { color: var(--ink-soft); font-size: 0.9rem; } .funnel-track { background: white; border: 1px solid var(--line); height: 0.75rem; border-radius: 2px; } @@ -126,3 +127,5 @@ td.coord, td.hgvs { font-family: var(--mono); font-size: 0.85rem; } /* aligned .disclaimer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--line); color: var(--ink-soft); font-size: 0.85rem; } @media print { .noprint, nav { display: none; } .report { border: none; padding: 0; } } +.note { color: var(--ink-soft); font-size: 0.88rem; border-left: 3px solid var(--line); + padding: 0.35rem 0 0.35rem 0.6rem; margin: 0.5rem 0 1rem; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2009825..a1ae115 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -59,6 +59,7 @@ export type Report = { }; shortlisted: Candidate[]; dismissed: Candidate[]; + top: Candidate[]; }; async function req(path: string, init?: RequestInit): Promise { diff --git a/web/src/lib/candidates.test.ts b/web/src/lib/candidates.test.ts index e75e664..3af4068 100644 --- a/web/src/lib/candidates.test.ts +++ b/web/src/lib/candidates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { candidateQuery, evidenceChips, funnelSteps, scoreBarPercent } from './candidates'; +import { barWidth, candidateQuery, evidenceChips, funnelSteps, plural, scoreBarPercent } from './candidates'; import type { Candidate, Funnel } from './api'; const candidate = (over: Partial = {}): Candidate => ({ @@ -86,3 +86,30 @@ describe('candidateQuery', () => { expect(candidateQuery({ maxAf: 0.01 })).toBe('max_af=0.01'); }); }); + +describe('barWidth', () => { + it('draws nothing for a step that kept nothing', () => { + expect(barWidth(0, 13)).toBe(0); + }); + + it('keeps a sliver visible for a small non-zero step', () => { + expect(barWidth(1, 1000)).toBeGreaterThan(0); + expect(barWidth(1, 1000)).toBeLessThan(3); + }); + + it('fills the track for the first step', () => { + expect(barWidth(13, 13)).toBe(100); + }); + + it('copes with an empty case', () => { + expect(barWidth(0, 0)).toBe(0); + }); +}); + +describe('plural', () => { + it('does not say "1 candidates"', () => { + expect(plural(1, 'candidate')).toBe('1 candidate'); + expect(plural(0, 'candidate')).toBe('0 candidates'); + expect(plural(12, 'candidate')).toBe('12 candidates'); + }); +}); diff --git a/web/src/lib/candidates.ts b/web/src/lib/candidates.ts index f395c6c..8bc00ea 100644 --- a/web/src/lib/candidates.ts +++ b/web/src/lib/candidates.ts @@ -68,3 +68,11 @@ export function candidateQuery(filters: CandidateFilters): string { if (filters.offset !== undefined) q.set('offset', String(filters.offset)); return q.toString(); } + +/** Bar width for one funnel step. A step that kept nothing draws nothing. */ +export function barWidth(value: number, total: number): number { + if (!total || value <= 0) return 0; + return Math.max(1.5, (value / total) * 100); +} + +export const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`; diff --git a/web/src/lib/components/Funnel.svelte b/web/src/lib/components/Funnel.svelte index d601ddc..6ca1fde 100644 --- a/web/src/lib/components/Funnel.svelte +++ b/web/src/lib/components/Funnel.svelte @@ -1,10 +1,9 @@
    @@ -12,7 +11,7 @@
  1. {step.value.toLocaleString('en-GB')} {step.label} - +
  2. {/each}
diff --git a/web/src/routes/cases/[id]/+page.svelte b/web/src/routes/cases/[id]/+page.svelte index 5be56b3..732dd0c 100644 --- a/web/src/routes/cases/[id]/+page.svelte +++ b/web/src/routes/cases/[id]/+page.svelte @@ -3,6 +3,7 @@ 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 { plural } from '$lib/candidates'; import CandidateRow from '$lib/components/CandidateRow.svelte'; import Funnel from '$lib/components/Funnel.svelte'; import VariantPanel from '$lib/components/VariantPanel.svelte'; @@ -22,6 +23,7 @@ let busy = $state(false); let scoring = $state(false); let error = $state(null); + let scoreNote = $state(null); let now = $state(Date.now()); let stopPolling: (() => void) | null = null; @@ -82,8 +84,10 @@ scoring = true; try { await api.score(data.caseId); + scoreNote = null; } catch (e) { - error = `Scoring failed: ${(e as Error).message}`; + // Scoring is optional: without a model the rank simply loses one of its four terms. + scoreNote = (e as Error).message; } finally { scoring = false; } @@ -138,6 +142,9 @@ {#if error}

{error}

{/if} +{#if scoreNote} +

Variants are unscored, so the model term contributes 0 to every rank. {scoreNote}

+{/if} {#if running}

@@ -177,7 +184,7 @@

-

{page.total} candidates ranked by phenotype fit, rarity, consequence and model score

+

{plural(page.total, 'candidate')} ranked by phenotype fit, rarity, consequence and model score

{#each page.items as candidate, i (candidate.variant.id)} Nothing shortlisted yet.

{/each} + {#if report.top.length} +

Top candidates, not yet decided ({report.top.length})

+ {#each report.top as item (item.variant.id)} +
+

{item.variant.gene ?? 'intergenic'} {item.variant.chrom}:{item.variant.pos} {item.variant.ref}>{item.variant.alt}

+ +
+ {/each} + {/if} + {#if report.dismissed.length}

Dismissed ({report.dismissed.length})