finai / apps /api /mbf /graph /agent.py
Sandy-sys's picture
Deploy FinAI
db7ef8e verified
Raw History Blame Contribute Delete
18.8 kB
"""Agent orchestrator: routes question β†’ multi-stage pipeline β†’ UIPlan.
Pipeline stages activate different slices of the 30-key pool, so a single
question distributes load across 8-12 distinct keys in parallel:
1. intent β†’ LIGHT (17-19)
2. retrieve β†’ RETRIEVER (11-16) ← GraphRAG-lite schema rerank
3. plan β†’ SQL (7-10)
4. critic β†’ CRITIC (1-6) ← runs concurrently with primary exec
4b.verify β†’ VERIFIER (20-23) ← second-opinion on result quality
5. insights β†’ SUMMARY (11-16) ← fan-out, 1 LLM call per result set
6. narrative β†’ NARRATIVE (7-10)
7. followup β†’ FOLLOWUP (24-26) ← smart follow-up generation
Each stage is resilient: any failure degrades to a heuristic and the pipeline
continues.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
# Type for stage event emitter: async function(event_dict) -> None
EmitFn = Callable[[dict[str, Any]], Awaitable[None]]
from ..keys.pool import get_pool
from ..retrieval.duck import run_sql
from ..retrieval.sql_safety_duck import validate_duckdb_sql
from ..ui_plan.schema import UIPlan
from .dynamic import (
ResultSet,
_pick_table,
assemble_plan,
dynamic_offline_plan,
heuristic_secondaries,
)
from .pipeline import (
classify_intent,
critique_sql,
exec_narrative,
generate_followups,
per_result_insights,
plan_sqls,
retrieve_tables,
verify_sql,
)
@dataclass
class AgentState:
question: str
intent: str = "exploratory"
sql: str | None = None
rows: list[dict[str, Any]] = field(default_factory=list)
plan: UIPlan | None = None
trace: list[dict[str, Any]] = field(default_factory=list)
mode: str = "offline"
keys_used: list[str] = field(default_factory=list)
confidence: float = 0.7
followups: list[str] = field(default_factory=list)
def _safe_run(sql: str) -> tuple[str, list[dict[str, Any]], str | None]:
chk = validate_duckdb_sql(sql)
if not chk.ok:
return sql, [], chk.reason
res = run_sql(chk.rewritten)
return chk.rewritten, res.rows, res.error
def _track(state: AgentState, kid: str | None) -> None:
if kid and kid not in state.keys_used:
state.keys_used.append(kid)
async def _noop_emit(_evt: dict[str, Any]) -> None:
return None
# --------------------------------------------------------------------------- #
# Result-quality helpers #
# --------------------------------------------------------------------------- #
def _is_all_null_or_zero(rows: list[dict[str, Any]]) -> bool:
"""True when every numeric value in every row is None or 0,
meaning the result set carries no useful information.
Non-numeric columns (labels, ids) are ignored."""
if not rows:
return True
found_any_numeric = False
for r in rows:
for v in r.values():
if v is None:
continue
if isinstance(v, (int, float)):
found_any_numeric = True
if v != 0:
return False
# strings that look like "0" or "0.00" β€” common from VARCHARs
elif isinstance(v, str):
try:
fv = float(v)
found_any_numeric = True
if fv != 0:
return False
except (ValueError, OverflowError):
pass # genuine text label β€” skip
# If there were no numeric columns at all, treat the result as meaningful
# (it's probably a label/lookup query).
return found_any_numeric
def _filter_meaningful(results: list[ResultSet]) -> list[ResultSet]:
"""Drop result sets whose numeric data is entirely null/zero."""
return [r for r in results if not _is_all_null_or_zero(r.rows)]
def _preview_rows(rows: list[dict[str, Any]], n: int = 3) -> list[dict[str, Any]]:
out = []
for r in rows[:n]:
item = {}
for k, v in r.items():
if isinstance(v, (int, float)):
item[k] = v
elif v is None:
item[k] = None
else:
s = str(v)
item[k] = s if len(s) <= 60 else s[:57] + "…"
out.append(item)
return out
async def _exec_secondaries_parallel(
question: str, table: str, specs: list[dict[str, Any]]
) -> list[ResultSet]:
"""Run secondary SQLs concurrently; each DuckDB call is short & cheap."""
def _job(spec: dict[str, Any]) -> ResultSet | None:
sq = (spec.get("sql") or "").strip()
st = spec.get("title") or "Companion view"
if not sq:
return None
rewritten, rows, err = _safe_run(sq)
if err or not rows or _is_all_null_or_zero(rows):
return None
return ResultSet(title=st, sql=rewritten, rows=rows, table=table)
loop = asyncio.get_running_loop()
tasks = [loop.run_in_executor(None, _job, s) for s in specs[:3]]
out = await asyncio.gather(*tasks, return_exceptions=True)
return [r for r in out if isinstance(r, ResultSet)]
async def _run_llm_path(state: AgentState, emit: EmitFn = _noop_emit) -> AgentState:
q = state.question
t0 = time.perf_counter()
def _ms() -> int:
return int((time.perf_counter() - t0) * 1000)
# Stages 1 + 2 run in parallel β€” intent and retrieval are independent.
await emit({"stage": "intent", "status": "running", "label": "Classifying intent",
"role": "LIGHT", "ms": _ms()})
await emit({"stage": "retrieve", "status": "running",
"label": "Retrieving relevant tables (GraphRAG-lite)",
"role": "RETRIEVER", "ms": _ms()})
(intent, k_intent), (tables, k_retr) = await asyncio.gather(
classify_intent(q), retrieve_tables(q, k=3),
)
state.intent = intent
_track(state, k_intent)
_track(state, k_retr)
state.trace.append({"stage": "intent", "value": intent, "key": k_intent})
state.trace.append({"stage": "retrieve", "tables": tables, "key": k_retr})
await emit({"stage": "intent", "status": "done", "key": k_intent,
"value": intent, "ms": _ms()})
await emit({"stage": "retrieve", "status": "done", "key": k_retr,
"tables": tables, "ms": _ms()})
if not tables:
tables = [_pick_table(q)]
# Stage 3 β€” SQL plan
await emit({"stage": "plan", "status": "running",
"label": "Generating SQL plan (primary + secondaries)",
"role": "SQL", "ms": _ms()})
plan_data, k_plan = await plan_sqls(q, tables, intent)
_track(state, k_plan)
state.trace.append({"stage": "plan", "key": k_plan,
"has_primary": bool(plan_data.get("primary"))})
table = plan_data.get("table") or tables[0]
primary = plan_data.get("primary") or {}
secondaries = plan_data.get("secondaries") or []
p_sql = (primary.get("sql") or "").strip()
p_title = primary.get("title") or "Result"
await emit({"stage": "plan", "status": "done", "key": k_plan,
"table": table, "primary_title": p_title,
"primary_sql": p_sql,
"secondary_count": len(secondaries),
"secondary_titles": [s.get("title") for s in secondaries[:3]],
"ms": _ms()})
# Stage 4 β€” SQL critic runs CONCURRENTLY with primary execution.
primary_result: ResultSet | None = None
if p_sql:
await emit({"stage": "critic", "status": "running",
"label": "Auditing primary SQL", "role": "CRITIC", "ms": _ms()})
await emit({"stage": "exec_primary", "status": "running",
"label": "Executing primary query on DuckDB",
"role": "DUCKDB", "ms": _ms()})
async def _run_primary() -> tuple[str, list[dict], str | None]:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _safe_run, p_sql)
critic_fut = asyncio.create_task(critique_sql(q, table, p_sql))
exec_fut = asyncio.create_task(_run_primary())
(rewritten, rows, err), (fix, k_crit) = await asyncio.gather(exec_fut, critic_fut)
_track(state, k_crit)
state.trace.append({"stage": "critic", "key": k_crit,
"applied": bool(fix) and bool(err)})
# Apply critic fix if execution failed OR if results are all-null/zero
all_null = (not err and rows and _is_all_null_or_zero(rows))
await emit({"stage": "exec_primary", "status": "done",
"row_count": len(rows), "error": err,
"all_null": bool(all_null), "ms": _ms()})
await emit({"stage": "critic", "status": "done", "key": k_crit,
"fix_provided": bool(fix),
"will_apply": bool(fix) and (bool(err) or bool(all_null)),
"ms": _ms()})
if (err or all_null) and fix:
await emit({"stage": "critic_retry", "status": "running",
"label": "Re-running corrected SQL", "ms": _ms()})
rewritten, rows, err = _safe_run(fix)
state.trace.append({"stage": "critic_retry", "error": err,
"fix_len": len(fix)})
await emit({"stage": "critic_retry", "status": "done",
"row_count": len(rows), "error": err, "ms": _ms()})
state.sql = rewritten
state.rows = rows
if not err and rows:
primary_result = ResultSet(title=p_title, sql=rewritten,
rows=rows, table=table)
# Stage 4b β€” SQL verifier (second opinion, runs concurrently w/ secondaries)
verify_task = None
if primary_result and primary_result.rows:
await emit({"stage": "verify", "status": "running",
"label": "Verifying result quality (second opinion)",
"role": "VERIFIER", "ms": _ms()})
verify_task = asyncio.create_task(
verify_sql(q, table, primary_result.sql, primary_result.rows)
)
# Stage 3b β€” secondaries fan-out in parallel DuckDB executions
results: list[ResultSet] = []
if primary_result:
results.append(primary_result)
if secondaries:
await emit({"stage": "secondaries", "status": "running",
"label": f"Running {min(len(secondaries),3)} companion queries in parallel",
"role": "DUCKDB", "ms": _ms()})
sec_results = await _exec_secondaries_parallel(q, table, secondaries)
results.extend(sec_results)
if secondaries:
await emit({"stage": "secondaries", "status": "done",
"succeeded": len(sec_results),
"attempted": min(len(secondaries), 3),
"ms": _ms()})
# Collect verifier result (ran concurrently with secondaries)
if verify_task:
try:
v_fix, v_conf, k_ver = await verify_task
_track(state, k_ver)
state.confidence = v_conf
state.trace.append({"stage": "verify", "key": k_ver,
"confidence": v_conf,
"fix_provided": bool(v_fix)})
await emit({"stage": "verify", "status": "done", "key": k_ver,
"confidence": v_conf,
"fix_provided": bool(v_fix), "ms": _ms()})
# If verifier says results are wrong and provides a fix, re-run
if v_fix and v_conf < 0.5:
await emit({"stage": "verify_retry", "status": "running",
"label": "Re-running verifier-corrected SQL",
"ms": _ms()})
rewritten, rows, err = _safe_run(v_fix)
if not err and rows and not _is_all_null_or_zero(rows):
state.sql = rewritten
state.rows = rows
results[0] = ResultSet(title=p_title, sql=rewritten,
rows=rows, table=table)
state.confidence = max(v_conf + 0.2, 0.6)
state.trace.append({"stage": "verify_retry", "error": err,
"row_count": len(rows)})
await emit({"stage": "verify_retry", "status": "done",
"row_count": len(rows), "error": err, "ms": _ms()})
except Exception: # noqa: BLE001
state.confidence = 0.5
# top-up with heuristic companion views if still sparse
if len(results) < 3:
await emit({"stage": "heuristic_topup", "status": "running",
"label": "Adding heuristic companion views", "ms": _ms()})
added = 0
for ti, sq in heuristic_secondaries(q, table):
if any(r.sql.strip() == sq.strip() for r in results):
continue
rewritten, rows, err = _safe_run(sq)
if err or not rows or _is_all_null_or_zero(rows):
continue
results.append(ResultSet(title=ti, sql=rewritten, rows=rows,
table=table))
added += 1
if len(results) >= 4:
break
await emit({"stage": "heuristic_topup", "status": "done",
"added": added, "ms": _ms()})
# Drop result sets whose numeric data is entirely null/zero
results = _filter_meaningful(results)
if not results:
state.trace.append({"stage": "llm_empty_fallback"})
state.plan = dynamic_offline_plan(q)
await emit({"stage": "fallback", "status": "done",
"label": "No results β€” using offline dynamic plan", "ms": _ms()})
return state
# Emit a preview of result sets so the UI can show data right away
await emit({"stage": "result_sets", "status": "done",
"count": len(results),
"previews": [
{"title": r.title, "rows": _preview_rows(r.rows),
"row_count": len(r.rows),
"columns": list(r.rows[0].keys()) if r.rows else []}
for r in results
],
"ms": _ms()})
# Stages 5 + 6 + 7 in parallel β€” insights, narrative, AND follow-ups
await emit({"stage": "insights", "status": "running",
"label": f"Generating per-result insights ({len(results)} parallel)",
"role": "SUMMARY", "ms": _ms()})
await emit({"stage": "narrative", "status": "running",
"label": "Drafting executive narrative",
"role": "NARRATIVE", "ms": _ms()})
await emit({"stage": "followups", "status": "running",
"label": "Generating smart follow-up questions",
"role": "FOLLOWUP", "ms": _ms()})
(insights_pair, narrative_pair, followup_pair) = await asyncio.gather(
per_result_insights(q, [(r.title, r.rows) for r in results]),
exec_narrative(q, [(r.title, r.rows) for r in results]),
generate_followups(q, [(r.title, r.rows) for r in results], intent),
)
insights, k_insights = insights_pair
narrative, k_nar = narrative_pair
followups, k_fol = followup_pair
for k in k_insights:
_track(state, k)
_track(state, k_nar)
_track(state, k_fol)
state.followups = followups
state.trace.append({"stage": "insights", "count": len(insights),
"keys": k_insights})
state.trace.append({"stage": "narrative", "key": k_nar})
state.trace.append({"stage": "followups", "key": k_fol,
"count": len(followups)})
await emit({"stage": "insights", "status": "done", "keys": k_insights,
"count": len([i for i in insights if i]), "ms": _ms()})
await emit({"stage": "narrative", "status": "done", "key": k_nar,
"preview": (narrative or "")[:200], "ms": _ms()})
await emit({"stage": "followups", "status": "done", "key": k_fol,
"count": len(followups), "ms": _ms()})
await emit({"stage": "synth", "status": "running",
"label": "Assembling adaptive UI plan", "ms": _ms()})
plan = assemble_plan(q, results, intent=intent)
if narrative:
plan.narrative = narrative
# Attach per-slice insights as the `why` on each data-table component
if insights:
tbl_comps = [c for c in plan.components if c.type == "data-table"]
for comp, ins in zip(tbl_comps, insights):
if ins:
comp.why = ins
state.plan = plan
state.trace.append({
"stage": "synth",
"components": len(plan.components),
"result_sets": len(results),
"keys_used": state.keys_used,
})
await emit({"stage": "synth", "status": "done",
"components": len(plan.components),
"result_sets": len(results),
"keys_used": state.keys_used, "ms": _ms()})
return state
async def run_agent(question: str, emit: EmitFn = _noop_emit) -> AgentState:
state = AgentState(question=question)
if not get_pool().available():
state.mode = "offline"
plan = dynamic_offline_plan(question)
state.plan = plan
state.intent = "descriptive"
for c in plan.citations:
if c.snippet:
state.sql = c.snippet
break
state.trace.append({"stage": "offline_dynamic",
"components": len(plan.components),
"result_sets": len(plan.citations)})
await emit({"stage": "offline", "status": "done",
"label": "No keys available β€” offline plan",
"components": len(plan.components)})
return state
state.mode = "groq"
try:
return await _run_llm_path(state, emit=emit)
except Exception as e: # noqa: BLE001
state.trace.append({"stage": "llm_exception", "error": str(e)[:200]})
state.plan = dynamic_offline_plan(question)
state.intent = "descriptive"
await emit({"stage": "exception", "status": "error",
"error": str(e)[:200]})
return state