recon + sqlglot validator + drill_down package; guard ReAct dimension picks against candidate list
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""Per-run context — exposes the active run_id to code deep in the call stack
|
||||
without threading it through every signature.
|
||||
"""Per-run context — exposes the active run_id (and active Analysis) to code
|
||||
deep in the call stack without threading them through every signature.
|
||||
|
||||
Set by the runtime at run entry; read by Analyses and helper functions so they
|
||||
can publish trace events without needing the run_id passed in explicitly.
|
||||
Set by the runtime; read by Analyses, Tools, and helper functions so they
|
||||
can publish trace events with the right associations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,3 +10,8 @@ from __future__ import annotations
|
||||
from contextvars import ContextVar
|
||||
|
||||
current_run_id: ContextVar[str | None] = ContextVar("nvi.current_run_id", default=None)
|
||||
|
||||
# Name of the Analysis currently executing (e.g. "direct_answer"). Set by the
|
||||
# runtime in `_execute_node` before each Analysis runs, reset after. Used by
|
||||
# event factories to annotate tool/llm calls so the UI can group them.
|
||||
current_analysis: ContextVar[str | None] = ContextVar("nvi.current_analysis", default=None)
|
||||
|
||||
@@ -5,6 +5,10 @@ transition; the FastAPI SSE endpoint subscribes and forwards.
|
||||
|
||||
Event payloads are constructed via the module-level factory functions below
|
||||
so the dict shape lives in one place and is grep-able.
|
||||
|
||||
Many event types carry an `analysis` field that names the active Analysis
|
||||
(read from `current_analysis` ContextVar). The UI uses it to group
|
||||
sub-events under the right Analysis node without inferring from time order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,7 +18,7 @@ import json
|
||||
from typing import Any
|
||||
|
||||
from api.analyses.types import Finding
|
||||
from api.runtime.context import current_run_id
|
||||
from api.runtime.context import current_analysis, current_run_id
|
||||
|
||||
# run_id -> queue of events. Events are plain dicts.
|
||||
_queues: dict[str, asyncio.Queue[dict[str, Any] | None]] = {}
|
||||
@@ -80,6 +84,8 @@ async def stream(run_id: str):
|
||||
# ── Event factories ──
|
||||
# Each returns the dict payload to pass into `publish()` /
|
||||
# `publish_current()`. Names mirror the `type` field for grep-ability.
|
||||
# Factories that fire from inside an Analysis pick up the analysis name from
|
||||
# the `current_analysis` ContextVar so callers don't have to pass it.
|
||||
|
||||
def run_start(question: str) -> dict[str, Any]:
|
||||
return {"type": "run_start", "question": question}
|
||||
@@ -101,33 +107,66 @@ def node_update(node: str, update: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def analysis_start(name: str, args: dict[str, Any], why: str) -> dict[str, Any]:
|
||||
return {"type": "analysis_start", "analysis": name, "args": args, "why": why}
|
||||
def analysis_start(name: str, args: dict[str, Any], why: str, *,
|
||||
step_id: str | None = None) -> dict[str, Any]:
|
||||
return {"type": "analysis_start", "analysis": name, "step_id": step_id,
|
||||
"args": args, "why": why}
|
||||
|
||||
|
||||
def analysis_end(name: str, finding: Finding) -> dict[str, Any]:
|
||||
def analysis_end(name: str, finding: Finding, *,
|
||||
step_id: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "analysis_end",
|
||||
"analysis": name,
|
||||
"step_id": step_id,
|
||||
"summary": finding.summary,
|
||||
"error": finding.error,
|
||||
"row_count": len(finding.rows),
|
||||
}
|
||||
|
||||
|
||||
def analysis_fallback(*, primary: str, fallback: str, reason: str,
|
||||
step_id: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "analysis_fallback",
|
||||
"primary": primary,
|
||||
"fallback": fallback,
|
||||
"reason": reason,
|
||||
"step_id": step_id,
|
||||
}
|
||||
|
||||
|
||||
def synth_start() -> dict[str, Any]:
|
||||
return {"type": "synth_start"}
|
||||
|
||||
|
||||
def tool_call_start(tool: str, *, input: Any = None) -> dict[str, Any]:
|
||||
return {"type": "tool_call_start", "tool": tool, "input": input}
|
||||
return {
|
||||
"type": "tool_call_start",
|
||||
"tool": tool,
|
||||
"analysis": current_analysis.get(),
|
||||
"input": input,
|
||||
}
|
||||
|
||||
|
||||
def tool_call_end(tool: str, *, output: Any = None, error: str | None = None) -> dict[str, Any]:
|
||||
return {"type": "tool_call_end", "tool": tool, "output": output, "error": error}
|
||||
def tool_call_end(tool: str, *, output: Any = None,
|
||||
error: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "tool_call_end",
|
||||
"tool": tool,
|
||||
"analysis": current_analysis.get(),
|
||||
"output": output,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def llm_call(label: str, *, system_len: int, user_len: int) -> dict[str, Any]:
|
||||
"""Lightweight breadcrumb for an LLM call. Doesn't carry the prompt
|
||||
contents (those go through Langfuse) — just the fact and rough size."""
|
||||
return {"type": "llm_call", "label": label, "system_chars": system_len, "user_chars": user_len}
|
||||
return {
|
||||
"type": "llm_call",
|
||||
"label": label,
|
||||
"analysis": current_analysis.get(),
|
||||
"system_chars": system_len,
|
||||
"user_chars": user_len,
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
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.
|
||||
`execute` iterates Plan steps and dispatches each to its Analysis. A step
|
||||
can declare an optional `fallback` Analysis that runs when the primary
|
||||
errors or returns an empty finding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,7 +22,7 @@ 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.context import current_analysis, current_run_id
|
||||
from api.runtime.state import RunState
|
||||
|
||||
logger = logging.getLogger("nvi.runtime")
|
||||
@@ -35,25 +36,74 @@ def _plan_node(state: RunState) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _step_id(idx: int, name: str, suffix: str | None = None) -> str:
|
||||
base = f"{name}#{idx}"
|
||||
return f"{base}.{suffix}" if suffix else base
|
||||
|
||||
|
||||
async def _invoke_analysis(name: str, args: dict[str, Any], question: str,
|
||||
*, step_id: str, run_id: str, why: str) -> Finding:
|
||||
"""Run a single Analysis with contextvar set, events around it, and
|
||||
error→Finding catch. Returns the Finding (success or failure)."""
|
||||
analysis = ANALYSES.get(name)
|
||||
await events.publish(run_id, events.analysis_start(name, args, why, step_id=step_id))
|
||||
if analysis is None:
|
||||
f = Finding(analysis=name, summary="", error=f"unknown analysis {name!r}")
|
||||
else:
|
||||
token = current_analysis.set(step_id)
|
||||
try:
|
||||
f = await analysis.run(args, question)
|
||||
except Exception as e:
|
||||
logger.exception("analysis %s raised", name)
|
||||
f = Finding(analysis=name, summary="", error=str(e))
|
||||
finally:
|
||||
current_analysis.reset(token)
|
||||
await events.publish(run_id, events.analysis_end(name, f, step_id=step_id))
|
||||
return f
|
||||
|
||||
|
||||
def _finding_is_empty_or_errored(f: Finding) -> bool:
|
||||
if f.error:
|
||||
return True
|
||||
if not f.summary and not f.rows:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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", "")),
|
||||
run_id = state["run_id"]
|
||||
for idx, step in enumerate(state.get("plan_steps", [])):
|
||||
primary_name = step["analysis"]
|
||||
primary_args = step.get("args", {})
|
||||
primary_why = step.get("why", "")
|
||||
primary_step_id = _step_id(idx, primary_name)
|
||||
|
||||
primary = await _invoke_analysis(
|
||||
primary_name, primary_args, state["question"],
|
||||
step_id=primary_step_id, run_id=run_id, why=primary_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))
|
||||
primary_dict = dataclasses.asdict(primary)
|
||||
primary_dict["step_id"] = primary_step_id
|
||||
findings.append(primary_dict)
|
||||
|
||||
fb = step.get("fallback")
|
||||
if fb and _finding_is_empty_or_errored(primary):
|
||||
fb_name = fb["analysis"]
|
||||
fb_step_id = _step_id(idx, fb_name, suffix="fallback")
|
||||
reason = primary.error or "primary returned empty finding"
|
||||
await events.publish(run_id, events.analysis_fallback(
|
||||
primary=primary_name, fallback=fb_name, reason=reason,
|
||||
step_id=fb_step_id,
|
||||
))
|
||||
fb_finding = await _invoke_analysis(
|
||||
fb_name, fb.get("args", {}), state["question"],
|
||||
step_id=fb_step_id, run_id=run_id, why=fb.get("why", ""),
|
||||
)
|
||||
fb_dict = dataclasses.asdict(fb_finding)
|
||||
fb_dict["step_id"] = fb_step_id
|
||||
fb_dict["is_fallback_for"] = primary_step_id
|
||||
findings.append(fb_dict)
|
||||
return {"findings": findings}
|
||||
|
||||
|
||||
@@ -61,14 +111,17 @@ 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):
|
||||
# Effective findings = primary OR its fallback if the primary was empty.
|
||||
# Pass everything through; the LLM weighs which one to cite.
|
||||
usable = [f for f in findings if not _finding_is_empty_or_errored(_dict_to_finding(f))]
|
||||
if not usable:
|
||||
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"[{f['analysis']}{ ' (fallback)' if f.get('is_fallback_for') else '' }]\n"
|
||||
f"summary: {f.get('summary') or '(none)'}\n"
|
||||
f"error: {f.get('error') or '(none)'}\n"
|
||||
f"sql: {f.get('sql')}"
|
||||
@@ -89,6 +142,17 @@ async def _synthesize_node(state: RunState) -> dict[str, Any]:
|
||||
return {"answer": answer}
|
||||
|
||||
|
||||
def _dict_to_finding(d: dict[str, Any]) -> Finding:
|
||||
return Finding(
|
||||
analysis=d.get("analysis", ""),
|
||||
summary=d.get("summary", ""),
|
||||
rows=d.get("rows") or [],
|
||||
sql=d.get("sql") or [],
|
||||
error=d.get("error"),
|
||||
metadata=d.get("metadata") or {},
|
||||
)
|
||||
|
||||
|
||||
def build_graph():
|
||||
g: StateGraph = StateGraph(RunState)
|
||||
g.add_node("plan", _plan_node)
|
||||
|
||||
Reference in New Issue
Block a user