flvcko's picture
Add debug prints and cache bust; ignore nested repo
7438bc0
Raw
History Blame Contribute Delete
9.57 kB
"""bioai.web.api -- FastAPI backend for the Biopesticide-AI web UI.
Replaces the Gradio UI with a clean REST API + static frontend architecture.
Endpoints:
GET / -> serve static index.html
GET /api/health -> {"status": "ok"}
GET /api/status -> backend status (model, LLM, device, species counts)
POST /api/design -> run the design pipeline, return JSON results
GET /static/* -> static assets (CSS, JS, images)
The frontend (static/index.html) calls these endpoints via fetch() and
renders the UI with vanilla JS. No build step required.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
# Make sure we can import bioai from anywhere
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
# NOTE: BiopesticideOrchestrator is imported lazily inside get_orchestrator()
# to avoid loading torch + models at module import time. This keeps the
# FastAPI app startup fast (<2 seconds) so HuggingFace doesn't kill the
# container before uvicorn binds to port 7860.
from bioai.sequence_utils import SAFETY_SPECIES, PEST_SPECIES
# ─────────────────────────────────────────────────────────────────────────────
# Singleton orchestrator
# ─────────────────────────────────────────────────────────────────────────────
_ORCHESTRATOR = None # lazily initialized in get_orchestrator()
def get_orchestrator():
"""Lazily initialize the orchestrator on first API call.
This avoids loading torch + models at import time, keeping FastAPI
startup fast enough for HuggingFace's 60-second container timeout.
"""
global _ORCHESTRATOR
if _ORCHESTRATOR is None:
print("[api] initializing orchestrator (first request)...", flush=True)
from bioai.orchestrator import BiopesticideOrchestrator
_ORCHESTRATOR = BiopesticideOrchestrator()
print(f"[api] backend = {type(_ORCHESTRATOR.ranker.sirna_model).__name__}, degraded_mode = {_ORCHESTRATOR.degraded_mode}", flush=True)
return _ORCHESTRATOR
# ─────────────────────────────────────────────────────────────────────────────
# Pydantic models
# ─────────────────────────────────────────────────────────────────────────────
class DesignRequest(BaseModel):
user_text: str
top_k: int = 10
pest_species: str | None = None # if provided, skip LLM parsing
class SimulateRequest(BaseModel):
candidates: list
pest_species: str = "unknown"
# ─────────────────────────────────────────────────────────────────────────────
# FastAPI app
# ─────────────────────────────────────────────────────────────────────────────
STATIC_DIR = Path(__file__).parent / "static"
app = FastAPI(
title="Biopesticide-AI",
description="dsRNA biopesticide design pipeline API",
version="2.0.0",
)
# Mount static files (CSS, JS, images)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/api/health")
async def health():
return {"status": "ok"}
@app.get("/api/status")
async def status():
"""Return backend status for the frontend status strip."""
orch = get_orchestrator()
model_class = type(orch.ranker.sirna_model).__name__
# Check if checkpoints actually loaded (not random init)
from bioai.paths import SIRNA_CHECKPOINT, PINN_CHECKPOINT
sirna_loaded = SIRNA_CHECKPOINT.exists()
pinn_loaded = PINN_CHECKPOINT.exists()
return {
"model_backend": "Caduceus-Ph-1" if model_class == "CaduceusAdapter" else "Dilated CNN (HyenaDNA-inspired)",
"llm_status": "Ollama Llama 3.2 3B (local)" if not orch.degraded_mode else "Degraded mode (Ollama not running)",
"llm_active": not orch.degraded_mode,
"device": str(orch.ranker.device),
"safety_species_count": len(SAFETY_SPECIES),
"pest_species_count": len(PEST_SPECIES),
"safety_species": SAFETY_SPECIES,
"pest_species": PEST_SPECIES,
"checkpoints": {
"sirna_loaded": sirna_loaded,
"pinn_loaded": pinn_loaded,
"sirna_path": str(SIRNA_CHECKPOINT),
"pinn_path": str(PINN_CHECKPOINT),
},
}
@app.post("/api/design")
async def design(req: DesignRequest):
"""Run the design pipeline and return results as JSON."""
if not req.user_text or not req.user_text.strip():
raise HTTPException(status_code=400, detail="user_text is required")
orch = get_orchestrator()
t0 = time.time()
try:
result = orch.design(req.user_text, top_k=int(req.top_k), pest_species_override=req.pest_species)
except Exception as e:
import traceback
raise HTTPException(
status_code=500,
detail=f"Pipeline error: {type(e).__name__}: {e}\n{traceback.format_exc()}"
)
elapsed = time.time() - t0
# Build response with metadata
response = {
"elapsed_seconds": round(elapsed, 2),
"pest_report": result.get("pest_report", {}),
"candidates": result.get("candidates", []),
"safety_cards": result.get("safety_cards", ""),
"regulatory_memo": result.get("regulatory_memo", ""),
"n_transcripts": result.get("n_transcripts", 0),
"n_precursors": result.get("n_precursors", 0),
"n_sirnas": result.get("n_sirnas", 0),
"total_cost_estimate": result.get("total_cost_estimate", 0.0),
"degraded_mode": result.get("degraded_mode", True),
}
return JSONResponse(content=response)
@app.get("/")
async def index():
"""Serve the main HTML page."""
index_path = STATIC_DIR / "index.html"
if not index_path.exists():
raise HTTPException(status_code=404, detail="index.html not found")
return HTMLResponse(content=index_path.read_text(encoding="utf-8"), status_code=200)
@app.post("/api/simulate")
async def simulate(req: SimulateRequest):
"""Run the virtual wet-lab simulation on the top candidates.
Each candidate should have: sirna_seq, efficacy, half_life_hours.
Returns 1000-trial Monte Carlo results per candidate.
"""
from bioai.simulation.wet_lab import WetLabSimulator, result_to_dict
if not req.candidates:
raise HTTPException(status_code=400, detail="candidates is required")
sim = WetLabSimulator(n_trials=1000, rng_seed=42)
results = sim.simulate_batch(req.candidates, pest_species=req.pest_species)
return JSONResponse(content={
"trials": [result_to_dict(r) for r in results],
"pest_species": req.pest_species,
"n_trials_per_candidate": 1000,
"stages": [
"delivery", "uptake", "dicer", "risc_loading", "cleavage", "phenotype"
],
})
# ─────────────────────────────────────────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Biopesticide-AI Web UI (FastAPI)")
parser.add_argument("--host", default="0.0.0.0", help="bind host (default 0.0.0.0)")
parser.add_argument("--port", type=int, default=7860, help="bind port (default 7860)")
parser.add_argument("--reload", action="store_true", help="auto-reload on file changes (dev mode)")
args = parser.parse_args()
import uvicorn
# DON'T pre-initialize the orchestrator here. On HuggingFace Spaces, the
# 14-species k-mer indexing + Ollama probe takes >60 seconds on the slow
# CPU, which causes HF to kill the container before uvicorn binds to port
# 7860. Instead, we start uvicorn immediately and initialize the
# orchestrator lazily on the first API request. The first request will
# be slow (~60s) but the server stays alive.
print("[api] launching uvicorn immediately (orchestrator initializes lazily on first request)")
print(f"[api] launching on http://{args.host}:{args.port}")
uvicorn.run(
"bioai.web.api:app",
host=args.host,
port=args.port,
reload=args.reload,
log_level="info",
)
if __name__ == "__main__":
main()