verbose live UI + tool-level SSE events + Groq default + regression tests
This commit is contained in:
52
api/main.py
52
api/main.py
@@ -1,14 +1,14 @@
|
||||
"""FastAPI entry point for nvi.
|
||||
|
||||
Endpoints:
|
||||
GET /healthz — liveness / readiness probe
|
||||
GET /healthz — liveness probe
|
||||
POST /ask — submit a question, returns run_id
|
||||
GET /runs/{run_id}/stream — SSE stream of node-state events
|
||||
GET /runs/{run_id}/stream — SSE stream of run events
|
||||
GET /runs/{run_id} — final state snapshot
|
||||
|
||||
Stubs only at this point — the Plan/Analyses/Tools/runtime are next.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
@@ -20,12 +20,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import 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 (single-process; replace with redis if we ever scale) ──
|
||||
|
||||
# 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
|
||||
@@ -52,7 +55,6 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(title="nvi", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -66,8 +68,6 @@ async def healthz():
|
||||
return {"status": "ok", "runs_in_memory": len(runs)}
|
||||
|
||||
|
||||
# ── Ask ──
|
||||
|
||||
class AskRequest(BaseModel):
|
||||
question: str
|
||||
|
||||
@@ -77,14 +77,31 @@ async def ask(req: AskRequest):
|
||||
run_id = uuid.uuid4().hex[:8]
|
||||
now = datetime.now(timezone.utc)
|
||||
runs[run_id] = {
|
||||
"status": "pending",
|
||||
"status": "running",
|
||||
"question": req.question,
|
||||
"created_at": now.timestamp(),
|
||||
"events": [],
|
||||
}
|
||||
# Real runtime invocation lands here once it exists.
|
||||
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": "pending"}
|
||||
return {"run_id": run_id, "status": "running"}
|
||||
|
||||
|
||||
@app.get("/runs/{run_id}")
|
||||
@@ -98,11 +115,4 @@ async def get_run(run_id: str):
|
||||
async def stream_run(run_id: str):
|
||||
if run_id not in runs:
|
||||
raise HTTPException(404, detail=f"Run {run_id} not found")
|
||||
|
||||
async def event_stream():
|
||||
# Placeholder: real implementation will tail the runtime's event queue.
|
||||
yield f"event: hello\ndata: {{\"run_id\": \"{run_id}\"}}\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
yield "event: end\ndata: {}\n\n"
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
return StreamingResponse(events.stream(run_id), media_type="text/event-stream")
|
||||
|
||||
Reference in New Issue
Block a user