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

139
api/runtime/runner.py Normal file
View File

@@ -0,0 +1,139 @@
"""langgraph wiring — the one place the framework appears.
Graph: START → plan → execute → synthesize → END
`execute` iterates Plan steps and dispatches each to its Analysis. Sequential
for now; swap to a fan-out with parallel nodes once we want to parallelise.
"""
from __future__ import annotations
import dataclasses
import logging
from typing import Any
from langgraph.graph import END, START, StateGraph
from api import langfuse_client as lf
from api.analyses.registry import REGISTRY as ANALYSES
from api.analyses.types import Finding
from api.llm import chat
from api.plan.planner import plan as run_planner
from api.prompts import load, render
from api.runtime import events
from api.runtime.context import current_run_id
from api.runtime.state import RunState
logger = logging.getLogger("nvi.runtime")
def _plan_node(state: RunState) -> dict[str, Any]:
plan_obj = run_planner(state["question"])
return {
"plan_rationale": plan_obj.rationale,
"plan_steps": [s.to_dict() for s in plan_obj.steps],
}
async def _execute_node(state: RunState) -> dict[str, Any]:
findings: list[dict[str, Any]] = []
for step in state.get("plan_steps", []):
name = step["analysis"]
analysis = ANALYSES.get(name)
await events.publish(
state["run_id"],
events.analysis_start(name, step.get("args", {}), step.get("why", "")),
)
if analysis is None:
f = Finding(analysis=name, summary="", error=f"unknown analysis {name!r}")
else:
try:
f = await analysis.run(step.get("args", {}), state["question"])
except Exception as e:
logger.exception("analysis %s raised", name)
f = Finding(analysis=name, summary="", error=str(e))
findings.append(dataclasses.asdict(f))
await events.publish(state["run_id"], events.analysis_end(name, f))
return {"findings": findings}
async def _synthesize_node(state: RunState) -> dict[str, Any]:
await events.publish(state["run_id"], events.synth_start())
findings = state.get("findings", [])
if not findings or all(f.get("error") for f in findings):
return {
"answer": "I couldn't answer this question — see the per-analysis errors above.",
"error": "all_analyses_failed",
}
findings_block = "\n\n".join(
f"[{f['analysis']}]\n"
f"summary: {f.get('summary') or '(none)'}\n"
f"error: {f.get('error') or '(none)'}\n"
f"sql: {f.get('sql')}"
for f in findings
)
with lf.span("synthesize", as_type="generation", input={"findings": len(findings)}) as span:
answer = chat(
system=load("synthesize.system"),
user=render(
"synthesize.user",
question=state["question"],
findings_block=findings_block,
),
max_tokens=512,
)
span.update(output={"answer": answer})
return {"answer": answer}
def build_graph():
g: StateGraph = StateGraph(RunState)
g.add_node("plan", _plan_node)
g.add_node("execute", _execute_node)
g.add_node("synthesize", _synthesize_node)
g.add_edge(START, "plan")
g.add_edge("plan", "execute")
g.add_edge("execute", "synthesize")
g.add_edge("synthesize", END)
return g.compile()
GRAPH = build_graph()
async def run(run_id: str, question: str) -> RunState:
"""Drive the graph end-to-end, publishing SSE events along the way."""
initial: RunState = {"run_id": run_id, "question": question}
final: RunState = initial
token = current_run_id.set(run_id)
try:
await events.publish(run_id, events.run_start(question))
async for event in GRAPH.astream(initial, stream_mode="updates"):
for node, update in event.items():
await events.publish(run_id, events.node_update(node, update))
if isinstance(update, dict):
final = {**final, **update}
if node == "plan" and isinstance(update, dict):
await events.publish(
run_id,
events.plan_ready(
update.get("plan_rationale", ""),
update.get("plan_steps", []),
),
)
await events.publish(
run_id,
events.run_end(answer=final.get("answer", ""), error=final.get("error")),
)
except Exception as e:
logger.exception("run %s failed", run_id)
await events.publish(run_id, events.run_end(error=str(e)))
final = {**final, "error": str(e)}
finally:
current_run_id.reset(token)
await events.close(run_id)
lf.flush()
return final