Files
rarelens/api/app/models.py
T
Kemal Yaylali 07a01715fd 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.
2026-09-12 08:30:44 +01:00

134 lines
6.0 KiB
Python

"""SQLAlchemy 2.0 declarative models.
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,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class JobStatus(str, enum.Enum):
queued = "queued"
running = "running"
succeeded = "succeeded"
failed = "failed"
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="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)
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))
case: Mapped[Case] = relationship(back_populates="jobs")
variants: Mapped[list["Variant"]] = relationship(back_populates="job")
class Variant(Base):
__tablename__ = "variants"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
job_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("jobs.id", ondelete="CASCADE"), index=True)
chrom: Mapped[str] = mapped_column(String(10), index=True)
pos: Mapped[int] = mapped_column(Integer, index=True)
ref: Mapped[str] = mapped_column(Text)
alt: Mapped[str] = mapped_column(Text)
gene: Mapped[str | None] = mapped_column(String(60), index=True)
consequence: Mapped[str | None] = mapped_column(Text) # "&"-joined VEP terms
impact: Mapped[str | None] = mapped_column(String(20))
hgvsc: Mapped[str | None] = mapped_column(Text)
hgvsp: Mapped[str | None] = mapped_column(Text)
gnomad_af: Mapped[float | None] = mapped_column(Float)
clinvar_sig: Mapped[str | None] = mapped_column(Text) # ","-joined co-located ClinVar terms
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):
__tablename__ = "predictions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
variant_id: Mapped[int] = mapped_column(ForeignKey("variants.id", ondelete="CASCADE"), unique=True)
model_name: Mapped[str] = mapped_column(String(80))
model_version: Mapped[str] = mapped_column(String(40))
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")