File size: 9,567 Bytes
914512c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7438bc0
 
 
 
914512c
 
 
 
 
7438bc0
914512c
 
7438bc0
 
 
 
 
 
914512c
 
7438bc0
 
914512c
7438bc0
914512c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df5dcd7
 
 
 
914512c
 
 
 
 
 
 
 
 
df5dcd7
 
 
 
 
 
914512c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df5dcd7
 
 
 
 
 
 
914512c
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
"""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()