recon + sqlglot validator + drill_down package; guard ReAct dimension picks against candidate list

This commit is contained in:
2026-06-03 07:15:02 -03:00
parent e124a8a7d9
commit 2dad62f7e7
38 changed files with 1954 additions and 596 deletions

View File

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