File size: 18,833 Bytes
db7ef8e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 | """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
|