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
@@ -0,0 +1,73 @@
"""cases, phenotypes and triage decisions
Samples become cases (a proband: a VCF plus observed phenotype terms), and triage gains the two
things it needs: HPO annotations to rank against, and somewhere to record the reviewer's calls.
Revision ID: 9a1c2d3e4f50
Revises: 5f2c8e1b9d04
Create Date: 2026-09-12 09:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = '9a1c2d3e4f50'
down_revision: str | None = '5f2c8e1b9d04'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.rename_table('samples', 'cases')
op.alter_column('jobs', 'sample_id', new_column_name='case_id')
op.create_table(
'case_phenotypes',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('case_id', sa.UUID(), nullable=False),
sa.Column('hpo_id', sa.String(length=20), nullable=False),
sa.Column('label', sa.String(length=200), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('case_id', 'hpo_id', name='uq_case_phenotypes_case_term'),
)
op.create_index(op.f('ix_case_phenotypes_case_id'), 'case_phenotypes', ['case_id'])
op.create_table(
'gene_phenotypes',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('gene_symbol', sa.String(length=60), nullable=False),
sa.Column('hpo_id', sa.String(length=20), nullable=False),
sa.Column('hpo_name', sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('gene_symbol', 'hpo_id', name='uq_gene_phenotypes_gene_term'),
)
op.create_index(op.f('ix_gene_phenotypes_gene_symbol'), 'gene_phenotypes', ['gene_symbol'])
op.create_index(op.f('ix_gene_phenotypes_hpo_id'), 'gene_phenotypes', ['hpo_id'])
op.create_table(
'variant_decisions',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('variant_id', sa.Integer(), nullable=False),
sa.Column('state', sa.Enum('shortlisted', 'dismissed', name='decisionstate'), nullable=False),
sa.Column('reason', sa.String(length=120), nullable=True),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('decided_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['variant_id'], ['variants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('variant_id'),
)
def downgrade() -> None:
op.drop_table('variant_decisions')
sa.Enum(name='decisionstate').drop(op.get_bind(), checkfirst=True)
op.drop_index(op.f('ix_gene_phenotypes_hpo_id'), table_name='gene_phenotypes')
op.drop_index(op.f('ix_gene_phenotypes_gene_symbol'), table_name='gene_phenotypes')
op.drop_table('gene_phenotypes')
op.drop_index(op.f('ix_case_phenotypes_case_id'), table_name='case_phenotypes')
op.drop_table('case_phenotypes')
op.alter_column('jobs', 'case_id', new_column_name='sample_id')
op.rename_table('cases', 'samples')
+3 -3
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import jobs, predictions, samples, variants
from app.routers import cases, jobs, phenotypes, variants
@asynccontextmanager
@@ -23,10 +23,10 @@ app.add_middleware(
# The ingress forwards /api/* to this service unchanged, and local dev uses the same prefix.
api = APIRouter(prefix="/api")
api.include_router(samples.router, prefix="/samples", tags=["samples"])
api.include_router(cases.router, prefix="/cases", tags=["cases"])
api.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
api.include_router(variants.router, prefix="/variants", tags=["variants"])
api.include_router(predictions.router, prefix="/predictions", tags=["predictions"])
api.include_router(phenotypes.router, prefix="/phenotypes", tags=["phenotypes"])
app.include_router(api)
+63 -7
View File
@@ -1,12 +1,23 @@
"""SQLAlchemy 2.0 declarative models.
One sample -> many jobs; one job -> many variants; one variant -> one prediction (latest).
A case is a proband: a VCF plus the phenotype terms observed in that patient. One case -> many
jobs (pipeline runs); one job -> many variants; one variant -> one prediction and one decision.
"""
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy import (
DateTime,
Enum,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -22,27 +33,58 @@ class JobStatus(str, enum.Enum):
failed = "failed"
class Sample(Base):
__tablename__ = "samples"
class DecisionState(str, enum.Enum):
shortlisted = "shortlisted"
dismissed = "dismissed"
class Case(Base):
__tablename__ = "cases"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(120), unique=True)
vcf_uri: Mapped[str] = mapped_column(Text)
assembly: Mapped[str] = mapped_column(String(10), default="GRCh38")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
jobs: Mapped[list["Job"]] = relationship(back_populates="sample")
jobs: Mapped[list["Job"]] = relationship(back_populates="case")
phenotypes: Mapped[list["CasePhenotype"]] = relationship(
back_populates="case", cascade="all, delete-orphan", order_by="CasePhenotype.hpo_id"
)
class CasePhenotype(Base):
"""An HPO term observed in this patient; the ranking matches these against gene annotations."""
__tablename__ = "case_phenotypes"
__table_args__ = (UniqueConstraint("case_id", "hpo_id", name="uq_case_phenotypes_case_term"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
case_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("cases.id", ondelete="CASCADE"), index=True)
hpo_id: Mapped[str] = mapped_column(String(20))
label: Mapped[str] = mapped_column(String(200))
case: Mapped[Case] = relationship(back_populates="phenotypes")
class GenePhenotype(Base):
"""HPO's gene-to-phenotype annotations (scripts/load-hpo.py); read-only reference data."""
__tablename__ = "gene_phenotypes"
__table_args__ = (UniqueConstraint("gene_symbol", "hpo_id", name="uq_gene_phenotypes_gene_term"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
gene_symbol: Mapped[str] = mapped_column(String(60), index=True)
hpo_id: Mapped[str] = mapped_column(String(20), index=True)
hpo_name: Mapped[str] = mapped_column(String(200))
class Job(Base):
__tablename__ = "jobs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
sample_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("samples.id", ondelete="CASCADE"))
case_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("cases.id", ondelete="CASCADE"))
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.queued)
workflow_ref: Mapped[str | None] = mapped_column(String(200)) # Argo workflow name / nf run id
vep_version: Mapped[str | None] = mapped_column(String(40))
log: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
sample: Mapped[Sample] = relationship(back_populates="jobs")
case: Mapped[Case] = relationship(back_populates="jobs")
variants: Mapped[list["Variant"]] = relationship(back_populates="job")
@@ -64,6 +106,7 @@ class Variant(Base):
annotations: Mapped[dict] = mapped_column(JSONB, default=dict) # full VEP CSQ record
job: Mapped[Job] = relationship(back_populates="variants")
prediction: Mapped["Prediction | None"] = relationship(back_populates="variant", uselist=False)
decision: Mapped["VariantDecision | None"] = relationship(back_populates="variant", uselist=False)
class Prediction(Base):
@@ -75,3 +118,16 @@ class Prediction(Base):
score: Mapped[float] = mapped_column(Float) # P(pathogenic)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
variant: Mapped[Variant] = relationship(back_populates="prediction")
class VariantDecision(Base):
"""A reviewer's triage call. No authentication here, so decisions are shared by all visitors."""
__tablename__ = "variant_decisions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
variant_id: Mapped[int] = mapped_column(ForeignKey("variants.id", ondelete="CASCADE"), unique=True)
state: Mapped[DecisionState] = mapped_column(Enum(DecisionState))
reason: Mapped[str | None] = mapped_column(String(120))
note: Mapped[str | None] = mapped_column(Text)
decided_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
variant: Mapped[Variant] = relationship(back_populates="decision")
+225
View File
@@ -0,0 +1,225 @@
import logging
import uuid
from dataclasses import asdict
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from app.db import SessionDep
from app.models import (
Case,
CasePhenotype,
DecisionState,
Job,
JobStatus,
Variant,
VariantDecision,
)
from app.schemas import (
CandidateOut,
CandidatePage,
CaseCreate,
CaseOut,
FunnelOut,
JobOut,
ProvenanceOut,
ReportOut,
ScoreOut,
)
from app.services import candidates as case_view
from app.services import events, triage
from app.services.scoring import score_job
logger = logging.getLogger(__name__)
router = APIRouter()
async def _case_or_404(session: SessionDep, case_id: uuid.UUID) -> Case:
case = await case_view.get_case(session, case_id)
if case is None:
raise HTTPException(404, "case not found")
return case
async def _shortlisted_counts(session: SessionDep, case_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not case_ids:
return {}
rows = await session.execute(
select(Job.case_id, func.count(VariantDecision.id))
.join(Variant, Variant.job_id == Job.id)
.join(VariantDecision, VariantDecision.variant_id == Variant.id)
.where(Job.case_id.in_(case_ids), VariantDecision.state == DecisionState.shortlisted)
.group_by(Job.case_id)
)
return dict(rows.all()) # type: ignore[arg-type]
async def _latest_jobs(session: SessionDep, case_ids: list[uuid.UUID]) -> dict[uuid.UUID, Job]:
if not case_ids:
return {}
jobs = await session.scalars(
select(Job).where(Job.case_id.in_(case_ids)).order_by(Job.created_at.desc())
)
latest: dict[uuid.UUID, Job] = {}
for job in jobs: # ordered newest first, so the first one wins
latest.setdefault(job.case_id, job)
return latest
def _as_case_out(case: Case, job: Job | None, shortlisted: int) -> CaseOut:
out = CaseOut.model_validate(case)
out.latest_job = JobOut.model_validate(job) if job is not None else None
out.shortlisted = shortlisted
return out
@router.get("", response_model=list[CaseOut])
async def list_cases(session: SessionDep):
cases = (
await session.scalars(
select(Case).order_by(Case.created_at.desc()).options(selectinload(Case.phenotypes))
)
).all()
ids = [c.id for c in cases]
jobs, counts = await _latest_jobs(session, ids), await _shortlisted_counts(session, ids)
return [_as_case_out(c, jobs.get(c.id), counts.get(c.id, 0)) for c in cases]
@router.post("", response_model=CaseOut, status_code=status.HTTP_201_CREATED)
async def create_case(payload: CaseCreate, session: SessionDep):
case = Case(
name=payload.name,
vcf_uri=payload.vcf_uri,
assembly=payload.assembly,
phenotypes=[CasePhenotype(hpo_id=p.hpo_id, label=p.label) for p in payload.phenotypes],
)
session.add(case)
try:
await session.commit()
except IntegrityError: # cases.name is unique
await session.rollback()
raise HTTPException(409, f"a case named {payload.name!r} already exists") from None
await session.refresh(case, attribute_names=["phenotypes"])
return _as_case_out(case, None, 0)
@router.get("/{case_id}", response_model=CaseOut)
async def get_case(case_id: uuid.UUID, session: SessionDep):
case = await _case_or_404(session, case_id)
counts = await _shortlisted_counts(session, [case_id])
return _as_case_out(case, await case_view.latest_job(session, case_id), counts.get(case_id, 0))
@router.get("/{case_id}/jobs", response_model=list[JobOut])
async def list_jobs(case_id: uuid.UUID, session: SessionDep):
result = await session.scalars(
select(Job).where(Job.case_id == case_id).order_by(Job.created_at.desc())
)
return result.all()
@router.post("/{case_id}/annotate", response_model=JobOut, status_code=status.HTTP_202_ACCEPTED)
async def annotate(case_id: uuid.UUID, session: SessionDep):
case = await _case_or_404(session, case_id)
# Commit `running` before launching: a local run that dies instantly is marked failed by its
# watcher, and a later status write here would overwrite that.
job = Job(case_id=case.id, status=JobStatus.running)
session.add(job)
await session.commit()
try:
job.workflow_ref = await events.launch(job.id, case.vcf_uri, case.assembly)
except events.LaunchError as e:
job.status = JobStatus.failed
job.log = str(e)
job.finished_at = datetime.now(UTC)
await session.commit()
await session.refresh(job)
return job
@router.post("/{case_id}/score", response_model=ScoreOut)
async def score(case_id: uuid.UUID, session: SessionDep):
await _case_or_404(session, case_id)
job = await case_view.latest_job(session, case_id, status=JobStatus.succeeded)
if job is None:
raise HTTPException(409, "no finished annotation to score")
try:
scored, version = await score_job(job.id, session)
except Exception as e:
# No registry, no model behind the alias, a model that will not load: all of these are
# the environment being unready, not a bug in the request. Say so rather than throwing 500.
logger.exception("scoring case %s failed", case_id)
raise HTTPException(503, f"could not score with the model: {e}") from e
return ScoreOut(case_id=case_id, scored=scored, model_version=version)
@router.get("/{case_id}/candidates", response_model=CandidatePage)
async def list_candidates(
case_id: uuid.UUID,
session: SessionDep,
gene: str | None = None,
impact: str | None = Query(None, pattern="^(HIGH|MODERATE|LOW|MODIFIER)$"),
max_af: float | None = Query(None, ge=0, le=1),
state: str | None = Query(None, pattern="^(shortlisted|dismissed|undecided)$"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> CandidatePage:
case = await _case_or_404(session, case_id)
view = await case_view.build(session, case)
items = view.candidates
if gene:
items = [c for c in items if (c.variant.gene or "").upper() == gene.upper()]
if impact:
items = [c for c in items if c.variant.impact == impact]
if max_af is not None:
items = [c for c in items if c.variant.gnomad_af is None or c.variant.gnomad_af <= max_af]
if state == "undecided":
items = [c for c in items if c.variant.decision is None]
elif state is not None:
items = [c for c in items if c.variant.decision and c.variant.decision.state.value == state]
labels = view.labels
return CandidatePage(
# The funnel describes the whole case, not the filtered view.
funnel=FunnelOut(**asdict(view.funnel)),
weights=triage.WEIGHTS,
items=[CandidateOut.from_candidate(c, labels) for c in items[offset : offset + limit]],
total=len(items),
limit=limit,
offset=offset,
)
@router.get("/{case_id}/report", response_model=ReportOut)
async def report(case_id: uuid.UUID, session: SessionDep) -> ReportOut:
case = await _case_or_404(session, case_id)
view = await case_view.build(session, case)
labels = view.labels
def decided(state: DecisionState) -> list[CandidateOut]:
return [
CandidateOut.from_candidate(c, labels)
for c in view.candidates
if c.variant.decision is not None and c.variant.decision.state == state
]
shortlisted, dismissed = decided(DecisionState.shortlisted), decided(DecisionState.dismissed)
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)),
funnel=FunnelOut(**asdict(view.funnel)),
generated_at=datetime.now(UTC),
provenance=ProvenanceOut(
job_id=view.job.id if view.job else None,
vep_version=view.job.vep_version if view.job else None,
finished_at=view.job.finished_at if view.job else None,
model_name=prediction.model_name if prediction else None,
model_version=prediction.model_version if prediction else None,
),
shortlisted=shortlisted,
dismissed=dismissed,
)
+25
View File
@@ -0,0 +1,25 @@
from fastapi import APIRouter, Query
from sqlalchemy import select
from app.db import SessionDep
from app.models import GenePhenotype
from app.schemas import PhenotypeTerm
router = APIRouter()
@router.get("", response_model=list[PhenotypeTerm])
async def search(
session: SessionDep,
q: str = Query(min_length=2, max_length=100),
limit: int = Query(20, ge=1, le=100),
) -> list[PhenotypeTerm]:
"""Type-ahead over the HPO terms that are annotated to at least one gene."""
rows = await session.execute(
select(GenePhenotype.hpo_id, GenePhenotype.hpo_name)
.where(GenePhenotype.hpo_name.ilike(f"%{q}%"))
.distinct()
.order_by(GenePhenotype.hpo_name)
.limit(limit)
)
return [PhenotypeTerm(hpo_id=hpo_id, label=name) for hpo_id, name in rows]
-22
View File
@@ -1,22 +0,0 @@
import uuid
from fastapi import APIRouter, HTTPException
from app.db import SessionDep
from app.models import Job, JobStatus
from app.schemas import ScoreOut
from app.services.scoring import score_job
router = APIRouter()
@router.post("/score/{job_id}", response_model=ScoreOut)
async def score(job_id: uuid.UUID, session: SessionDep) -> ScoreOut:
"""Score every variant of a finished job with the model behind the registry alias."""
job = await session.get(Job, job_id)
if job is None:
raise HTTPException(404, "job not found")
if job.status != JobStatus.succeeded:
raise HTTPException(409, f"job is {job.status.value}; only succeeded jobs can be scored")
n, version = await score_job(job_id, session)
return ScoreOut(job_id=job_id, scored=n, model_version=version)
-61
View File
@@ -1,61 +0,0 @@
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.db import SessionDep
from app.models import Job, JobStatus, Sample
from app.schemas import JobOut, SampleCreate, SampleOut
from app.services import events
router = APIRouter()
@router.get("", response_model=list[SampleOut])
async def list_samples(session: SessionDep):
result = await session.scalars(select(Sample).order_by(Sample.created_at.desc()))
return result.all()
@router.post("", response_model=SampleOut, status_code=status.HTTP_201_CREATED)
async def create_sample(payload: SampleCreate, session: SessionDep):
sample = Sample(**payload.model_dump())
session.add(sample)
try:
await session.commit()
except IntegrityError: # samples.name is unique
await session.rollback()
raise HTTPException(409, f"a sample named {payload.name!r} already exists") from None
await session.refresh(sample)
return sample
@router.get("/{sample_id}/jobs", response_model=list[JobOut])
async def list_jobs(sample_id: uuid.UUID, session: SessionDep):
result = await session.scalars(
select(Job).where(Job.sample_id == sample_id).order_by(Job.created_at.desc())
)
return result.all()
@router.post("/{sample_id}/annotate", response_model=JobOut, status_code=status.HTTP_202_ACCEPTED)
async def annotate(sample_id: uuid.UUID, session: SessionDep):
sample = await session.get(Sample, sample_id)
if sample is None:
raise HTTPException(404, "sample not found")
# Commit `running` before launching: a local run that dies instantly is marked failed by its
# watcher, and a later status write here would overwrite that.
job = Job(sample_id=sample.id, status=JobStatus.running)
session.add(job)
await session.commit()
try:
job.workflow_ref = await events.launch(job.id, sample.vcf_uri, sample.assembly)
except events.LaunchError as e:
job.status = JobStatus.failed
job.log = str(e)
job.finished_at = datetime.now(UTC)
await session.commit()
await session.refresh(job)
return job
+52 -49
View File
@@ -1,60 +1,63 @@
import uuid
from fastapi import APIRouter, Query
from sqlalchemy import Integer, case, cast, func, select
from fastapi import APIRouter, HTTPException
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import selectinload
from app.db import SessionDep
from app.models import Prediction, Variant
from app.schemas import VariantOut, VariantPage
from app.models import Variant, VariantDecision
from app.schemas import CandidateOut, DecisionIn, DecisionOut, VariantDetailOut
from app.services import candidates as case_view
from app.services import triage
router = APIRouter()
# Karyotype order (1..22, X, Y, MT) instead of text order, where "10" sorts before "2".
_chrom = func.regexp_replace(Variant.chrom, "^chr", "", "i")
CHROM_ORDER = case(
(_chrom.regexp_match("^[0-9]+$"), cast(_chrom, Integer)),
(_chrom == "X", 23),
(_chrom == "Y", 24),
(_chrom.in_(["M", "MT"]), 25),
else_=26,
)
@router.get("", response_model=VariantPage)
async def list_variants(
job_id: uuid.UUID,
session: SessionDep,
gene: str | None = None,
impact: str | None = Query(None, pattern="^(HIGH|MODERATE|LOW|MODIFIER)$"),
max_af: float | None = Query(None, ge=0, le=1),
min_score: float | None = Query(None, ge=0, le=1),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> VariantPage:
stmt = select(Variant).where(Variant.job_id == job_id)
if gene:
stmt = stmt.where(Variant.gene == gene.upper())
if impact:
stmt = stmt.where(Variant.impact == impact)
if max_af is not None:
stmt = stmt.where((Variant.gnomad_af.is_(None)) | (Variant.gnomad_af <= max_af))
if min_score is not None:
stmt = stmt.join(Prediction, Prediction.variant_id == Variant.id).where(
Prediction.score >= min_score
@router.get("/{variant_id}", response_model=VariantDetailOut)
async def get_variant(variant_id: int, session: SessionDep) -> VariantDetailOut:
"""Every piece of evidence for one variant, including the raw VEP record."""
variant = await session.scalar(
select(Variant)
.where(Variant.id == variant_id)
.options(
selectinload(Variant.prediction),
selectinload(Variant.decision),
selectinload(Variant.job),
)
)
if variant is None:
raise HTTPException(404, "variant not found")
case = await case_view.get_case(session, variant.job.case_id)
terms = [p.hpo_id for p in case.phenotypes] if case else []
gene_terms = await case_view.gene_terms_for(session, {variant.gene} if variant.gene else set())
total = await session.scalar(select(func.count()).select_from(stmt.subquery()))
rows = await session.scalars(
stmt.options(selectinload(Variant.prediction))
# id breaks ties between split multiallelics at one position, keeping pages stable.
.order_by(CHROM_ORDER, Variant.chrom, Variant.pos, Variant.id)
.limit(limit)
.offset(offset)
# evaluate, not rank: the panel must work for a variant that did not make the candidate list.
scored = triage.evaluate(variant, terms, gene_terms)
labels = {p.hpo_id: p.label for p in case.phenotypes} if case else {}
base = CandidateOut.from_candidate(scored, labels).model_dump()
return VariantDetailOut(**base, annotations=variant.annotations or {})
@router.post("/{variant_id}/decision", response_model=DecisionOut)
async def decide(variant_id: int, payload: DecisionIn, session: SessionDep) -> DecisionOut:
if await session.get(Variant, variant_id) is None:
raise HTTPException(404, "variant not found")
stmt = insert(VariantDecision).values(
variant_id=variant_id, state=payload.state, reason=payload.reason, note=payload.note
)
return VariantPage(
items=[VariantOut.model_validate(v) for v in rows],
total=total or 0,
limit=limit,
offset=offset,
# Changing your mind replaces the call rather than failing on the unique constraint.
await session.execute(
stmt.on_conflict_do_update(
index_elements=[VariantDecision.variant_id],
set_={
"state": stmt.excluded.state,
"reason": stmt.excluded.reason,
"note": stmt.excluded.note,
"decided_at": func.now(),
},
)
)
await session.commit()
decision = await session.scalar(
select(VariantDecision).where(VariantDecision.variant_id == variant_id)
)
return DecisionOut.model_validate(decision)
+99 -19
View File
@@ -2,12 +2,15 @@ import re
import uuid
from datetime import datetime
from pathlib import PurePosixPath
from typing import Literal
from typing import TYPE_CHECKING, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.config import settings
from app.models import JobStatus
from app.models import DecisionState, JobStatus
if TYPE_CHECKING:
from app.services.triage import Candidate
class ORMModel(BaseModel):
@@ -19,11 +22,18 @@ VCF_SUFFIXES = (".vcf", ".vcf.gz", ".vcf.bgz", ".bcf")
GCS_URI = re.compile(r"gs://[a-z0-9][a-z0-9._-]{1,220}[a-z0-9]/\S+")
class SampleCreate(BaseModel):
class PhenotypeTerm(ORMModel):
"""An HPO term: the id is what ranking matches on, the label is for people."""
hpo_id: str = Field(pattern=r"^HP:\d{7}$")
label: str = Field(min_length=1, max_length=200)
class CaseCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
vcf_uri: str
# Passed to VEP --assembly; the VEP cache must contain it.
assembly: Assembly = "GRCh38"
phenotypes: list[PhenotypeTerm] = Field(default_factory=list, max_length=100)
@field_validator("vcf_uri")
@classmethod
@@ -44,17 +54,9 @@ class SampleCreate(BaseModel):
return v
class SampleOut(ORMModel):
id: uuid.UUID
name: str
vcf_uri: str
assembly: str
created_at: datetime
class JobOut(ORMModel):
id: uuid.UUID
sample_id: uuid.UUID
case_id: uuid.UUID
status: JobStatus
workflow_ref: str | None
vep_version: str | None
@@ -63,6 +65,17 @@ class JobOut(ORMModel):
finished_at: datetime | None
class CaseOut(ORMModel):
id: uuid.UUID
name: str
vcf_uri: str
assembly: str
created_at: datetime
phenotypes: list[PhenotypeTerm] = Field(default_factory=list)
latest_job: JobOut | None = None
shortlisted: int = 0
class PredictionOut(ORMModel):
model_name: str
model_version: str
@@ -85,14 +98,81 @@ class VariantOut(ORMModel):
prediction: PredictionOut | None = None
class ScoreOut(BaseModel):
job_id: uuid.UUID
scored: int
model_version: str
class DecisionIn(BaseModel):
state: DecisionState
reason: str | None = Field(None, max_length=120)
note: str | None = Field(None, max_length=2000)
class VariantPage(BaseModel):
items: list[VariantOut]
class DecisionOut(ORMModel):
state: DecisionState
reason: str | None
note: str | None
decided_at: datetime
class CandidateOut(BaseModel):
variant: VariantOut
score: float
components: dict[str, float]
matched_terms: list[PhenotypeTerm]
scored: bool
decision: DecisionOut | None = None
@classmethod
def from_candidate(cls, candidate: "Candidate", labels: dict[str, str]) -> "CandidateOut":
variant = candidate.variant
return cls(
variant=VariantOut.model_validate(variant),
score=round(candidate.score, 4),
components={name: round(v, 4) for name, v in candidate.components.items()},
matched_terms=[
PhenotypeTerm(hpo_id=term, label=labels.get(term, term))
for term in candidate.matched_terms
],
scored=candidate.scored,
decision=DecisionOut.model_validate(variant.decision) if variant.decision else None,
)
class VariantDetailOut(CandidateOut):
annotations: dict
class FunnelOut(BaseModel):
total: int
rare: int
candidates: int
phenotype_matched: int
class CandidatePage(BaseModel):
funnel: FunnelOut
weights: dict[str, float]
items: list[CandidateOut]
total: int
limit: int
offset: int
class ProvenanceOut(BaseModel):
job_id: uuid.UUID | None = None
vep_version: str | None = None
finished_at: datetime | None = None
model_name: str | None = None
model_version: str | None = None
class ReportOut(BaseModel):
case: CaseOut
funnel: FunnelOut
generated_at: datetime
provenance: ProvenanceOut
shortlisted: list[CandidateOut]
dismissed: list[CandidateOut]
class ScoreOut(BaseModel):
case_id: uuid.UUID
scored: int
model_version: str
+79
View File
@@ -0,0 +1,79 @@
"""Assemble a case's ranked candidates: load the latest results, rank them, attach decisions.
Everything for one job is loaded at once, which is fine for a gene panel or a chromosome — the
size of case this demo handles. A whole genome would need the narrowing pushed into SQL.
"""
import uuid
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Case, GenePhenotype, Job, JobStatus, Variant
from app.services import triage
EMPTY_FUNNEL = triage.Funnel(total=0, rare=0, candidates=0, phenotype_matched=0)
@dataclass(frozen=True)
class CaseView:
case: Case
job: Job | None
funnel: triage.Funnel
candidates: list[triage.Candidate]
@property
def labels(self) -> dict[str, str]:
return {p.hpo_id: p.label for p in self.case.phenotypes}
async def get_case(session: AsyncSession, case_id: uuid.UUID) -> Case | None:
return await session.scalar(
select(Case).where(Case.id == case_id).options(selectinload(Case.phenotypes))
)
async def latest_job(
session: AsyncSession, case_id: uuid.UUID, *, status: JobStatus | None = None
) -> Job | None:
stmt = select(Job).where(Job.case_id == case_id).order_by(Job.created_at.desc()).limit(1)
if status is not None:
stmt = stmt.where(Job.status == status)
return await session.scalar(stmt)
async def gene_terms_for(session: AsyncSession, genes: set[str]) -> dict[str, set[str]]:
"""gene symbol -> the HPO terms annotated to it."""
if not genes:
return {}
rows = await session.execute(
select(GenePhenotype.gene_symbol, GenePhenotype.hpo_id).where(
GenePhenotype.gene_symbol.in_(genes)
)
)
index: dict[str, set[str]] = {}
for gene, hpo_id in rows:
index.setdefault(gene, set()).add(hpo_id)
return index
async def build(session: AsyncSession, case: Case) -> CaseView:
job = await latest_job(session, case.id, status=JobStatus.succeeded)
if job is None:
return CaseView(case=case, job=None, funnel=EMPTY_FUNNEL, candidates=[])
variants = (
await session.scalars(
select(Variant)
.where(Variant.job_id == job.id)
.options(selectinload(Variant.prediction), selectinload(Variant.decision))
)
).all()
case_terms = [p.hpo_id for p in case.phenotypes]
gene_terms = await gene_terms_for(session, {v.gene for v in variants if v.gene})
return CaseView(
case=case,
job=job,
funnel=triage.funnel(variants, case_terms, gene_terms),
candidates=triage.rank(variants, case_terms, gene_terms),
)
+106
View File
@@ -0,0 +1,106 @@
"""Narrow a case's variants the way a clinical scientist does, and say why.
The rank is a weighted sum of four parts a reviewer can audit. ClinVar is deliberately not one of
them: it is shown beside the result as independent confirmation, so a variant never ranks highly
merely because ClinVar already called it pathogenic.
Rarity and consequence *filter* (the usual first pass); phenotype only *ranks*, because a real
diagnosis can sit in a gene nobody has annotated yet and filtering on it would hide exactly that.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from app.models import Variant
WEIGHTS = {"phenotype": 0.35, "rarity": 0.25, "consequence": 0.20, "model": 0.20}
RARE_AF = 0.001
CANDIDATE_IMPACTS = frozenset({"HIGH", "MODERATE"})
IMPACT_SEVERITY = {"HIGH": 1.0, "MODERATE": 0.6, "LOW": 0.2, "MODIFIER": 0.0}
# Allele frequency ceiling -> score, rarest first.
RARITY_STEPS = ((0.0, 1.0), (0.0001, 0.8), (0.001, 0.5), (0.01, 0.2))
@dataclass(frozen=True)
class Funnel:
"""How many variants survive each narrowing step; the headline of the case page."""
total: int
rare: int
candidates: int
phenotype_matched: int
@dataclass(frozen=True)
class Candidate:
variant: Variant
score: float
components: dict[str, float]
matched_terms: list[str]
scored: bool
def rarity_score(af: float | None) -> float:
if af is None: # absent from gnomAD
return 1.0
for ceiling, score in RARITY_STEPS:
if af <= ceiling:
return score
return 0.0
def consequence_score(impact: str | None) -> float:
return IMPACT_SEVERITY.get(impact or "", 0.0)
def phenotype_score(
gene: str | None, case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
) -> tuple[float, list[str]]:
"""What fraction of the patient's terms HPO associates with this gene, and which ones."""
if not gene or not case_terms:
return 0.0, []
annotated = gene_terms.get(gene, set())
matched = [term for term in case_terms if term in annotated]
return len(matched) / len(case_terms), matched
def is_rare(variant: Variant) -> bool:
return variant.gnomad_af is None or variant.gnomad_af < RARE_AF
def is_candidate(variant: Variant) -> bool:
return is_rare(variant) and variant.impact in CANDIDATE_IMPACTS
def funnel(
variants: Sequence[Variant], case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
) -> Funnel:
rare = [v for v in variants if is_rare(v)]
candidates = [v for v in rare if v.impact in CANDIDATE_IMPACTS]
matched = sum(1 for v in candidates if phenotype_score(v.gene, case_terms, gene_terms)[1])
return Funnel(len(variants), len(rare), len(candidates), matched)
def evaluate(
variant: Variant, case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
) -> Candidate:
"""Score one variant, whether or not it survived the filters."""
phenotype, matched = phenotype_score(variant.gene, case_terms, gene_terms)
prediction = variant.prediction
components = {
"phenotype": phenotype,
"rarity": rarity_score(variant.gnomad_af),
"consequence": consequence_score(variant.impact),
"model": float(prediction.score) if prediction is not None else 0.0,
}
score = sum(WEIGHTS[name] * value for name, value in components.items())
return Candidate(variant, score, components, matched, prediction is not None)
def rank(
variants: Sequence[Variant], case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
) -> list[Candidate]:
candidates = [evaluate(v, case_terms, gene_terms) for v in variants if is_candidate(v)]
# id breaks ties, so equal scores do not shuffle between requests.
candidates.sort(key=lambda c: (-c.score, c.variant.id))
return candidates
+2 -1
View File
@@ -70,7 +70,8 @@ async def db(migrated_db: None) -> AsyncIterator[None]:
async with engine.begin() as conn:
await conn.execute(
text("TRUNCATE samples, jobs, variants, predictions RESTART IDENTITY CASCADE")
text("TRUNCATE cases, case_phenotypes, gene_phenotypes, jobs, variants, "
"predictions, variant_decisions RESTART IDENTITY CASCADE")
)
yield
+40 -13
View File
@@ -2,20 +2,47 @@ import uuid
from typing import Any
from app.db import SessionLocal
from app.models import Job, JobStatus, Sample, Variant
from app.models import Case, CasePhenotype, GenePhenotype, Job, JobStatus, Prediction, Variant
VARIANT_DEFAULTS: dict[str, Any] = {
"chrom": "22", "pos": 1, "ref": "A", "alt": "G", "gene": "NF2",
"impact": "HIGH", "consequence": "frameshift_variant", "gnomad_af": None, "annotations": {},
}
async def seed_job(
variants: list[dict[str, Any]], status: JobStatus = JobStatus.succeeded
) -> uuid.UUID:
"""Insert a sample, a job and its variants; each variant dict overrides the defaults."""
async def seed_case(
*,
phenotypes: list[tuple[str, str]] | None = None,
variants: list[dict[str, Any]] | None = None,
gene_terms: dict[str, list[tuple[str, str]]] | None = None,
status: JobStatus = JobStatus.succeeded,
name: str | None = None,
) -> tuple[uuid.UUID, uuid.UUID]:
"""Insert a case, its phenotypes, a job and its variants. Returns (case_id, job_id).
`variants` entries override VARIANT_DEFAULTS; a "score" key becomes a Prediction.
"""
async with SessionLocal() as s:
sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38")
job = Job(sample=sample, status=status)
rows = [
Variant(job=job, **{"chrom": "22", "pos": 1, "ref": "A", "alt": "G", "annotations": {}} | v)
for v in variants
]
s.add_all([sample, job, *rows])
case = Case(
name=name or f"case-{uuid.uuid4()}",
vcf_uri="gs://bucket/proband.vcf.gz",
assembly="GRCh38",
phenotypes=[CasePhenotype(hpo_id=hpo, label=label) for hpo, label in (phenotypes or [])],
)
job = Job(case=case, status=status, vep_version="113.0")
s.add_all([case, job])
for gene, terms in (gene_terms or {}).items():
s.add_all(
GenePhenotype(gene_symbol=gene, hpo_id=hpo, hpo_name=label) for hpo, label in terms
)
for spec in variants or []:
fields = VARIANT_DEFAULTS | spec
score = fields.pop("score", None)
variant = Variant(job=job, **fields)
s.add(variant)
if score is not None:
await s.flush()
s.add(Prediction(variant_id=variant.id, model_name="rarelens-pathogenicity",
model_version="demo", score=float(score)))
await s.commit()
return job.id
return case.id, job.id
+18 -18
View File
@@ -8,9 +8,9 @@ from app.config import settings
from app.services import events
async def new_sample(client: AsyncClient) -> str:
async def new_case(client: AsyncClient) -> str:
r = await client.post(
"/api/samples",
"/api/cases",
json={"name": f"s-{uuid.uuid4()}", "vcf_uri": "gs://bucket/x.vcf.gz", "assembly": "GRCh37"},
)
assert r.status_code == 201, r.text
@@ -23,9 +23,9 @@ async def test_local_without_nextflow_fails_fast_with_instructions(
) -> None:
monkeypatch.setattr(settings, "pubsub_topic", None)
monkeypatch.setattr(events.shutil, "which", lambda _: None)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
assert r.status_code == 202
job = r.json()
assert job["status"] == "failed"
@@ -71,9 +71,9 @@ async def test_local_run_is_watched_and_a_crash_marks_the_job_failed(
return FakeProcess(1, b"ERROR ~ VEP cache not found")
monkeypatch.setattr(events.asyncio, "create_subprocess_exec", fake_exec)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
assert r.status_code == 202
await events.drain()
@@ -117,9 +117,9 @@ async def test_pubsub_publishes_to_the_full_topic_path(
monkeypatch.setattr(settings, "pubsub_topic", "vcf-uploaded")
monkeypatch.setattr(settings, "gcp_project", "my-proj")
monkeypatch.setattr(events, "_publisher", lambda: publisher)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
job = r.json()
assert (job["status"], job["workflow_ref"]) == ("running", "pubsub:msg-123")
[(topic, data)] = publisher.published
@@ -134,9 +134,9 @@ async def test_pubsub_failure_marks_the_job_failed(
monkeypatch.setattr(settings, "pubsub_topic", "vcf-uploaded")
monkeypatch.setattr(settings, "gcp_project", "my-proj")
monkeypatch.setattr(events, "_publisher", lambda: FakePublisher(RuntimeError("403 denied")))
sample_id = await new_sample(client)
case_id = await new_case(client)
job = (await client.post(f"/api/samples/{sample_id}/annotate")).json()
job = (await client.post(f"/api/cases/{case_id}/annotate")).json()
assert job["status"] == "failed"
assert "403 denied" in job["log"]
@@ -159,9 +159,9 @@ async def test_the_pipeline_gets_its_own_database_url(
return FakeProcess(0, b"")
monkeypatch.setattr(events.asyncio, "create_subprocess_exec", fake_exec)
sample_id = await new_sample(client)
case_id = await new_case(client)
await client.post(f"/api/samples/{sample_id}/annotate")
await client.post(f"/api/cases/{case_id}/annotate")
await events.drain()
assert launched["env"]["DATABASE_URL"] == "postgresql+asyncpg://u:[email protected]:5432/db"
@@ -183,9 +183,9 @@ async def test_progress_is_recorded_while_the_pipeline_runs(
])
monkeypatch.setattr(events.asyncio, "create_subprocess_exec", fake_exec)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
await events.drain()
job = (await client.get(f"/api/jobs/{r.json()['id']}")).json()
@@ -196,12 +196,12 @@ async def test_progress_is_recorded_while_the_pipeline_runs(
@pytest.mark.usefixtures("db")
async def test_progress_never_overwrites_a_finished_job(client: AsyncClient) -> None:
from app.db import SessionLocal
from app.models import Job, JobStatus, Sample
from app.models import Case, Job, JobStatus
async with SessionLocal() as s:
sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38")
job = Job(sample=sample, status=JobStatus.succeeded)
s.add_all([sample, job])
case = Case(name=f"c-{uuid.uuid4()}", vcf_uri="gs://bucket/x.vcf.gz", assembly="GRCh38")
job = Job(case=case, status=JobStatus.succeeded)
s.add_all([case, job])
await s.commit()
job_id = job.id
+137
View File
@@ -0,0 +1,137 @@
"""The triage workflow: a case with a phenotype, ranked candidates, decisions, a report."""
import uuid
import pytest
from factories import seed_case
from httpx import AsyncClient
NF2_TERMS = [("HP:0000365", "Hearing impairment"), ("HP:0009592", "Vestibular schwannoma")]
CASE_TERMS = [*NF2_TERMS, ("HP:0002321", "Vertigo")]
async def a_case_with_candidates() -> tuple[uuid.UUID, uuid.UUID]:
return await seed_case(
phenotypes=CASE_TERMS,
gene_terms={"NF2": NF2_TERMS},
variants=[
{"id": 1, "gene": "NF2", "pos": 1000, "impact": "HIGH", "score": 0.94,
"clinvar_sig": "pathogenic"},
{"id": 2, "gene": "CHEK2", "pos": 2000, "impact": "MODERATE", "gnomad_af": 0.0004,
"score": 0.55},
{"id": 3, "gene": "TTN", "pos": 3000, "impact": "MODIFIER", "gnomad_af": None},
{"id": 4, "gene": "APOE", "pos": 4000, "impact": "HIGH", "gnomad_af": 0.3},
],
)
@pytest.mark.usefixtures("db")
async def test_a_case_keeps_its_phenotype(client: AsyncClient) -> None:
body = {
"name": "PROBAND-01",
"vcf_uri": "gs://bucket/proband.vcf.gz",
"phenotypes": [{"hpo_id": h, "label": lab} for h, lab in NF2_TERMS],
}
r = await client.post("/api/cases", json=body)
assert r.status_code == 201, r.text
assert [p["label"] for p in r.json()["phenotypes"]] == [lab for _, lab in NF2_TERMS]
listed = (await client.get("/api/cases")).json()
assert listed[0]["name"] == "PROBAND-01"
assert listed[0]["shortlisted"] == 0
@pytest.mark.usefixtures("db")
async def test_the_funnel_shows_the_narrowing(client: AsyncClient) -> None:
case_id, _ = await a_case_with_candidates()
page = (await client.get(f"/api/cases/{case_id}/candidates")).json()
# 4 variants -> 3 rare -> 2 rare and coding -> 1 of those in a phenotype-matched gene
assert page["funnel"] == {"total": 4, "rare": 3, "candidates": 2, "phenotype_matched": 1}
@pytest.mark.usefixtures("db")
async def test_the_phenotype_matched_variant_ranks_first_with_its_reasons(
client: AsyncClient,
) -> None:
case_id, _ = await a_case_with_candidates()
page = (await client.get(f"/api/cases/{case_id}/candidates")).json()
assert [c["variant"]["gene"] for c in page["items"]] == ["NF2", "CHEK2"]
top = page["items"][0]
assert [t["label"] for t in top["matched_terms"]] == ["Hearing impairment", "Vestibular schwannoma"]
assert top["components"]["phenotype"] == pytest.approx(2 / 3, abs=1e-4)
assert top["components"]["rarity"] == 1.0
assert page["weights"]["phenotype"] == 0.35
# ClinVar is evidence, not an input to the rank.
assert top["variant"]["clinvar_sig"] == "pathogenic"
@pytest.mark.usefixtures("db")
async def test_a_reviewer_decides_and_the_decision_sticks(client: AsyncClient) -> None:
case_id, _ = await a_case_with_candidates()
variant_id = (await client.get(f"/api/cases/{case_id}/candidates")).json()["items"][0]["variant"]["id"]
r = await client.post(
f"/api/variants/{variant_id}/decision",
json={"state": "shortlisted", "reason": "fits the phenotype", "note": "confirm by Sanger"},
)
assert r.status_code == 200, r.text
# Changing your mind replaces the decision rather than failing.
r = await client.post(f"/api/variants/{variant_id}/decision", json={"state": "dismissed"})
assert r.status_code == 200
detail = (await client.get(f"/api/variants/{variant_id}")).json()
assert detail["decision"]["state"] == "dismissed"
assert (await client.get("/api/cases")).json()[0]["shortlisted"] == 0
@pytest.mark.usefixtures("db")
async def test_the_variant_panel_carries_the_evidence(client: AsyncClient) -> None:
case_id, _ = await a_case_with_candidates()
variant_id = (await client.get(f"/api/cases/{case_id}/candidates")).json()["items"][0]["variant"]["id"]
detail = (await client.get(f"/api/variants/{variant_id}")).json()
assert detail["variant"]["hgvsp"] is None or isinstance(detail["variant"]["hgvsp"], str)
assert detail["score"] > 0
assert [t["hpo_id"] for t in detail["matched_terms"]] == [h for h, _ in NF2_TERMS]
assert "annotations" in detail
@pytest.mark.usefixtures("db")
async def test_the_report_is_the_decision_trail(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 the phenotype"})
await client.post(f"/api/variants/{items[1]['variant']['id']}/decision",
json={"state": "dismissed", "reason": "gene unrelated to phenotype"})
report = (await client.get(f"/api/cases/{case_id}/report")).json()
assert report["funnel"]["candidates"] == 2
assert [v["variant"]["gene"] for v in report["shortlisted"]] == ["NF2"]
assert report["shortlisted"][0]["decision"]["reason"] == "fits the phenotype"
assert [v["variant"]["gene"] for v in report["dismissed"]] == ["CHEK2"]
assert report["provenance"]["vep_version"] == "113.0"
@pytest.mark.usefixtures("db")
async def test_candidates_can_still_be_filtered(client: AsyncClient) -> None:
case_id, _ = await a_case_with_candidates()
page = (await client.get(f"/api/cases/{case_id}/candidates", params={"gene": "chek2"})).json()
assert [c["variant"]["gene"] for c in page["items"]] == ["CHEK2"]
# The funnel still describes the whole case, not the filtered view.
assert page["funnel"]["total"] == 4
@pytest.mark.usefixtures("db")
async def test_phenotype_search_backs_the_picker(client: AsyncClient) -> None:
await seed_case(gene_terms={"NF2": NF2_TERMS}, variants=[])
found = (await client.get("/api/phenotypes", params={"q": "vestibular"})).json()
assert found == [{"hpo_id": "HP:0009592", "label": "Vestibular schwannoma"}]
@pytest.mark.usefixtures("db")
async def test_candidates_before_the_pipeline_has_run(client: AsyncClient) -> None:
r = await client.post("/api/cases", json={"name": "empty", "vcf_uri": "gs://bucket/x.vcf.gz"})
page = (await client.get(f"/api/cases/{r.json()['id']}/candidates")).json()
assert page["items"] == []
assert page["funnel"]["total"] == 0
+8 -8
View File
@@ -26,9 +26,9 @@ class FakeJobsClient:
return self.result
async def new_sample(client: AsyncClient) -> str:
async def new_case(client: AsyncClient) -> str:
r = await client.post(
"/api/samples",
"/api/cases",
json={"name": f"s-{uuid.uuid4()}", "vcf_uri": "gs://bucket/x.vcf.gz", "assembly": "GRCh37"},
)
assert r.status_code == 201, r.text
@@ -49,9 +49,9 @@ async def test_annotate_executes_the_job_with_pipeline_arguments(
) -> None:
jobs = FakeJobsClient(FakeOperation("projects/p/locations/l/executions/rarelens-nextflow-abc12"))
monkeypatch.setattr(events, "_jobs_client", lambda: jobs)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
assert r.status_code == 202
job = r.json()
assert job["status"] == "running"
@@ -74,9 +74,9 @@ async def test_a_failed_execution_call_marks_the_job_failed(
monkeypatch.setattr(
events, "_jobs_client", lambda: FakeJobsClient(RuntimeError("403 permission denied"))
)
sample_id = await new_sample(client)
case_id = await new_case(client)
job = (await client.post(f"/api/samples/{sample_id}/annotate")).json()
job = (await client.post(f"/api/cases/{case_id}/annotate")).json()
assert job["status"] == "failed"
assert "403 permission denied" in job["log"]
@@ -91,7 +91,7 @@ async def test_cloud_run_job_takes_precedence_over_pubsub(
monkeypatch.setattr(
events, "_publisher", lambda: pytest.fail("Pub/Sub must not be used in the serverless track")
)
sample_id = await new_sample(client)
case_id = await new_case(client)
assert (await client.post(f"/api/samples/{sample_id}/annotate")).json()["status"] == "running"
assert (await client.post(f"/api/cases/{case_id}/annotate")).json()["status"] == "running"
assert len(jobs.requests) == 1
+2 -2
View File
@@ -5,8 +5,8 @@ from httpx import AsyncClient
@pytest.mark.usefixtures("db")
async def test_resources_live_under_api_prefix(client: AsyncClient) -> None:
# The ingress forwards /api/* unchanged, so the app itself must serve that prefix.
assert (await client.get("/api/samples")).status_code == 200
assert (await client.get("/samples")).status_code == 404
assert (await client.get("/api/cases")).status_code == 200
assert (await client.get("/cases")).status_code == 404
async def test_health_stays_at_root_for_probes(client: AsyncClient) -> None:
-18
View File
@@ -1,18 +0,0 @@
import pytest
from httpx import AsyncClient
@pytest.mark.usefixtures("db")
async def test_duplicate_sample_name_is_409_not_500(client: AsyncClient) -> None:
body = {"name": "HG002", "vcf_uri": "gs://bucket/hg002.vcf.gz"}
assert (await client.post("/api/samples", json=body)).status_code == 201
r = await client.post("/api/samples", json=body)
assert r.status_code == 409
assert "HG002" in r.json()["detail"]
async def test_unknown_assembly_is_rejected(client: AsyncClient) -> None:
r = await client.post(
"/api/samples", json={"name": "a", "vcf_uri": "gs://bucket/a.vcf.gz", "assembly": "hg19"}
)
assert r.status_code == 422
+32 -20
View File
@@ -4,11 +4,12 @@ import uuid
import numpy as np
import pandas as pd
import pytest
from factories import seed_case
from httpx import AsyncClient
from sqlalchemy import select
from app.db import SessionLocal
from app.models import Job, JobStatus, Prediction, Sample, Variant
from app.models import JobStatus, Prediction, Variant
from app.services import scoring
@@ -40,20 +41,16 @@ class FakeModel:
return np.full(len(frame), self.score)
async def make_job(status: JobStatus, n_variants: int) -> uuid.UUID:
async with SessionLocal() as s:
sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38")
job = Job(sample=sample, status=status)
s.add_all([sample, job, *(variant(job=job, pos=i + 1) for i in range(n_variants))])
await s.commit()
return job.id
async def make_case(status: JobStatus, n_variants: int) -> tuple[uuid.UUID, uuid.UUID]:
return await seed_case(
status=status,
variants=[{"pos": i + 1, "annotations": {}} for i in range(n_variants)],
)
async def predictions(job_id: uuid.UUID) -> list[Prediction]:
async with SessionLocal() as s:
rows = await s.scalars(
select(Prediction).join(Variant).where(Variant.job_id == job_id)
)
rows = await s.scalars(select(Prediction).join(Variant).where(Variant.job_id == job_id))
return list(rows)
@@ -61,15 +58,15 @@ async def predictions(job_id: uuid.UUID) -> list[Prediction]:
async def test_scoring_twice_updates_instead_of_failing(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
job_id = await make_job(JobStatus.succeeded, n_variants=3)
case_id, job_id = await make_case(JobStatus.succeeded, n_variants=3)
monkeypatch.setattr(scoring, "load_model", lambda: (FakeModel(0.9), "7"))
r = await client.post(f"/api/predictions/score/{job_id}")
r = await client.post(f"/api/cases/{case_id}/score")
assert r.status_code == 200, r.text
assert r.json() == {"job_id": str(job_id), "scored": 3, "model_version": "7"}
assert r.json() == {"case_id": str(case_id), "scored": 3, "model_version": "7"}
monkeypatch.setattr(scoring, "load_model", lambda: (FakeModel(0.2), "8"))
r = await client.post(f"/api/predictions/score/{job_id}")
r = await client.post(f"/api/cases/{case_id}/score")
assert r.status_code == 200, r.text
preds = await predictions(job_id)
@@ -78,13 +75,28 @@ async def test_scoring_twice_updates_instead_of_failing(
@pytest.mark.usefixtures("db")
async def test_scoring_unknown_job_is_404(client: AsyncClient) -> None:
r = await client.post(f"/api/predictions/score/{uuid.uuid4()}")
async def test_scoring_an_unknown_case_is_404(client: AsyncClient) -> None:
r = await client.post(f"/api/cases/{uuid.uuid4()}/score")
assert r.status_code == 404
@pytest.mark.usefixtures("db")
async def test_scoring_unfinished_job_is_409(client: AsyncClient) -> None:
job_id = await make_job(JobStatus.running, n_variants=1)
r = await client.post(f"/api/predictions/score/{job_id}")
async def test_scoring_before_the_annotation_finishes_is_409(client: AsyncClient) -> None:
case_id, _ = await make_case(JobStatus.running, n_variants=1)
r = await client.post(f"/api/cases/{case_id}/score")
assert r.status_code == 409
@pytest.mark.usefixtures("db")
async def test_an_unreachable_model_registry_is_explained_not_a_500(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
case_id, _ = await make_case(JobStatus.succeeded, n_variants=1)
def unreachable() -> tuple:
raise ConnectionError("connection refused to http://localhost:5000")
monkeypatch.setattr(scoring, "load_model", unreachable)
r = await client.post(f"/api/cases/{case_id}/score")
assert r.status_code == 503
assert "connection refused" in r.json()["detail"]
+4 -4
View File
@@ -2,7 +2,7 @@ import pytest
from httpx import AsyncClient
from pydantic import ValidationError
from app.schemas import SampleCreate
from app.schemas import CaseCreate
@pytest.mark.parametrize(
@@ -16,7 +16,7 @@ from app.schemas import SampleCreate
],
)
def test_vcf_uri_accepts_gcs_objects_and_files_under_the_data_root(uri: str) -> None:
assert SampleCreate(name="s", vcf_uri=uri).vcf_uri == uri
assert CaseCreate(name="s", vcf_uri=uri).vcf_uri == uri
@pytest.mark.parametrize(
@@ -36,11 +36,11 @@ def test_vcf_uri_accepts_gcs_objects_and_files_under_the_data_root(uri: str) ->
)
def test_vcf_uri_rejects_everything_else(uri: str) -> None:
with pytest.raises(ValidationError):
SampleCreate(name="s", vcf_uri=uri)
CaseCreate(name="s", vcf_uri=uri)
async def test_bad_vcf_uri_is_422_at_the_api(client: AsyncClient) -> None:
r = await client.post("/api/samples", json={"name": "s", "vcf_uri": "/etc/passwd"})
r = await client.post("/api/cases", json={"name": "s", "vcf_uri": "/etc/passwd"})
assert r.status_code == 422
+101
View File
@@ -0,0 +1,101 @@
"""Ranking is the scientific claim this app makes, so it is tested as pure logic."""
import pytest
from app.models import Prediction, Variant
from app.services import triage
def variant(**kw: object) -> Variant:
fields: dict = {
"id": 1, "chrom": "22", "pos": 100, "ref": "A", "alt": "G",
"gene": "NF2", "impact": "HIGH", "consequence": "frameshift_variant",
"gnomad_af": None, "clinvar_sig": None, "annotations": {},
}
fields.update(kw)
score = fields.pop("score", None)
v = Variant(**fields)
if score is not None:
v.prediction = Prediction(model_name="m", model_version="1", score=float(score))
return v
def test_weights_sum_to_one() -> None:
assert sum(triage.WEIGHTS.values()) == pytest.approx(1.0)
@pytest.mark.parametrize(
("af", "expected"),
[(None, 1.0), (0.0, 1.0), (0.00005, 0.8), (0.0005, 0.5), (0.005, 0.2), (0.05, 0.0)],
)
def test_rarity_rewards_absence_from_gnomad(af: float | None, expected: float) -> None:
assert triage.rarity_score(af) == expected
@pytest.mark.parametrize(
("impact", "expected"),
[("HIGH", 1.0), ("MODERATE", 0.6), ("LOW", 0.2), ("MODIFIER", 0.0), (None, 0.0), ("?", 0.0)],
)
def test_consequence_severity(impact: str | None, expected: float) -> None:
assert triage.consequence_score(impact) == expected
def test_phenotype_match_is_the_fraction_of_the_patients_terms() -> None:
gene_terms = {"NF2": {"HP:0000365", "HP:0009592"}}
case_terms = ["HP:0000365", "HP:0009592", "HP:0002321", "HP:0000598"]
score, matched = triage.phenotype_score("NF2", case_terms, gene_terms)
assert score == 0.5
assert matched == ["HP:0000365", "HP:0009592"]
def test_phenotype_match_is_zero_for_genes_hpo_has_never_annotated() -> None:
assert triage.phenotype_score("NOVEL1", ["HP:0000365"], {}) == (0.0, [])
def test_phenotype_match_is_zero_when_no_phenotype_was_entered() -> None:
assert triage.phenotype_score("NF2", [], {"NF2": {"HP:0000365"}}) == (0.0, [])
def test_the_funnel_counts_each_narrowing_step() -> None:
variants = [
variant(id=1, gnomad_af=None, impact="HIGH", gene="NF2"), # rare, coding, matched
variant(id=2, gnomad_af=0.0002, impact="MODERATE", gene="CHEK2"), # rare, coding
variant(id=3, gnomad_af=0.3, impact="HIGH", gene="NF2"), # common
variant(id=4, gnomad_af=None, impact="MODIFIER", gene="NF2"), # rare, non-coding
]
funnel = triage.funnel(variants, case_terms=["HP:0000365"], gene_terms={"NF2": {"HP:0000365"}})
assert (funnel.total, funnel.rare, funnel.candidates, funnel.phenotype_matched) == (4, 3, 2, 1)
def test_the_diagnosis_outranks_the_noise() -> None:
gene_terms = {"NF2": {"HP:0000365", "HP:0009592"}}
case_terms = ["HP:0000365", "HP:0009592"]
diagnosis = variant(id=1, gene="NF2", impact="HIGH", gnomad_af=None, score=0.94)
plausible = variant(id=2, gene="CHEK2", impact="MODERATE", gnomad_af=0.0004, score=0.55)
noise = variant(id=3, gene="TTN", impact="MODERATE", gnomad_af=0.0009, score=0.10)
ranked = triage.rank([noise, plausible, diagnosis], case_terms, gene_terms)
assert [c.variant.id for c in ranked] == [1, 2, 3]
top = ranked[0]
assert top.matched_terms == case_terms
assert top.components["phenotype"] == 1.0
assert top.score == pytest.approx(0.35 + 0.25 + 0.20 + 0.20 * 0.94)
def test_an_unscored_variant_still_ranks_and_says_so() -> None:
[candidate] = triage.rank([variant(id=1, gnomad_af=None)], [], {})
assert candidate.components["model"] == 0.0
assert candidate.scored is False
def test_common_and_non_coding_variants_are_not_candidates() -> None:
variants = [
variant(id=1, gnomad_af=0.2, impact="HIGH"),
variant(id=2, gnomad_af=None, impact="MODIFIER"),
]
assert triage.rank(variants, [], {}) == []
def test_ranking_is_deterministic_for_equal_scores() -> None:
a = variant(id=7, gene="AAA", chrom="1", pos=10, gnomad_af=None)
b = variant(id=3, gene="BBB", chrom="1", pos=10, gnomad_af=None)
assert [c.variant.id for c in triage.rank([a, b], [], {})] == [3, 7]
-46
View File
@@ -1,46 +0,0 @@
import uuid
import pytest
from factories import seed_job
from httpx import AsyncClient
@pytest.mark.parametrize(
"paging", [{"limit": 0}, {"limit": -1}, {"limit": 501}, {"offset": -1}]
)
async def test_out_of_range_paging_is_422_not_500(client: AsyncClient, paging: dict) -> None:
r = await client.get("/api/variants", params={"job_id": str(uuid.uuid4()), **paging})
assert r.status_code == 422
async def positions(client: AsyncClient, job_id: uuid.UUID) -> list[tuple[str, int, str]]:
r = await client.get("/api/variants", params={"job_id": str(job_id)})
assert r.status_code == 200, r.text
return [(v["chrom"], v["pos"], v["alt"]) for v in r.json()["items"]]
@pytest.mark.usefixtures("db")
async def test_chromosomes_sort_naturally(client: AsyncClient) -> None:
job_id = await seed_job([{"chrom": c} for c in ["10", "MT", "2", "X", "chr3", "1", "Y"]])
assert [c for c, _, _ in await positions(client, job_id)] == [
"1", "2", "chr3", "10", "X", "Y", "MT",
]
@pytest.mark.usefixtures("db")
async def test_same_position_keeps_insertion_order_across_pages(client: AsyncClient) -> None:
# Split multiallelics share chrom/pos; without a tiebreak, pages can repeat or skip rows.
job_id = await seed_job([{"pos": 5, "alt": a} for a in "CGT"])
assert [a for _, _, a in await positions(client, job_id)] == ["C", "G", "T"]
@pytest.mark.usefixtures("db")
async def test_long_vep_strings_are_stored(client: AsyncClient) -> None:
clin_sig = ",".join(["conflicting_classifications_of_pathogenicity"] * 8)
consequence = (
"splice_region_variant&splice_polypyrimidine_tract_variant&intron_variant"
"&NMD_transcript_variant&non_coding_transcript_variant"
)
job_id = await seed_job([{"clinvar_sig": clin_sig, "consequence": consequence}])
[v] = (await client.get("/api/variants", params={"job_id": str(job_id)})).json()["items"]
assert (v["clinvar_sig"], v["consequence"]) == (clin_sig, consequence)