verbose live UI + tool-level SSE events + Groq default + regression tests

This commit is contained in:
2026-06-03 05:04:29 -03:00
parent 131f4d9b86
commit e124a8a7d9
69 changed files with 3030 additions and 137 deletions

View File

@@ -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")