Files
nvi/api/main.py

130 lines
3.9 KiB
Python

"""FastAPI entry point for nvi.
Endpoints:
GET /healthz — liveness probe
POST /ask — submit a question, returns run_id
GET /runs/{run_id}/stream — SSE stream of run events
GET /runs/{run_id} — final state snapshot
GET /runs/{run_id}/log — JSONL transcript (every published event)
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
from api.runtime import events
from api.runtime.runner import run as run_graph
logger = logging.getLogger("nvi")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# In-memory run store. Each entry holds final-state + lifecycle metadata; the
# event queue is owned by api.runtime.events.
runs: dict[str, dict] = {}
RUN_TTL_SECONDS = 3600
RUN_CLEANUP_INTERVAL = 300
async def _cleanup_runs():
while True:
await asyncio.sleep(RUN_CLEANUP_INTERVAL)
now = datetime.now(timezone.utc).timestamp()
expired = [
rid for rid, r in runs.items()
if r["status"] in ("completed", "error")
and now - r.get("created_at", now) > RUN_TTL_SECONDS
]
for rid in expired:
del runs[rid]
@asynccontextmanager
async def lifespan(_app: FastAPI):
task = asyncio.create_task(_cleanup_runs())
yield
task.cancel()
app = FastAPI(title="nvi", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/healthz")
async def healthz():
return {"status": "ok", "runs_in_memory": len(runs)}
class AskRequest(BaseModel):
question: str
@app.post("/ask")
async def ask(req: AskRequest):
run_id = uuid.uuid4().hex[:8]
now = datetime.now(timezone.utc)
runs[run_id] = {
"status": "running",
"question": req.question,
"created_at": now.timestamp(),
}
events.open_run(run_id)
async def _drive():
try:
final = await run_graph(run_id, req.question)
runs[run_id] = {
**runs[run_id],
"status": "error" if final.get("error") else "completed",
"plan_rationale": final.get("plan_rationale"),
"plan_steps": final.get("plan_steps", []),
"findings": final.get("findings", []),
"answer": final.get("answer", ""),
"error": final.get("error"),
}
except Exception as e:
logger.exception("run %s failed at top level", run_id)
runs[run_id] = {**runs[run_id], "status": "error", "error": str(e)}
asyncio.create_task(_drive())
logger.info("ask_received run_id=%s q=%r", run_id, req.question)
return {"run_id": run_id, "status": "running"}
@app.get("/runs/{run_id}")
async def get_run(run_id: str):
if run_id not in runs:
raise HTTPException(404, detail=f"Run {run_id} not found")
return runs[run_id]
@app.get("/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in runs:
raise HTTPException(404, detail=f"Run {run_id} not found")
return StreamingResponse(events.stream(run_id), media_type="text/event-stream")
@app.get("/runs/{run_id}/log")
async def get_run_log(run_id: str):
"""Serve the JSONL transcript written by `events.publish`. Available even
for in-flight runs (the file is flushed after every write)."""
path = events.log_path(run_id)
if not path.exists():
raise HTTPException(404, detail=f"No log file for run {run_id}")
return FileResponse(path, media_type="application/x-ndjson", filename=path.name)