Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """Genome Firewall inference API — FASTA -> antibiotic-response prediction. | |
| The Next.js app (via a Convex action) POSTs a genome FASTA + a target antibiotic; | |
| this service runs AMRFinderPlus, builds the 600-feature vector, runs the calibrated | |
| model, and returns the exact contract the app renders. | |
| Run locally (shells out to Docker for AMRFinderPlus): | |
| ./.venv/bin/uvicorn serve:app --host 0.0.0.0 --port 8000 | |
| Deployed (Dockerfile is based on the AMRFinderPlus image, so `amrfinder` is a | |
| local binary and no Docker-in-Docker is needed). | |
| Endpoints: | |
| GET /health | |
| POST /predict {"fasta": "...", "antibiotic": "Ciprofloxacin"} | |
| {"fasta": "...", "drugs": ["ciprofloxacin", ...]} # all/subset | |
| {"features": {...}, "antibiotic": "..."} # skip AMRFinder | |
| {"genome_id": "...", "features": {...}} # back-compat | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| from pathlib import Path | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| import gf_infer as gf | |
| THIS = Path(__file__).resolve().parent | |
| AMRFINDER_IMAGE = os.environ.get( | |
| "AMRFINDER_IMAGE", "staphb/ncbi-amrfinderplus:4.2.7-2026-03-24.1" | |
| ) | |
| AMRFINDER_THREADS = os.environ.get("AMRFINDER_THREADS", "4") | |
| # Optional shared-secret: if set, callers must send it as `Authorization: Bearer <token>`. | |
| API_TOKEN = os.environ.get("INFERENCE_API_TOKEN") | |
| app = FastAPI( | |
| title="Genome Firewall API", | |
| version="1.0.0", | |
| description="Research prototype. Confirm all results with standard laboratory " | |
| "susceptibility testing.", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| class PredictRequest(BaseModel): | |
| fasta: str | None = None | |
| antibiotic: str | None = None | |
| drugs: list[str] | None = None | |
| features: dict[str, float] | None = None | |
| genome_id: str | None = None | |
| def _have_amrfinder_binary() -> bool: | |
| return shutil.which("amrfinder") is not None | |
| def run_amrfinder(fasta_text: str) -> str: | |
| """FASTA text -> path of an AMRFinderPlus v4 TSV. Uses a local binary if present, | |
| otherwise a one-shot Docker run (local dev).""" | |
| workdir = Path(tempfile.mkdtemp(prefix="gf_")) | |
| fna = workdir / "genome.fna" | |
| tsv = workdir / "genome.tsv" | |
| fna.write_text(fasta_text) | |
| if _have_amrfinder_binary(): | |
| cmd = [ | |
| "amrfinder", "-n", str(fna), "-O", "Escherichia", "--plus", | |
| "-o", str(tsv), "--threads", AMRFINDER_THREADS, "--name", "query", | |
| ] | |
| else: | |
| cmd = [ | |
| "docker", "run", "--rm", "--platform", "linux/amd64", | |
| "-v", f"{workdir}:/data", AMRFINDER_IMAGE, | |
| "amrfinder", "-n", "/data/genome.fna", "-O", "Escherichia", "--plus", | |
| "-o", "/data/genome.tsv", "--threads", AMRFINDER_THREADS, "--name", "query", | |
| ] | |
| proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1200) | |
| if proc.returncode != 0 or not tsv.exists(): | |
| raise HTTPException( | |
| 502, | |
| f"AMRFinderPlus failed (exit {proc.returncode}): " | |
| f"{(proc.stderr or proc.stdout or '')[-400:]}", | |
| ) | |
| return str(tsv) | |
| def _check_auth(authorization: str | None) -> None: | |
| if API_TOKEN and authorization != f"Bearer {API_TOKEN}": | |
| raise HTTPException(401, "missing or invalid Authorization bearer token") | |
| def health(): | |
| return { | |
| "status": "ok", | |
| "drugs": gf.DRUGS, | |
| "modelVersion": gf.MODEL_VERSION, | |
| "amrfinder": "binary" if _have_amrfinder_binary() else "docker", | |
| "disclaimer": "Research prototype — confirm with standard lab testing.", | |
| } | |
| def predict(req: PredictRequest, authorization: str | None = None): | |
| _check_auth(authorization) | |
| # Resolve the feature source: FASTA (run AMRFinder) or a raw feature vector. | |
| tsv_path: str | None = None | |
| if req.fasta: | |
| tsv_path = run_amrfinder(req.fasta) | |
| elif req.features is None: | |
| raise HTTPException(400, "provide `fasta` or `features`") | |
| # Which drugs to score. | |
| if req.antibiotic: | |
| try: | |
| targets = [gf.resolve_drug(req.antibiotic)] | |
| except KeyError as e: | |
| raise HTTPException(400, str(e)) | |
| elif req.drugs: | |
| targets = [] | |
| for d in req.drugs: | |
| try: | |
| targets.append(gf.resolve_drug(d)) | |
| except KeyError as e: | |
| raise HTTPException(400, str(e)) | |
| else: | |
| targets = gf.DRUGS | |
| def score(drug: str) -> dict: | |
| if tsv_path is not None: | |
| return gf.predict_one(tsv_path, drug) | |
| return gf.predict_from_features(req.features or {}, drug) | |
| results = {gf.resolve_drug(d): score(d) for d in targets} | |
| # The app sends a single antibiotic → return that result at the top level so | |
| # the Convex action can read {score, classification, ...} directly. | |
| if req.antibiotic: | |
| top = results[gf.resolve_drug(req.antibiotic)] | |
| return {**top, "genome_id": req.genome_id, "predictions": results} | |
| return {"genome_id": req.genome_id, "predictions": results} | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) | |