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:
+63
-7
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user