shrut27's picture
Implement complete API contract, persistence, jobs, artifacts, and architecture status
ff453a6 verified
Raw
History Blame Contribute Delete
15.8 kB
"""Versioned REST API for the TP53 discovery workflow."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from uuid import uuid4
import pandas as pd
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from utils.calculations import (
DEFAULT_WEIGHTS,
SCORING_VERSION,
apply_weights,
bmut_from_score,
consensus_score,
empirical_docking_score,
ingest_variant_frame,
md_metrics,
pocket_hydrophobicity,
pocket_records,
selectivity_from_scores,
structure_record,
)
from api import store
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
app = FastAPI(
title="TP53 Mutant Discovery API",
version="1.0.0",
description="Traceable variant-to-compound analysis with asynchronous jobs and versioned artifacts.",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
class ProjectCreate(BaseModel):
name: str = Field(min_length=1)
cancer_type: str = "Lung Cancer"
metadata: dict[str, Any] = Field(default_factory=dict)
class VariantIngest(BaseModel):
project_id: str
records: list[dict[str, Any]]
class VariantAnalyze(BaseModel):
project_id: str
class StructurePrepare(BaseModel):
project_id: str
variant_ids: list[str]
source_preference: str = "experimental_then_template"
class PocketDetect(BaseModel):
project_id: str
structure_ids: list[str]
class CompoundScreen(BaseModel):
project_id: str
variant_id: str
library_id: str = "platform-library-v1"
model_id: str = "complementarity-v1"
limit: int = Field(default=500, ge=1, le=2000)
class DockingRun(BaseModel):
project_id: str
variant_id: str
compounds: list[dict[str, Any]]
protocol_id: str = "matched-wt-mutant-v1"
class MDRun(BaseModel):
project_id: str
complexes: list[dict[str, Any]]
protocol_id: str = "explicit-solvent-3x100ns-v1"
class RankingCompute(BaseModel):
project_id: str
variant_id: str
candidates: list[dict[str, Any]]
weights: dict[str, float] = Field(default_factory=lambda: DEFAULT_WEIGHTS.copy())
@app.on_event("startup")
def startup() -> None:
store.init_db()
def require_project(project_id: str) -> dict:
project = store.get_project(project_id)
if not project:
raise HTTPException(404, f"Project {project_id} not found")
return project
def submit(background: BackgroundTasks, project_id: str, operation: str, payload: dict) -> dict:
require_project(project_id)
job = store.create_job(project_id, operation, payload)
background.add_task(process_job, job["job_id"])
return {"job_id": job["job_id"], "status": "queued", "status_url": f"/jobs/{job['job_id']}"}
@app.get("/health")
def health() -> dict:
return {"status": "ok", "version": app.version, "scoring_version": SCORING_VERSION}
@app.post("/projects", status_code=201)
def create_project(body: ProjectCreate) -> dict:
return store.create_project(body.name, body.cancer_type, body.metadata)
@app.get("/projects/{project_id}")
def get_project(project_id: str) -> dict:
return require_project(project_id)
@app.post("/variants:ingest", status_code=202)
def variants_ingest(body: VariantIngest, background: BackgroundTasks) -> dict:
return submit(background, body.project_id, "variants:ingest", body.model_dump())
@app.post("/variants/{variant_id}/analyze", status_code=202)
def variant_analyze(variant_id: str, body: VariantAnalyze, background: BackgroundTasks) -> dict:
return submit(
background,
body.project_id,
"variant:analyze",
{**body.model_dump(), "variant_id": variant_id},
)
@app.post("/structures:prepare", status_code=202)
def structures_prepare(body: StructurePrepare, background: BackgroundTasks) -> dict:
return submit(background, body.project_id, "structures:prepare", body.model_dump())
@app.post("/pockets:detect", status_code=202)
def pockets_detect(body: PocketDetect, background: BackgroundTasks) -> dict:
return submit(background, body.project_id, "pockets:detect", body.model_dump())
@app.post("/compounds:screen", status_code=202)
def compounds_screen(body: CompoundScreen, background: BackgroundTasks) -> dict:
return submit(background, body.project_id, "compounds:screen", body.model_dump())
@app.post("/docking:run", status_code=202)
def docking_run(body: DockingRun, background: BackgroundTasks) -> dict:
return submit(background, body.project_id, "docking:run", body.model_dump())
@app.post("/md:run", status_code=202)
def md_run(body: MDRun, background: BackgroundTasks) -> dict:
return submit(background, body.project_id, "md:run", body.model_dump())
@app.post("/rankings:compute", status_code=202)
def rankings_compute(body: RankingCompute, background: BackgroundTasks) -> dict:
return submit(background, body.project_id, "rankings:compute", body.model_dump())
@app.get("/jobs/{job_id}")
def get_job(job_id: str) -> dict:
job = store.get_job(job_id)
if not job:
raise HTTPException(404, f"Job {job_id} not found")
return job
@app.get("/projects/{project_id}/jobs")
def project_jobs(project_id: str) -> list[dict]:
require_project(project_id)
return store.list_jobs(project_id)
@app.get("/projects/{project_id}/artifacts")
def project_artifacts(project_id: str) -> list[dict]:
require_project(project_id)
return store.list_artifacts(project_id)
@app.get("/artifacts/{artifact_id}")
def artifact(artifact_id: str) -> dict:
row = store.get_artifact(artifact_id)
if not row:
raise HTTPException(404, f"Artifact {artifact_id} not found")
return row
@app.get("/projects/{project_id}/report")
def project_report(project_id: str) -> dict:
project = require_project(project_id)
variants = store.list_records(project_id, "variant")
structures = store.list_records(project_id, "structure")
pockets = store.list_records(project_id, "pocket")
rankings = store.list_records(project_id, "ranking")
return {
"project": project,
"summary": {
"variants": len(variants),
"structures": len(structures),
"pockets": len(pockets),
"ranked_candidates": len(rankings),
},
"top_candidates": sorted(rankings, key=lambda x: x.get("rescue_score", 0), reverse=True)[:10],
"jobs": store.list_jobs(project_id),
"artifacts": store.list_artifacts(project_id),
"versions": {"api": app.version, "scoring": SCORING_VERSION},
}
def process_job(job_id: str) -> None:
job = store.get_job(job_id)
if not job:
return
try:
store.update_job(job_id, "running", log=f"Started {job['operation']}")
handler = HANDLERS[job["operation"]]
result = handler(job["project_id"], job["input"], job_id)
store.update_job(job_id, "succeeded", output=result, log="Completed successfully")
except Exception as exc:
store.update_job(job_id, "failed", error=str(exc), log=f"Failed: {exc}")
def handle_ingest(project_id: str, payload: dict, job_id: str) -> dict:
frame = pd.DataFrame(payload["records"])
normalized = ingest_variant_frame(frame, require_project(project_id)["cancer_type"])
records = []
for _, row in normalized.iterrows():
item = _jsonable(row.to_dict())
item["variant_id"] = f"VAR-{uuid4().hex[:12]}"
records.append(item)
ids = store.add_records(project_id, "variant", records)
artifact = store.add_artifact(
project_id, "qc-report", "variant-qc.json", records, "application/json", job_id
)
return {"variant_ids": ids, "normalized_records": records, "qc_artifact_id": artifact["artifact_id"]}
def handle_analyze(project_id: str, payload: dict, job_id: str) -> dict:
record = store.get_record(project_id, "variant", payload["variant_id"])
if not record:
raise ValueError(f"Variant {payload['variant_id']} not found")
return {
"variant_id": payload["variant_id"],
"hgvs_p": record["hgvs_p"],
"priority": record["priority_score"],
"class": record["functional_class"],
"domain": record["domain"],
"route": record["route"],
"confidence": record["confidence"],
"qc_flags": record["qc_flags"],
}
def handle_structures(project_id: str, payload: dict, job_id: str) -> dict:
rows = []
for variant_id in payload["variant_ids"]:
variant = store.get_record(project_id, "variant", variant_id)
if not variant:
raise ValueError(f"Variant {variant_id} not found")
rec = _jsonable(structure_record(pd.Series(variant)))
rec["structure_id"] = f"STR-{uuid4().hex[:12]}"
rec["variant_id"] = variant_id
rec["source_preference"] = payload["source_preference"]
rows.append(rec)
ids = store.add_records(project_id, "structure", rows)
artifact = store.add_artifact(project_id, "structures", "structures.json", rows, "application/json", job_id)
return {"structure_ids": ids, "structures": rows, "artifact_id": artifact["artifact_id"]}
def handle_pockets(project_id: str, payload: dict, job_id: str) -> dict:
rows = []
for structure_id in payload["structure_ids"]:
structure = store.get_record(project_id, "structure", structure_id)
if not structure:
raise ValueError(f"Structure {structure_id} not found")
for rec in pocket_records(structure):
rec["pocket_id"] = f"POC-{uuid4().hex[:12]}"
rec["structure_id"] = structure_id
rows.append(_jsonable(rec))
ids = store.add_records(project_id, "pocket", rows)
artifact = store.add_artifact(project_id, "pockets", "pockets.json", rows, "application/json", job_id)
return {"pocket_ids": ids, "pockets": rows, "artifact_id": artifact["artifact_id"]}
def handle_screen(project_id: str, payload: dict, job_id: str) -> dict:
variant = store.get_record(project_id, "variant", payload["variant_id"])
if not variant:
raise ValueError(f"Variant {payload['variant_id']} not found")
source = pd.read_csv(DATA_DIR / "screening.csv")
rows = source[source["variant"] == variant["hgvs_p"]].nlargest(payload["limit"], "ai_score")
output = [_jsonable(row) for row in rows.to_dict("records")]
artifact = store.add_artifact(project_id, "screening", "screening.json", output, "application/json", job_id)
return {
"variant_id": payload["variant_id"],
"model_id": payload["model_id"],
"library_id": payload["library_id"],
"candidates": output,
"artifact_id": artifact["artifact_id"],
}
def handle_docking(project_id: str, payload: dict, job_id: str) -> dict:
variant = store.get_record(project_id, "variant", payload["variant_id"])
if not variant:
raise ValueError(f"Variant {payload['variant_id']} not found")
structure = next(
(s for s in store.list_records(project_id, "structure") if s["variant_id"] == payload["variant_id"]),
None,
)
if not structure:
structure = _jsonable(structure_record(pd.Series(variant)))
hyd = pocket_hydrophobicity(int(variant["position"]), variant["hgvs_p"])
rows = []
for compound in payload["compounds"]:
required = ("compound_id", "mw", "logp", "tpsa", "rotbonds")
missing = [key for key in required if key not in compound]
if missing:
raise ValueError(f"Compound missing {missing}")
dmut = empirical_docking_score(
compound["mw"], compound["logp"], compound["tpsa"], compound["rotbonds"],
structure.get("pocket_vol_mut") or 165, hyd,
)
dwt = empirical_docking_score(
compound["mw"], compound["logp"], compound["tpsa"], compound["rotbonds"],
structure.get("pocket_vol_wt") or 165, 0.30, wt_pocket=True,
)
rows.append({
**compound,
"docking_id": f"DOCK-{uuid4().hex[:12]}",
"variant_id": payload["variant_id"],
"dock_mut": round(dmut, 3),
"dock_wt": round(dwt, 3),
"delta_dock": round(dmut - dwt, 3),
"mutant_preference": round(selectivity_from_scores(dmut, dwt), 3),
"protocol_version": payload["protocol_id"],
"units": "kcal/mol",
})
ids = store.add_records(project_id, "docking", rows)
artifact = store.add_artifact(project_id, "docking", "docking.json", rows, "application/json", job_id)
return {"docking_ids": ids, "poses": rows, "artifact_id": artifact["artifact_id"]}
def handle_md(project_id: str, payload: dict, job_id: str) -> dict:
rows = []
for complex_data in payload["complexes"]:
for key in ("complex_id", "dock_mut"):
if key not in complex_data:
raise ValueError(f"Complex missing {key}")
bmut = bmut_from_score(complex_data["dock_mut"])
selectivity = float(complex_data.get("mutant_preference", 0))
metrics = md_metrics(
complex_data["dock_mut"], complex_data.get("rotbonds", 4), bmut, selectivity
)
rows.append({
"md_id": f"MD-{uuid4().hex[:12]}",
**complex_data,
**metrics,
"replicas": 3,
"duration_ns": 100,
"protocol_version": payload["protocol_id"],
})
ids = store.add_records(project_id, "md", rows)
artifact = store.add_artifact(project_id, "md", "md-summary.json", rows, "application/json", job_id)
return {"md_ids": ids, "results": rows, "artifact_id": artifact["artifact_id"]}
def handle_rankings(project_id: str, payload: dict, job_id: str) -> dict:
candidates = pd.DataFrame(payload["candidates"])
required = list(DEFAULT_WEIGHTS)
missing = [col for col in required if col not in candidates.columns]
if missing:
raise ValueError(f"Candidates missing score components: {missing}")
if "variant" not in candidates:
candidates["variant"] = payload["variant_id"]
if "confidence" not in candidates:
candidates["confidence"] = 0.5
if "admet_flag" not in candidates:
candidates["admet_flag"] = "Watch"
ranked = apply_weights(candidates, payload["weights"])
rows = []
for rec in ranked.to_dict("records"):
rec = _jsonable(rec)
rec["ranking_id"] = f"RNK-{uuid4().hex[:12]}"
rec["variant_id"] = payload["variant_id"]
rec["scoring_version"] = SCORING_VERSION
rows.append(rec)
ids = store.add_records(project_id, "ranking", rows)
artifact = store.add_artifact(project_id, "ranking", "ranking.json", rows, "application/json", job_id)
return {"ranking_ids": ids, "shortlist": rows[:10], "artifact_id": artifact["artifact_id"]}
HANDLERS = {
"variants:ingest": handle_ingest,
"variant:analyze": handle_analyze,
"structures:prepare": handle_structures,
"pockets:detect": handle_pockets,
"compounds:screen": handle_screen,
"docking:run": handle_docking,
"md:run": handle_md,
"rankings:compute": handle_rankings,
}
def _jsonable(value: Any) -> Any:
if isinstance(value, dict):
return {k: _jsonable(v) for k, v in value.items()}
if isinstance(value, list):
return [_jsonable(v) for v in value]
if pd.isna(value):
return None
if hasattr(value, "item"):
return value.item()
return value
# Mounted last so API routes take precedence; Docker serves API and dashboard together.
app.mount("/", StaticFiles(directory=Path(__file__).resolve().parent.parent, html=True), name="dashboard")