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
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: up down clean migrate test lint data loader pipeline annotate images kind serverless-deploy serverless-destroy gcp-configure gcp-secrets
.PHONY: up down clean migrate test lint data hpo demo-case loader pipeline annotate images kind serverless-deploy serverless-destroy gcp-configure gcp-secrets
VCF ?= data/example.vcf.gz
TAG ?= latest
@@ -30,6 +30,12 @@ lint:
data: ## download the public demo slice: GIAB HG002 + ClinVar, chr22 (see docs/data.md)
scripts/fetch-demo-data.sh
hpo: ## load HPO gene-to-phenotype annotations, which the ranking matches against
scripts/load-hpo.py
demo-case: ## build the simulated proband: GIAB background + one ClinVar pathogenic variant
scripts/make-demo-case.sh
loader:
docker build -t rarelens/loader:dev -f pipeline/loader.Dockerfile pipeline
+11 -4
View File
@@ -1,8 +1,11 @@
# rarelens
A small, end-to-end variant interpretation platform for rare genetic disease research.
Scientists upload a VCF, a Nextflow workflow annotates it with Ensembl VEP, a machine
learning model scores each variant, and results are browsable in a web app.
A case is a proband: a VCF plus the patient's phenotype (HPO terms). A Nextflow workflow
annotates the variants with Ensembl VEP, a model scores each one, and the app narrows
thousands of variants to a handful of candidates ranked against that phenotype — each
carrying the evidence for its rank, and each able to be shortlisted or dismissed with a
reason that ends up in a case report.
This repository is a **self-training lab**. It exists so that one engineer can learn, in
public, how a modern life-sciences platform is built end to end: full-stack application,
@@ -28,11 +31,15 @@ clinical tool and makes no diagnostic claims.
```bash
make up # postgres + api + web + mlflow via docker-compose
make migrate # alembic upgrade head
make data # real public data: GIAB HG002 + ClinVar, chr22 (needs bcftools)
make hpo # HPO gene-to-phenotype annotations: what the ranking matches against
make demo-case # a simulated proband: GIAB background + one ClinVar pathogenic variant
make test # api, ml, loader and web tests (no Docker needed for the DB tests)
```
Then open http://localhost:5173.
Then open http://localhost:5173, create a case pointing at `data/proband-simulated.vcf.gz`,
give it the phenotype of the planted disease (for the default NF2 case: bilateral vestibular
schwannoma, sensorineural hearing impairment, tinnitus, meningioma, cataract), and analyse it.
The planted variant should come back ranked first.
The docker-compose API has no Nextflow, so "Run VEP annotation" marks the job failed with the
command to run instead. With Nextflow and Docker on the host, a VEP cache in `pipeline/cache/vep`
@@ -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
+51 -48
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("/{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())
# 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.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.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
)
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)
# 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(),
},
)
return VariantPage(
items=[VariantOut.model_validate(v) for v in rows],
total=total or 0,
limit=limit,
offset=offset,
)
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)
+1 -1
View File
@@ -1,6 +1,6 @@
# Test data
`make data` fetches the demo slice automatically. Provenance, licences, citations and the
`make data` fetches the demo slice and `make demo-case` builds the simulated proband. Provenance, licences, citations and the
evaluation plan live in [../docs/data.md](../docs/data.md).
No patient data. Use public sources only:
+30 -1
View File
@@ -2,8 +2,9 @@
```mermaid
flowchart LR
U[Scientist] -->|browser| W[SvelteKit web]
U[Scientist] -->|phenotype + VCF| W[SvelteKit web]
W -->|REST /api| A[FastAPI]
H[(HPO gene-phenotype annotations)] --> A
A --> P[(PostgreSQL / Cloud SQL)]
A -->|publish vcf-uploaded| Q[Pub/Sub]
Q --> E[Argo Events sensor]
@@ -12,6 +13,7 @@ flowchart LR
B -->|reads VCF, VEP cache| G[(GCS bucket)]
B -->|writes variants, marks job succeeded| P
AW -.->|exit handler marks job failed| P
A -->|rank: phenotype, rarity, consequence, model| C[Ranked candidates -> decisions -> report]
A -->|models:/rarelens-pathogenicity@production| M[MLflow registry]
T[ml/train.py] --> M
GH[GitHub Actions] -->|images via WIF| AR[Artifact Registry]
@@ -19,6 +21,33 @@ flowchart LR
R --> CD[ArgoCD] --> K[GKE Autopilot]
```
## The triage model
A **case** is a proband: a VCF plus the HPO terms observed in that patient. Annotation produces
variants; the model scores them; ranking then answers the only question that matters — which few
variants could explain *this* phenotype.
Rarity (<0.1% in gnomAD) and consequence (HIGH or MODERATE) *filter*, which is the usual first
pass. Phenotype only *ranks*: a real diagnosis can sit in a gene nobody has annotated yet, and
filtering on phenotype would hide exactly that case. The rank is a weighted sum whose parts are
shown next to every candidate (`app/services/triage.py`):
| Component | Weight |
|---|---|
| phenotype terms of this patient annotated to the gene | 0.35 |
| rarity in gnomAD | 0.25 |
| consequence severity | 0.20 |
| model P(pathogenic) | 0.20 |
**ClinVar is deliberately not an input.** It sits beside the result as independent confirmation, so
the demo never ranks a variant highly merely because ClinVar already called it pathogenic. On the
simulated NF2 case the planted variant ranks first on phenotype, rarity and consequence alone, and
ClinVar agrees afterwards.
Each candidate can be shortlisted or dismissed with a reason and a note; the case report is that
decision trail plus the funnel counts and the provenance (VEP version, model version, run time).
There is no authentication, so decisions are shared by everyone who opens the demo.
## Two deployment tracks
The same images and the same pipeline, deployed two ways (`infra/terraform/variables.tf`):
+19
View File
@@ -28,6 +28,7 @@ benchmarks variant callers against, so it is both realistic and unambiguously sh
| **gnomAD** v4 | allele frequency feature and filter | `gs://gcp-public-data--gnomad`, `s3://gnomad-public-us-east-1` | free use, no restriction | Chen et al., *Nature* 625:92100, 2024. [10.1038/s41586-023-06045-0](https://doi.org/10.1038/s41586-023-06045-0); Karczewski et al., *Nature* 581:434443, 2020. [10.1038/s41586-020-2308-7](https://doi.org/10.1038/s41586-020-2308-7) |
| **1000 Genomes** 30x | optional cohort/trio data | EBI FTP, `s3://1000genomes` | fully open, no access restriction | Byrska-Bishop et al., *Cell* 185(18):34263440.e19, 2022. [10.1016/j.cell.2022.08.004](https://doi.org/10.1016/j.cell.2022.08.004) |
| **MANE Select** | one transcript per gene, if transcript choice ever matters | Ensembl/RefSeq | open | Morales et al., *Nature* 604:310315, 2022. [10.1038/s41586-022-04558-8](https://doi.org/10.1038/s41586-022-04558-8) |
| **Human Phenotype Ontology** gene-to-phenotype | what the phenotype half of the ranking matches against (`make hpo`) | `purl.obolibrary.org/obo/hp/hpoa/genes_to_phenotype.txt` | free to use with attribution | Gargano et al., *Nucleic Acids Res* 52(D1):D1333D1346, 2024. [10.1093/nar/gkad1005](https://doi.org/10.1093/nar/gkad1005) |
## Tools and scores
@@ -40,6 +41,24 @@ benchmarks variant callers against, so it is both realistic and unambiguously sh
Neither score is required: `rarelens_ml.features` treats a missing CADD or AlphaMissense value as
NaN and LightGBM handles it, so the pipeline runs without the plugin data.
## The simulated proband
`make demo-case` builds `data/proband-simulated.vcf.gz`: real GIAB HG002 variants as background
plus one real ClinVar 2-star pathogenic variant in a disease gene (NF2 by default, giving
neurofibromatosis type 2). Spiking a known variant into a public genome is how phenotype-driven
triage tools are benchmarked, every input is public, and the file's header says SIMULATED. It is
not a patient, and no part of it is invented: both the background and the planted variant are real
published records.
The point of it is that the case has a right answer, so the ranking can be checked rather than
admired. Give the case the phenotype of the planted disease and the planted variant should rank
first — on phenotype, rarity and consequence, with ClinVar agreeing only afterwards.
**Caveat when running without a VEP cache.** `VEP_DATABASE=true` queries Ensembl's public database
instead of the 25 GB cache. It returns no gnomAD frequencies, so every variant looks absent from
gnomAD and the rarity term stops discriminating. Fine for showing the mechanics; use the cache for
anything you would quote.
## Evaluating the model honestly
The model trains on ClinVar labels and is scored on ClinVar-labelled variants, which is exactly
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Load HPO's gene-to-phenotype annotations into the gene_phenotypes table.
This is the reference data the ranking matches a case's phenotype against. Source file:
https://purl.obolibrary.org/obo/hp/hpoa/genes_to_phenotype.txt (HPO release, ~20 MB).
Cite the Human Phenotype Ontology when showing results; see docs/data.md.
"""
import argparse
import csv
import io
import os
import sys
import urllib.request
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
URL = "https://purl.obolibrary.org/obo/hp/hpoa/genes_to_phenotype.txt"
def rows(handle: io.TextIOBase) -> list[tuple[str, str, str]]:
"""Unique (gene, term) pairs; the file repeats them once per associated disease."""
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str, str]] = []
for row in csv.DictReader(handle, delimiter="\t"):
gene, hpo_id, name = row["gene_symbol"], row["hpo_id"], row["hpo_name"]
if not gene or not hpo_id or (gene, hpo_id) in seen:
continue
seen.add((gene, hpo_id))
out.append((gene[:60], hpo_id[:20], name[:200]))
return out
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--url", default=URL)
p.add_argument("--file", help="use a local copy instead of downloading")
a = p.parse_args()
url = os.environ.get("DATABASE_URL")
if not url:
sys.exit("DATABASE_URL is not set")
if a.file:
with open(a.file) as fh:
annotations = rows(fh)
else:
print(f"downloading {a.url}", file=sys.stderr)
with urllib.request.urlopen(a.url) as response: # noqa: S310 - fixed HPO release URL
annotations = rows(io.TextIOWrapper(response, encoding="utf-8"))
print(f"{len(annotations)} gene/term pairs", file=sys.stderr)
engine = create_engine(make_url(url).set(drivername="postgresql+psycopg"))
with engine.begin() as conn:
conn.execute(text("TRUNCATE gene_phenotypes RESTART IDENTITY"))
cursor = conn.connection.cursor()
with cursor.copy("COPY gene_phenotypes (gene_symbol, hpo_id, hpo_name) FROM STDIN") as copy:
for row in annotations:
copy.write_row(row)
print(f"loaded {len(annotations)} annotations", file=sys.stderr)
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Build a SIMULATED proband VCF: real GIAB HG002 variants as background, plus one real ClinVar
# pathogenic variant in a disease gene, which becomes the diagnosis the triage should find.
#
# Spiking a known variant into a public genome is how phenotype-driven triage tools are
# benchmarked. Every input here is public and openly licensed, and this is not a real patient.
# Provenance and citations: docs/data.md
set -euo pipefail
IMAGE=${BCFTOOLS_IMAGE:-quay.io/biocontainers/bcftools:1.20--h8b25389_0}
GENE=${GENE:-NF2}
REGION=${REGION:-chr22:20000000-31000000}
BACKGROUND=${BACKGROUND:-12} # kept small: VEP's database mode is slow per variant
OUT_DIR=${OUT_DIR:-data}
CLINVAR=${CLINVAR:-https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/clinvar.vcf.gz}
GIAB=${GIAB:-https://ftp-trace.ncbi.nlm.nih.gov/ReferenceSamples/giab/release/AshkenazimTrio/HG002_NA24385_son/NISTv4.2.1/GRCh38/HG002_GRCh38_1_22_v4.2.1_benchmark.vcf.gz}
mkdir -p "$OUT_DIR"
docker run --rm -v "$PWD/$OUT_DIR:/out" "$IMAGE" bash -eu -c "
echo '==> background: GIAB HG002 $REGION' >&2
bcftools view -H -r '$REGION' '$GIAB' \
| awk '\$5 !~ /,/ && length(\$4) < 20 && length(\$5) < 20' \
| awk 'NR % 149 == 0' \
| head -n $BACKGROUND \
| awk -v OFS='\t' '{ sub(/^chr/, \"\", \$1); print \$1, \$2, \".\", \$4, \$5, \".\", \"PASS\", \".\" }' \
> /out/_background.tsv
echo '==> diagnosis: a ClinVar pathogenic variant in $GENE' >&2
bcftools view -H -r 22 -i 'INFO/CLNSIG ~ \"Pathogenic\" && INFO/GENEINFO ~ \"${GENE}:\" && INFO/CLNREVSTAT ~ \"multiple_submitters\"' '$CLINVAR' \
| awk 'length(\$4) < 20 && length(\$5) < 20' \
| head -n 1 \
| awk -v OFS='\t' '{ print \$1, \$2, \".\", \$4, \$5, \".\", \"PASS\", \".\" }' \
> /out/_spike.tsv
test -s /out/_background.tsv || { echo 'no background variants found' >&2; exit 1; }
test -s /out/_spike.tsv || { echo 'no 2-star pathogenic ClinVar variant found for $GENE' >&2; exit 1; }
{
printf '##fileformat=VCFv4.2\n'
printf '##source=rarelens SIMULATED proband: GIAB HG002 background + one ClinVar pathogenic %s variant\n' '$GENE'
printf '##contig=<ID=22,length=50818468>\n'
printf '#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n'
sort -k2,2n /out/_background.tsv /out/_spike.tsv
} | bgzip > /out/proband-simulated.vcf.gz
tabix -f -p vcf /out/proband-simulated.vcf.gz
rm -f /out/_background.tsv
"
echo
echo "wrote $OUT_DIR/proband-simulated.vcf.gz"
echo "the planted diagnosis (chrom pos ref alt):"
awk '{print " " $1, $2, $4, $5}' "$OUT_DIR/_spike.tsv"
rm -f "$OUT_DIR/_spike.tsv"
+87 -1
View File
@@ -12,7 +12,7 @@
}
html { background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 17px; }
body { margin: 0; }
main { max-width: 1100px; margin: 0 auto; padding: 2.5rem 1.5rem; }
main { max-width: 1280px; margin: 0 auto; padding: 2.5rem 1.5rem; }
h1 { font-weight: 600; font-size: 2rem; letter-spacing: -0.01em; margin: 0 0 0.5rem; }
h2 { font-weight: 600; font-size: 1.25rem; margin: 2rem 0 0.75rem; }
p.lede { color: var(--ink-soft); max-width: 60ch; margin: 0 0 2rem; }
@@ -40,3 +40,89 @@ td.coord, td.hgvs { font-family: var(--mono); font-size: 0.85rem; } /* aligned
.spinner { animation: spin 0.9s linear infinite; }
}
@keyframes spin { to { transform: rotate(360deg); } }
/* --- triage ------------------------------------------------------------------------------- */
.muted { color: var(--ink-soft); }
.actions { display: flex; align-items: center; gap: 1rem; }
.reportlink { font-weight: 600; }
.provenance { font-size: 0.85rem; margin: 0.35rem 0 0; }
.casehead { margin-bottom: 1.25rem; }
.casehead h1 { margin-bottom: 0.4rem; }
.log { white-space: pre-wrap; background: white; border: 1px solid var(--line); padding: 0.75rem;
max-height: 14rem; overflow: auto; font-size: 0.8rem; }
.chips { display: inline-flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.chip { font-size: 0.78rem; padding: 0.12rem 0.5rem; border-radius: 999px; border: 1px solid var(--line);
background: white; color: var(--ink-soft); white-space: nowrap; }
.chip.match { background: var(--plum-soft); border-color: transparent; color: var(--plum); font-weight: 600; }
.chip.rare { border-color: var(--ink-soft); color: var(--ink); }
.chip.impact { background: var(--ink); border-color: var(--ink); color: white; }
.chip.model { border-style: dashed; color: var(--ink); }
.chip.clinvar { background: #fdf1df; border-color: transparent; color: var(--amber); font-weight: 600; }
.chipx { background: none; border: none; color: inherit; padding: 0 0 0 0.3rem; cursor: pointer; font: inherit; }
/* 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-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; }
.funnel-bar { display: block; height: 100%; background: var(--plum); opacity: 0.75; }
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-bottom: 1rem; }
.triage { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.25rem; align-items: start; }
@media (min-width: 62rem) { .triage { grid-template-columns: minmax(0, 1fr) 24rem; } }
.candidate { display: grid; grid-template-columns: 2rem minmax(10rem, 16rem) 1fr auto auto; gap: 0.75rem;
align-items: center; width: 100%; text-align: left; background: white; color: var(--ink);
border: 1px solid var(--line); border-left: 3px solid transparent; border-radius: 4px;
padding: 0.6rem 0.75rem; margin-bottom: 0.4rem; font: inherit; cursor: pointer; }
.candidate:hover { border-left-color: var(--plum-soft); }
.candidate.selected { border-left-color: var(--plum); background: #fbf7fa; }
.candidate .rank { font-family: var(--mono); color: var(--ink-soft); }
.candidate .who { display: flex; flex-direction: column; gap: 0.1rem; }
.candidate .who .coord, .candidate .who .hgvs { font-size: 0.78rem; color: var(--ink-soft); }
.rankscore { display: flex; align-items: center; gap: 0.5rem; }
.scorebar { width: 4rem; height: 0.5rem; background: var(--line); border-radius: 2px; overflow: hidden; }
.scorebar span { display: block; height: 100%; background: var(--plum); }
.scorenum { font-family: var(--mono); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.decision { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; }
.decision.shortlisted { color: var(--plum); }
.decision.dismissed { color: var(--ink-soft); }
.panel { background: white; border: 1px solid var(--line); border-radius: 4px; padding: 1rem; position: sticky; top: 1rem; }
.panel-head { display: flex; justify-content: space-between; align-items: start; gap: 0.5rem; }
.panel h3 { margin: 0 0 0.5rem; font-size: 1.05rem; }
.panel h4 { margin: 1.1rem 0 0.4rem; font-size: 0.78rem; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--ink-soft); }
.panel input, .panel textarea { width: 100%; margin-bottom: 0.5rem; font-family: inherit; }
.components { border: none; background: none; }
.components th { font-weight: 400; color: var(--ink-soft); padding: 0.15rem 0.5rem 0.15rem 0; }
.components td { padding: 0.15rem 0; font-size: 0.82rem; text-align: right; }
.evidence { display: grid; grid-template-columns: 7rem 1fr; gap: 0.2rem 0.5rem; margin: 0; font-size: 0.88rem; }
.evidence dt { color: var(--ink-soft); }
.evidence dd { margin: 0; }
.links { display: flex; gap: 0.75rem; font-size: 0.85rem; }
.decide { display: flex; gap: 0.5rem; }
.newcase { display: grid; gap: 0.6rem; margin-bottom: 1.5rem; }
.newcase .row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.newcase button { justify-self: start; }
.picker { position: relative; display: grid; gap: 0.4rem; }
.picker .results { list-style: none; margin: 0; padding: 0.25rem; background: white;
border: 1px solid var(--line); border-radius: 4px; max-height: 12rem; overflow: auto; }
.picker .results button { width: 100%; text-align: left; border: none; padding: 0.3rem 0.4rem; }
.picker .results button:hover { background: var(--plum-soft); }
.caselist { list-style: none; padding: 0; margin: 0; }
.caselist li { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; padding: 0.7rem 0.75rem;
background: white; border: 1px solid var(--line); border-radius: 4px; margin-bottom: 0.4rem; }
.casename { font-weight: 600; min-width: 10rem; }
.report { background: white; border: 1px solid var(--line); padding: 2rem; }
.reportitem { border-top: 1px solid var(--line); padding-top: 0.75rem; margin-top: 0.75rem; }
.reportitem h3 { margin: 0 0 0.4rem; }
.dismissed { color: var(--ink-soft); }
.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; } }
+76 -13
View File
@@ -1,22 +1,71 @@
// Dynamic, not static: the same image serves /api behind the ingress and a full URL elsewhere.
import { env } from '$env/dynamic/public';
import { variantQuery, type VariantFilters } from './query';
import { candidateQuery, type CandidateFilters } from './candidates';
export type Assembly = 'GRCh38' | 'GRCh37';
export type Sample = { id: string; name: string; vcf_uri: string; assembly: Assembly; created_at: string };
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed';
export type PhenotypeTerm = { hpo_id: string; label: string };
export type Job = {
id: string; sample_id: string; status: 'queued' | 'running' | 'succeeded' | 'failed';
log: string | null; created_at: string; finished_at: string | null;
id: string; case_id: string; status: JobStatus;
vep_version: string | null; log: string | null; created_at: string; finished_at: string | null;
};
export type Case = {
id: string; name: string; vcf_uri: string; assembly: Assembly; created_at: string;
phenotypes: PhenotypeTerm[]; latest_job: Job | null; shortlisted: number;
};
export type Variant = {
id: number; chrom: string; pos: number; ref: string; alt: string; gene: string | null;
consequence: string | null; impact: string | null; hgvsc: string | null; hgvsp: string | null;
gnomad_af: number | null; clinvar_sig: string | null; prediction: { score: number; model_version: string } | null;
gnomad_af: number | null; clinvar_sig: string | null;
prediction: { score: number; model_name: string; model_version: string } | null;
};
export type DecisionState = 'shortlisted' | 'dismissed';
export type Decision = {
state: DecisionState; reason: string | null; note: string | null; decided_at: string;
};
export type Candidate = {
variant: Variant;
score: number;
components: Record<string, number>;
matched_terms: PhenotypeTerm[];
scored: boolean;
decision: Decision | null;
};
export type VariantDetail = Candidate & { annotations: Record<string, string> };
export type Funnel = { total: number; rare: number; candidates: number; phenotype_matched: number };
export type CandidatePage = {
funnel: Funnel;
weights: Record<string, number>;
items: Candidate[];
total: number;
limit: number;
offset: number;
};
export type Report = {
case: Case;
funnel: Funnel;
generated_at: string;
provenance: {
job_id: string | null; vep_version: string | null; finished_at: string | null;
model_name: string | null; model_version: string | null;
};
shortlisted: Candidate[];
dismissed: Candidate[];
};
export type VariantPage = { items: Variant[]; total: number; limit: number; offset: number };
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(`${env.PUBLIC_API_URL ?? '/api'}${path}`, { headers: { 'content-type': 'application/json' }, ...init });
const r = await fetch(`${env.PUBLIC_API_URL ?? '/api'}${path}`, {
headers: { 'content-type': 'application/json' },
...init
});
if (!r.ok) throw new Error(await errorReason(r));
return r.json() as Promise<T>;
}
@@ -34,12 +83,26 @@ async function errorReason(r: Response): Promise<string> {
return fallback;
}
const post = (body?: unknown): RequestInit => ({
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body)
});
export const api = {
samples: () => req<Sample[]>('/samples'),
createSample: (body: Pick<Sample, 'name' | 'vcf_uri' | 'assembly'>) =>
req<Sample>('/samples', { method: 'POST', body: JSON.stringify(body) }),
jobsForSample: (sampleId: string) => req<Job[]>(`/samples/${sampleId}/jobs`),
annotate: (sampleId: string) => req<Job>(`/samples/${sampleId}/annotate`, { method: 'POST' }),
cases: () => req<Case[]>('/cases'),
createCase: (body: { name: string; vcf_uri: string; assembly: Assembly; phenotypes: PhenotypeTerm[] }) =>
req<Case>('/cases', post(body)),
case: (id: string) => req<Case>(`/cases/${id}`),
annotate: (id: string) => req<Job>(`/cases/${id}/annotate`, post()),
score: (id: string) => req<{ scored: number; model_version: string }>(`/cases/${id}/score`, post()),
job: (id: string) => req<Job>(`/jobs/${id}`),
variants: (filters: VariantFilters) => req<VariantPage>(`/variants?${variantQuery(filters)}`)
candidates: (caseId: string, filters: CandidateFilters = {}) => {
const query = candidateQuery(filters);
return req<CandidatePage>(`/cases/${caseId}/candidates${query ? `?${query}` : ''}`);
},
variant: (id: number) => req<VariantDetail>(`/variants/${id}`),
decide: (id: number, body: { state: DecisionState; reason?: string; note?: string }) =>
req<Decision>(`/variants/${id}/decision`, post(body)),
report: (caseId: string) => req<Report>(`/cases/${caseId}/report`),
phenotypes: (q: string) => req<PhenotypeTerm[]>(`/phenotypes?q=${encodeURIComponent(q)}`)
};
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { candidateQuery, evidenceChips, funnelSteps, scoreBarPercent } from './candidates';
import type { Candidate, Funnel } from './api';
const candidate = (over: Partial<Candidate> = {}): Candidate => ({
variant: {
id: 1, chrom: '22', pos: 100, ref: 'A', alt: 'G', gene: 'NF2',
consequence: 'frameshift_variant', impact: 'HIGH', hgvsc: null, hgvsp: 'p.Ser486fs',
gnomad_af: null, clinvar_sig: null, prediction: null
},
score: 0.88,
components: { phenotype: 0.67, rarity: 1, consequence: 1, model: 0.94 },
matched_terms: [{ hpo_id: 'HP:0000365', label: 'Hearing impairment' }],
scored: true,
decision: null,
...over
});
describe('evidenceChips', () => {
it('leads with the phenotype match, because that is what makes it a candidate', () => {
const [first] = evidenceChips(candidate(), 3);
expect(first).toEqual({ label: '1/3 phenotype terms', tone: 'match' });
});
it('says plainly when the gene has no link to the phenotype', () => {
const chips = evidenceChips(candidate({ matched_terms: [] }), 3);
expect(chips[0]).toEqual({ label: 'no phenotype match', tone: 'muted' });
});
it('calls an absent variant absent rather than showing a zero', () => {
expect(evidenceChips(candidate(), 3)[1]).toEqual({ label: 'absent from gnomAD', tone: 'rare' });
});
it('shows a frequency when there is one', () => {
const c = candidate();
c.variant.gnomad_af = 0.00042;
expect(evidenceChips(c, 3)[1].label).toBe('gnomAD 4.2e-4');
});
it('reads the consequence without the underscores', () => {
expect(evidenceChips(candidate(), 3)[2]).toEqual({ label: 'frameshift variant', tone: 'impact' });
});
it('marks an unscored variant instead of implying a zero score', () => {
const chips = evidenceChips(candidate({ scored: false }), 3);
expect(chips[3]).toEqual({ label: 'unscored', tone: 'muted' });
});
it('shows ClinVar last, as confirmation rather than an input', () => {
const c = candidate();
c.variant.clinvar_sig = 'pathogenic';
expect(evidenceChips(c, 3).at(-1)).toEqual({ label: 'ClinVar: pathogenic', tone: 'clinvar' });
});
});
describe('funnelSteps', () => {
it('describes each narrowing step in order', () => {
const funnel: Funnel = { total: 1284, rare: 41, candidates: 12, phenotype_matched: 6 };
expect(funnelSteps(funnel).map((s) => `${s.label} ${s.value}`)).toEqual([
'variants called 1284',
'rare (<0.1%) 41',
'coding candidates 12',
'in phenotype-matched genes 6'
]);
});
});
describe('scoreBarPercent', () => {
it('maps the 0..1 score onto a bar width', () => {
expect(scoreBarPercent(0.884)).toBe(88);
expect(scoreBarPercent(0)).toBe(0);
expect(scoreBarPercent(1)).toBe(100);
});
});
describe('candidateQuery', () => {
it('sends only the filters that are set', () => {
expect(candidateQuery({})).toBe('');
expect(candidateQuery({ gene: ' nf2 ', impact: 'HIGH', state: 'undecided' })).toBe(
'gene=nf2&impact=HIGH&state=undecided'
);
});
it('drops a cleared allele frequency box', () => {
expect(candidateQuery({ maxAf: null })).toBe('');
expect(candidateQuery({ maxAf: 0.01 })).toBe('max_af=0.01');
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { Candidate, Funnel } from './api';
export type Chip = {
label: string;
tone: 'match' | 'rare' | 'impact' | 'model' | 'clinvar' | 'muted';
};
export type CandidateFilters = {
gene?: string;
impact?: string;
maxAf?: number | string | null;
state?: 'shortlisted' | 'dismissed' | 'undecided' | '';
limit?: number;
offset?: number;
};
const formatAf = (af: number) => af.toExponential(1);
/**
* The reasons this variant is a candidate, in the order a reviewer reads them. ClinVar comes last
* and is styled apart, because it confirms the ranking rather than feeding it.
*/
export function evidenceChips(candidate: Candidate, caseTermCount: number): Chip[] {
const { variant } = candidate;
const chips: Chip[] = [
candidate.matched_terms.length
? { label: `${candidate.matched_terms.length}/${caseTermCount} phenotype terms`, tone: 'match' }
: { label: 'no phenotype match', tone: 'muted' },
variant.gnomad_af === null
? { label: 'absent from gnomAD', tone: 'rare' }
: { label: `gnomAD ${formatAf(variant.gnomad_af)}`, tone: 'rare' },
{
label: (variant.consequence ?? 'unknown consequence').split('&')[0].replace(/_/g, ' '),
tone: 'impact'
},
candidate.scored && variant.prediction
? { label: `model ${variant.prediction.score.toFixed(2)}`, tone: 'model' }
: { label: 'unscored', tone: 'muted' }
];
if (variant.clinvar_sig) chips.push({ label: `ClinVar: ${variant.clinvar_sig}`, tone: 'clinvar' });
return chips;
}
/** Thousands of variants down to a handful: the narrowing is the story. */
export function funnelSteps(funnel: Funnel): { label: string; value: number }[] {
return [
{ label: 'variants called', value: funnel.total },
{ label: 'rare (<0.1%)', value: funnel.rare },
{ label: 'coding candidates', value: funnel.candidates },
{ label: 'in phenotype-matched genes', value: funnel.phenotype_matched }
];
}
export const scoreBarPercent = (score: number): number =>
Math.max(0, Math.min(100, Math.round(score * 100)));
export function candidateQuery(filters: CandidateFilters): string {
const q = new URLSearchParams();
const gene = filters.gene?.trim();
if (gene) q.set('gene', gene);
if (filters.impact) q.set('impact', filters.impact);
const { maxAf } = filters;
if (maxAf !== null && maxAf !== undefined && maxAf !== '' && Number.isFinite(Number(maxAf))) {
q.set('max_af', String(maxAf));
}
if (filters.state) q.set('state', filters.state);
if (filters.limit !== undefined) q.set('limit', String(filters.limit));
if (filters.offset !== undefined) q.set('offset', String(filters.offset));
return q.toString();
}
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Candidate } from '$lib/api';
import { evidenceChips, scoreBarPercent } from '$lib/candidates';
import Chips from './Chips.svelte';
let {
candidate,
rank,
termCount,
selected = false,
onselect
}: {
candidate: Candidate;
rank: number;
termCount: number;
selected?: boolean;
onselect: () => void;
} = $props();
const v = $derived(candidate.variant);
</script>
<button class="candidate" class:selected onclick={onselect}>
<span class="rank">{rank}</span>
<span class="who">
<strong>{v.gene ?? 'intergenic'}</strong>
<span class="coord">{v.chrom}:{v.pos} {v.ref}&gt;{v.alt}</span>
{#if v.hgvsp ?? v.hgvsc}<span class="hgvs">{v.hgvsp ?? v.hgvsc}</span>{/if}
</span>
<Chips chips={evidenceChips(candidate, termCount)} />
<span class="rankscore">
<span class="scorebar"><span style="width: {scoreBarPercent(candidate.score)}%"></span></span>
<span class="scorenum">{candidate.score.toFixed(2)}</span>
</span>
{#if candidate.decision}
<span class="decision {candidate.decision.state}">{candidate.decision.state}</span>
{/if}
</button>
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import type { Chip } from '$lib/candidates';
let { chips }: { chips: Chip[] } = $props();
</script>
<span class="chips">
{#each chips as chip (chip.label)}
<span class="chip {chip.tone}">{chip.label}</span>
{/each}
</span>
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Funnel } from '$lib/api';
import { funnelSteps } from '$lib/candidates';
let { funnel }: { funnel: Funnel } = $props();
const steps = $derived(funnelSteps(funnel));
const width = (value: number) => (funnel.total ? Math.max(1.5, (value / funnel.total) * 100) : 0);
</script>
<ol class="funnel">
{#each steps as step (step.label)}
<li>
<span class="funnel-value">{step.value.toLocaleString('en-GB')}</span>
<span class="funnel-label">{step.label}</span>
<span class="funnel-track"><span class="funnel-bar" style="width: {width(step.value)}%"></span></span>
</li>
{/each}
</ol>
@@ -0,0 +1,62 @@
<script lang="ts">
import { api, type PhenotypeTerm } from '$lib/api';
let { selected = $bindable() }: { selected: PhenotypeTerm[] } = $props();
let query = $state('');
let results = $state<PhenotypeTerm[]>([]);
let searching = $state(false);
async function search() {
if (query.trim().length < 2) {
results = [];
return;
}
searching = true;
try {
results = await api.phenotypes(query.trim());
} catch {
results = []; // the picker is a convenience; a failed lookup must not block the form
} finally {
searching = false;
}
}
function add(term: PhenotypeTerm) {
if (!selected.some((t) => t.hpo_id === term.hpo_id)) selected = [...selected, term];
query = '';
results = [];
}
</script>
<div class="picker">
<input
placeholder="Search HPO terms, e.g. hearing"
bind:value={query}
oninput={search}
aria-label="Phenotype search"
/>
{#if searching}<span class="muted">searching…</span>{/if}
{#if results.length}
<ul class="results">
{#each results as term (term.hpo_id)}
<li>
<button type="button" class="quiet" onclick={() => add(term)}>
{term.label} <small class="muted">{term.hpo_id}</small>
</button>
</li>
{/each}
</ul>
{/if}
<span class="chips">
{#each selected as term (term.hpo_id)}
<span class="chip match">
{term.label}
<button
type="button"
class="chipx"
onclick={() => (selected = selected.filter((t) => t.hpo_id !== term.hpo_id))}
aria-label="Remove {term.label}"></button>
</span>
{/each}
</span>
</div>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { api, type DecisionState, type VariantDetail } from '$lib/api';
import { evidenceChips } from '$lib/candidates';
import Chips from './Chips.svelte';
let {
detail,
termCount,
weights,
onclose,
ondecided
}: {
detail: VariantDetail;
termCount: number;
weights: Record<string, number>;
onclose: () => void;
ondecided: (updated: VariantDetail) => void;
} = $props();
let reason = $state('');
let note = $state('');
let shownFor = $state<number | null>(null);
let saving = $state(false);
let error = $state<string | null>(null);
// The panel is reused as you click through candidates: load each variant's own decision.
$effect(() => {
if (shownFor === detail.variant.id) return;
shownFor = detail.variant.id;
reason = detail.decision?.reason ?? '';
note = detail.decision?.note ?? '';
});
const v = $derived(detail.variant);
const links = $derived([
{
label: 'Ensembl',
href: `https://www.ensembl.org/Homo_sapiens/Location/View?r=${v.chrom}:${v.pos}-${v.pos}`
},
{
label: 'gnomAD',
href: `https://gnomad.broadinstitute.org/region/${v.chrom}-${Math.max(1, v.pos - 25)}-${v.pos + 25}?dataset=gnomad_r4`
},
...(v.gene
? [{ label: 'ClinVar', href: `https://www.ncbi.nlm.nih.gov/clinvar/?term=${encodeURIComponent(v.gene)}%5Bgene%5D` }]
: [])
]);
async function decide(state: DecisionState) {
saving = true;
error = null;
try {
await api.decide(v.id, { state, reason: reason || undefined, note: note || undefined });
ondecided(await api.variant(v.id));
} catch (e) {
error = (e as Error).message;
} finally {
saving = false;
}
}
</script>
<aside class="panel">
<header class="panel-head">
<h3>{v.gene ?? 'intergenic'} <span class="coord">{v.chrom}:{v.pos} {v.ref}&gt;{v.alt}</span></h3>
<button class="quiet" onclick={onclose} aria-label="Close panel"></button>
</header>
<Chips chips={evidenceChips(detail, termCount)} />
<h4>Why it ranks {detail.score.toFixed(2)}</h4>
<table class="components">
<tbody>
{#each Object.entries(detail.components) as [name, value] (name)}
<tr>
<th>{name}</th>
<td class="coord">{value.toFixed(2)} × {(weights[name] ?? 0).toFixed(2)}</td>
<td class="coord">{(value * (weights[name] ?? 0)).toFixed(3)}</td>
</tr>
{/each}
</tbody>
</table>
{#if detail.matched_terms.length}
<h4>Phenotype match</h4>
<p>HPO annotates <strong>{v.gene}</strong> with {detail.matched_terms.map((t) => t.label).join(', ')}.</p>
{/if}
<h4>Evidence</h4>
<dl class="evidence">
<dt>Consequence</dt><dd>{v.consequence ?? '—'} <small class="muted">{v.impact ?? ''}</small></dd>
<dt>HGVS</dt><dd class="hgvs">{v.hgvsp ?? v.hgvsc ?? '—'}</dd>
<dt>gnomAD</dt><dd>{v.gnomad_af === null ? 'absent' : v.gnomad_af.toExponential(2)}</dd>
<dt>ClinVar</dt><dd>{v.clinvar_sig ?? 'no submission'}</dd>
<dt>Model</dt>
<dd>{v.prediction ? `${v.prediction.score.toFixed(2)} (model ${v.prediction.model_version})` : 'unscored'}</dd>
</dl>
<p class="links">
{#each links as link (link.label)}<a href={link.href} target="_blank" rel="noreferrer">{link.label}</a>{/each}
</p>
<h4>Decision</h4>
{#if error}<p role="alert">{error}</p>{/if}
<input placeholder="Reason, e.g. fits the phenotype" bind:value={reason} aria-label="Reason" />
<textarea placeholder="Note for the report" rows="3" bind:value={note} aria-label="Note"></textarea>
<div class="decide">
<button onclick={() => decide('shortlisted')} disabled={saving}>Shortlist</button>
<button class="quiet" onclick={() => decide('dismissed')} disabled={saving}>Dismiss</button>
</div>
</aside>
-29
View File
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import { variantQuery } from './query';
describe('variantQuery', () => {
it('always includes the job id', () => {
expect(variantQuery({ jobId: 'abc' })).toBe('job_id=abc');
});
it('includes filters that have values', () => {
const q = new URLSearchParams(
variantQuery({ jobId: 'abc', gene: ' brca1 ', impact: 'HIGH', maxAf: 0.01, limit: 100 })
);
expect(q.get('gene')).toBe('brca1');
expect(q.get('impact')).toBe('HIGH');
expect(q.get('max_af')).toBe('0.01');
expect(q.get('limit')).toBe('100');
});
it('drops a cleared number input instead of sending "null"', () => {
// Svelte binds an emptied <input type="number"> to null.
for (const maxAf of [null, undefined, '', Number.NaN]) {
expect(variantQuery({ jobId: 'abc', maxAf })).toBe('job_id=abc');
}
});
it('drops blank gene and impact', () => {
expect(variantQuery({ jobId: 'abc', gene: ' ', impact: '' })).toBe('job_id=abc');
});
});
-22
View File
@@ -1,22 +0,0 @@
export type VariantFilters = {
jobId: string;
gene?: string;
impact?: string;
// An emptied <input type="number"> binds to null, so accept it and drop it.
maxAf?: number | string | null;
limit?: number;
offset?: number;
};
export function variantQuery(f: VariantFilters): string {
const q = new URLSearchParams({ job_id: f.jobId });
const gene = f.gene?.trim();
if (gene) q.set('gene', gene);
if (f.impact) q.set('impact', f.impact);
if (f.maxAf !== null && f.maxAf !== undefined && f.maxAf !== '' && Number.isFinite(Number(f.maxAf))) {
q.set('max_af', String(f.maxAf));
}
if (f.limit !== undefined) q.set('limit', String(f.limit));
if (f.offset !== undefined) q.set('offset', String(f.offset));
return q.toString();
}
+53 -29
View File
@@ -1,18 +1,20 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, type Assembly, type Sample } from '$lib/api';
import { api, type Assembly, type Case, type PhenotypeTerm } from '$lib/api';
import PhenotypePicker from '$lib/components/PhenotypePicker.svelte';
let samples = $state<Sample[]>([]);
let cases = $state<Case[]>([]);
let loaded = $state(false);
let loadError = $state<string | null>(null);
let name = $state('');
let vcfUri = $state('');
let assembly = $state<Assembly>('GRCh38');
let phenotypes = $state<PhenotypeTerm[]>([]);
let error = $state<string | null>(null);
async function refresh() {
try {
samples = await api.samples();
cases = await api.cases();
loadError = null;
} catch (e) {
loadError = (e as Error).message;
@@ -24,44 +26,66 @@
async function add() {
error = null;
try { await api.createSample({ name, vcf_uri: vcfUri, assembly }); name = ''; vcfUri = ''; await refresh(); }
catch (e) { error = (e as Error).message; }
try {
await api.createCase({ name, vcf_uri: vcfUri, assembly, phenotypes });
name = '';
vcfUri = '';
phenotypes = [];
await refresh();
} catch (e) {
error = (e as Error).message;
}
}
const status = (c: Case) => c.latest_job?.status ?? 'not analysed';
</script>
<h1>rarelens</h1>
<p class="lede">Annotate a VCF with Ensembl VEP, score each variant, and browse what came back. Public test data only.</p>
<p class="lede">
Rare disease triage on public data: a proband's variants narrowed against their phenotype, each
candidate carrying the evidence for its rank. Research demo, not a diagnostic tool.
</p>
<h2>Add a sample</h2>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
<input placeholder="Sample name" bind:value={name} aria-label="Sample name" />
<input placeholder="gs://bucket/sample.vcf.gz or /data/example.vcf.gz" bind:value={vcfUri} aria-label="VCF path" style="flex:1; min-width: 20rem" />
<h2>New case</h2>
<div class="newcase">
<div class="row">
<input placeholder="Case name, e.g. PROBAND-01" bind:value={name} aria-label="Case name" />
<input
placeholder="gs://bucket/proband.vcf.gz or /data/proband.vcf.gz"
bind:value={vcfUri}
aria-label="VCF path"
style="flex:1; min-width: 18rem" />
<select bind:value={assembly} aria-label="Assembly">
<option>GRCh38</option><option>GRCh37</option>
</select>
<button onclick={add} disabled={!name || !vcfUri}>Add sample</button>
</div>
{#if error}<p role="alert">Could not add the sample: {error}</p>{/if}
<PhenotypePicker bind:selected={phenotypes} />
<button onclick={add} disabled={!name || !vcfUri}>Create case</button>
</div>
{#if error}<p role="alert">Could not create the case: {error}</p>{/if}
<h2>Samples</h2>
<h2>Cases</h2>
{#if loadError}
<p role="alert">Could not load samples: {loadError}. <button class="quiet" onclick={refresh}>Retry</button></p>
<p role="alert">Could not load cases: {loadError}. <button class="quiet" onclick={refresh}>Retry</button></p>
{:else if !loaded}
<p style="color:var(--ink-soft)">Loading samples</p>
{:else if samples.length === 0}
<div class="empty">No samples yet. Add one above to run the annotation pipeline.</div>
<p class="muted">Loading…</p>
{:else if cases.length === 0}
<div class="empty">No cases yet. Create one above, then run the annotation pipeline on it.</div>
{:else}
<table>
<thead><tr><th>Name</th><th>VCF</th><th>Assembly</th><th>Added</th></tr></thead>
<tbody>
{#each samples as s (s.id)}
<tr>
<td><a href="/samples/{s.id}">{s.name}</a></td>
<td class="hgvs">{s.vcf_uri}</td>
<td>{s.assembly}</td>
<td>{new Date(s.created_at).toLocaleDateString('en-GB')}</td>
</tr>
<ul class="caselist">
{#each cases as c (c.id)}
<li>
<a class="casename" href="/cases/{c.id}">{c.name}</a>
<span class="chips">
{#each c.phenotypes.slice(0, 4) as term (term.hpo_id)}
<span class="chip match">{term.label}</span>
{/each}
</tbody>
</table>
{#if c.phenotypes.length > 4}<span class="chip muted">+{c.phenotypes.length - 4}</span>{/if}
{#if c.phenotypes.length === 0}<span class="chip muted">no phenotype</span>{/if}
</span>
<span class="status {c.latest_job?.status ?? ''}">{status(c)}</span>
<span class="muted">{c.shortlisted} shortlisted</span>
</li>
{/each}
</ul>
{/if}
+206
View File
@@ -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}
+2
View File
@@ -0,0 +1,2 @@
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => ({ caseId: params.id });
@@ -0,0 +1,78 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, type Report } from '$lib/api';
import Funnel from '$lib/components/Funnel.svelte';
import Chips from '$lib/components/Chips.svelte';
import { evidenceChips } from '$lib/candidates';
let { data } = $props();
let report = $state<Report | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
report = await api.report(data.caseId);
} catch (e) {
error = (e as Error).message;
}
});
const termCount = $derived(report?.case.phenotypes.length ?? 0);
</script>
<a class="noprint" href="/cases/{data.caseId}">← Back to triage</a>
{#if error}<p role="alert">{error}</p>{/if}
{#if report}
<article class="report">
<h1>Case report — {report.case.name}</h1>
<p class="muted">
Generated {new Date(report.generated_at).toLocaleString('en-GB')} ·
{report.case.assembly}
{#if report.provenance.vep_version}· VEP {report.provenance.vep_version}{/if}
{#if report.provenance.model_version}· model {report.provenance.model_version}{/if}
</p>
<h2>Phenotype</h2>
<span class="chips">
{#each report.case.phenotypes as term (term.hpo_id)}
<span class="chip match">{term.label} <small class="muted">{term.hpo_id}</small></span>
{:else}
<span class="chip muted">none recorded</span>
{/each}
</span>
<h2>Narrowing</h2>
<Funnel funnel={report.funnel} />
<h2>Shortlisted ({report.shortlisted.length})</h2>
{#each report.shortlisted as item (item.variant.id)}
<div class="reportitem">
<h3>{item.variant.gene ?? 'intergenic'} <span class="coord">{item.variant.chrom}:{item.variant.pos} {item.variant.ref}&gt;{item.variant.alt}</span></h3>
<Chips chips={evidenceChips(item, termCount)} />
<p><strong>Reason:</strong> {item.decision?.reason ?? '—'}</p>
{#if item.decision?.note}<p class="muted">{item.decision.note}</p>{/if}
</div>
{:else}
<p class="muted">Nothing shortlisted yet.</p>
{/each}
{#if report.dismissed.length}
<h2>Dismissed ({report.dismissed.length})</h2>
<ul class="dismissed">
{#each report.dismissed as item (item.variant.id)}
<li>
<strong>{item.variant.gene ?? 'intergenic'}</strong>
<span class="coord">{item.variant.chrom}:{item.variant.pos}</span>
{item.decision?.reason ?? 'no reason given'}
</li>
{/each}
</ul>
{/if}
<p class="disclaimer">
Research demonstration on public data. Computational evidence is supporting only under
ACMG/AMP guidance and this tool makes no diagnostic claim.
</p>
</article>
{/if}
@@ -0,0 +1,2 @@
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => ({ caseId: params.id });
-141
View File
@@ -1,141 +0,0 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { api, type Job, type VariantPage } from '$lib/api';
import { poll } from '$lib/poll';
import { formatElapsed, latestStep } from '$lib/progress';
const POLL_MS = 3000;
const finished = (j: Job) => j.status === 'succeeded' || j.status === 'failed';
let { data } = $props();
let job = $state<Job | null>(null);
let page = $state<VariantPage | null>(null);
let gene = $state('');
let impact = $state('');
let maxAf = $state<number | null>(0.01);
let busy = $state(false);
let error = $state<string | null>(null);
let stopPolling: (() => void) | null = null;
let now = $state(Date.now());
const running = $derived(!!job && !finished(job));
const elapsedMs = $derived(job ? now - Date.parse(job.created_at) : 0);
const step = $derived(latestStep(job?.log ?? null));
// 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 {
const jobs = await api.jobsForSample(data.sampleId); // newest first
const latest = jobs[0] ?? null;
const lastGood = jobs.find((j) => j.status === 'succeeded') ?? null;
// Follow a run in progress; otherwise show the latest results, else the latest failure.
job = latest && !finished(latest) ? latest : (lastGood ?? latest);
if (job) await follow(job);
} catch (e) {
error = (e as Error).message;
}
});
onDestroy(() => stopPolling?.());
async function follow(j: Job) {
stopPolling?.();
if (j.status === 'succeeded') return loadVariants();
if (finished(j)) return;
stopPolling = poll(() => api.job(j.id), {
intervalMs: POLL_MS,
done: finished,
onValue: (next) => {
job = next;
if (next.status === 'succeeded') loadVariants();
},
onError: (e) => { error = `Lost track of the job: ${(e as Error).message}`; }
});
}
async function runAnnotation() {
busy = true;
error = null;
page = null;
try {
job = await api.annotate(data.sampleId);
await follow(job);
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
async function loadVariants() {
if (!job) return;
error = null;
try {
page = await api.variants({ jobId: job.id, gene, impact, maxAf, limit: 100 });
} catch (e) {
error = (e as Error).message;
}
}
const scoreClass = (s: number) => (s >= 0.8 ? 'score high' : s >= 0.5 ? 'score mid' : 'score');
</script>
<a href="/">All samples</a>
<h1>Sample {data.sampleId.slice(0, 8)}</h1>
{#if error}<p role="alert">{error}</p>{/if}
{#if job}
<p>Job <span class="hgvs">{job.id.slice(0, 8)}</span>: <span class="status {job.status}">{job.status}</span></p>
{#if running}
<p class="progress" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
Annotating for {formatElapsed(elapsedMs)}{#if step} · <span class="hgvs">{step}</span>{/if}
</p>
{/if}
{#if job.status === 'failed' && job.log}
<pre class="hgvs" style="white-space:pre-wrap; background:white; border:1px solid var(--line); padding:0.75rem; max-height:16rem; overflow:auto">{job.log}</pre>
{/if}
{/if}
{#if !job || job.status === 'failed'}
<button onclick={runAnnotation} disabled={busy}>{job ? 'Run VEP annotation again' : 'Run VEP annotation'}</button>
{/if}
{#if job?.status === 'succeeded'}
<h2>Variants</h2>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-bottom:0.75rem">
<input placeholder="Gene symbol" bind:value={gene} aria-label="Gene" />
<select bind:value={impact} aria-label="Impact">
<option value="">Any impact</option><option>HIGH</option><option>MODERATE</option><option>LOW</option><option>MODIFIER</option>
</select>
<label>Max gnomAD AF <input type="number" step="0.001" min="0" max="1" bind:value={maxAf} style="width:6rem" /></label>
<button class="quiet" onclick={loadVariants}>Apply filters</button>
</div>
{#if page && page.items.length === 0}
<div class="empty">No variants match these filters. Raise the allele frequency cap or clear the gene filter.</div>
{:else if page}
<p style="color:var(--ink-soft)">{page.total} variants, showing {page.items.length}</p>
<table>
<thead><tr><th>Position</th><th>Gene</th><th>Consequence</th><th>HGVS</th><th>gnomAD AF</th><th>ClinVar</th><th>Score</th></tr></thead>
<tbody>
{#each page.items as v (v.id)}
<tr>
<td class="coord">{v.chrom}:{v.pos} {v.ref}>{v.alt}</td>
<td>{v.gene ?? ''}</td>
<td>{v.consequence ?? ''}<br /><small style="color:var(--ink-soft)">{v.impact ?? ''}</small></td>
<td class="hgvs">{v.hgvsp ?? v.hgvsc ?? ''}</td>
<td>{v.gnomad_af?.toExponential(2) ?? 'absent'}</td>
<td>{v.clinvar_sig ?? ''}</td>
<td>{#if v.prediction}<span class={scoreClass(v.prediction.score)}>{v.prediction.score.toFixed(2)}</span>{:else}<span style="color:var(--ink-soft)">unscored</span>{/if}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
-2
View File
@@ -1,2 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => ({ sampleId: params.id });