Spaces:
Sleeping
Sleeping
| """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") | |
| async def health(): | |
| return {"status": "ok"} | |
| 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), | |
| }, | |
| } | |
| 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) | |
| 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) | |
| 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() | |