Files
rarelens/api/app/routers/cases.py
T
Kemal Yaylali 3ab404ebe5 fix(web): stop presenting an unscored case as a failed analysis, and four smaller things
From clicking through the redesigned UI:

- scoring a case without a model registry painted a red failure across a case that had in
  fact analysed fine. It is now a quiet note saying the model term contributes 0, because
  scoring is an optional fourth of the rank, not the analysis.
- the MLflow default moves to port 5001. On macOS, AirPlay Receiver owns 5000, which is why
  the registry answered "403" rather than refusing the connection; docker-compose publishes
  5001 to match.
- a funnel step that kept nothing drew a visible bar. Zero now draws zero.
- "1 candidates".
- the funnel's fixed grid columns forced a horizontal scrollbar on the report.

The report also lists the top undecided candidates now: the first thing anyone opens has no
decisions in it, and "Shortlisted (0)" alone said nothing about what the tool found.

Tests: api 77, web 32; ruff, mypy, svelte-check clean.
2026-09-12 08:49:00 +01:00

234 lines
8.4 KiB
Python

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()
REPORT_TOP = 5
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)
top = [
CandidateOut.from_candidate(c, labels)
for c in view.candidates
if c.variant.decision is None
][:REPORT_TOP]
prediction = next((c.variant.prediction for c in view.candidates if c.variant.prediction), None)
return ReportOut(
case=_as_case_out(case, view.job, len(shortlisted)),
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,
top=top,
)