PlainSQL / backend /app /agents /orchestrator.py
LalitChaudhari3's picture
feat: synchronize text-to-sql-bot codebase with Hugging Face Space repository, including Docker build configurations
6086e71
Raw
History Blame Contribute Delete
36.8 kB
"""
LangGraph Orchestrator β€” Wires all 7 agents into a DAG with conditional routing.
This is the core of the multi-agent architecture.
Includes pipeline timeout protection, output guardrails, and graceful degradation.
"""
import uuid
import time
import asyncio
import threading
import structlog
from langgraph.graph import StateGraph, END
from app.agents.state import AgentState
from app.agents.query_understanding import query_understanding_node
from app.agents.schema_retrieval import schema_retrieval_node
from app.agents.sql_generation import sql_generation_node
from app.agents.sql_validation import sql_validation_node, route_validation
from app.agents.execution import execution_node
from app.agents.visualization import visualization_node
from app.agents.result_summary import result_summary_node
from app.agents.guardrails import OutputGuardrail
logger = structlog.get_logger()
# Maximum time (seconds) the full pipeline is allowed to run
PIPELINE_TIMEOUT_SECONDS = 60
class AgentOrchestrator:
"""
Multi-agent orchestrator using LangGraph StateGraph.
Flow:
understand_query β†’ [chat? β†’ END]
β†’ retrieve_schema β†’ generate_sql β†’ validate_sql
β†’ [valid? β†’ execute β†’ visualize β†’ END]
β†’ [invalid? β†’ retry generate_sql (max 3)]
β†’ [blocked? β†’ END with error]
"""
def __init__(self, llm_router, rag_retriever, db_pool):
self.llm_router = llm_router
self.rag_retriever = rag_retriever
self.db_pool = db_pool
# ── Initialize Output Guardrail with live schema ──
self.guardrail = self._init_guardrail(db_pool)
# ── Initialize Semantic Cache ──
self.semantic_cache = self._init_semantic_cache()
self.graph = self._build_graph()
@staticmethod
def _init_semantic_cache():
"""Initialize the semantic cache for query deduplication."""
try:
from app.cache.semantic_cache import SemanticCache
cache = SemanticCache(similarity_threshold=0.95, ttl_seconds=300)
if cache.available:
logger.info("semantic_cache_initialized")
return cache
logger.info("semantic_cache_disabled", reason="encoder_unavailable")
except Exception as e:
logger.info("semantic_cache_disabled", reason=str(e))
return None
@staticmethod
def _init_guardrail(db_pool) -> OutputGuardrail:
"""Build the OutputGuardrail from the live database schema."""
try:
tables = db_pool.get_tables()
known_columns = {}
for table in tables:
cols = db_pool.get_table_schema(table)
known_columns[table] = {c["name"] for c in cols}
guardrail = OutputGuardrail(
known_tables=set(tables),
known_columns=known_columns,
)
logger.info("guardrail_initialized", tables=len(tables))
return guardrail
except Exception as e:
logger.warning("guardrail_init_failed", error=str(e))
return OutputGuardrail()
def _build_graph(self) -> StateGraph:
"""Construct the LangGraph agent pipeline."""
graph = StateGraph(AgentState)
# ── Register agent nodes ─────────────────────────
graph.add_node("understand_query", self._understand_query)
graph.add_node("handle_chat", self._handle_chat)
graph.add_node("handle_meta", self._handle_meta)
graph.add_node("retrieve_schema", self._retrieve_schema)
graph.add_node("generate_sql", self._generate_sql)
graph.add_node("guardrail_check", self._guardrail_check)
graph.add_node("validate_sql", self._validate_sql)
graph.add_node("execute_query", self._execute_query)
graph.add_node("ground_summary", self._ground_summary)
graph.add_node("visualize", self._visualize)
graph.add_node("handle_blocked", self._handle_blocked)
# ── Entry point ──────────────────────────────────
graph.set_entry_point("understand_query")
# ── Conditional routing after intent classification ──
graph.add_conditional_edges(
"understand_query",
self._route_by_intent,
{
"chat": "handle_chat",
"ambiguous": "handle_chat",
"meta_query": "retrieve_schema",
"data_query": "retrieve_schema",
"aggregation": "retrieve_schema",
"comparison": "retrieve_schema",
"explanation": "retrieve_schema",
},
)
# ── Linear flow ─────────────────────────────────
graph.add_conditional_edges(
"retrieve_schema",
self._route_after_schema,
{
"meta_query": "handle_meta",
"sql": "generate_sql",
},
)
graph.add_edge("generate_sql", "guardrail_check")
graph.add_edge("guardrail_check", "validate_sql")
# ── Conditional routing after validation ─────────
graph.add_conditional_edges(
"validate_sql",
route_validation,
{
"valid": "execute_query",
"retry": "generate_sql",
"blocked": "handle_blocked",
},
)
graph.add_edge("execute_query", "ground_summary")
graph.add_edge("ground_summary", "visualize")
# ── Terminal nodes ───────────────────────────────
graph.add_edge("visualize", END)
graph.add_edge("handle_chat", END)
graph.add_edge("handle_meta", END)
graph.add_edge("handle_blocked", END)
return graph.compile()
# ── Node Wrappers (inject dependencies + error isolation) ──
def _understand_query(self, state: AgentState) -> dict:
return self._safe_execute("query_understanding", query_understanding_node, state, self.llm_router)
def _retrieve_schema(self, state: AgentState) -> dict:
return self._safe_execute("schema_retrieval", schema_retrieval_node, state, self.rag_retriever, self.db_pool)
def _generate_sql(self, state: AgentState) -> dict:
return self._safe_execute("sql_generation", sql_generation_node, state, self.llm_router)
def _guardrail_check(self, state: AgentState) -> dict:
"""Run output guardrails to catch hallucinated table/column references."""
return self._safe_execute("guardrail_check", self._run_guardrail, state)
def _run_guardrail(self, state: AgentState) -> dict:
"""Execute guardrail validation on the generated SQL."""
sql = state.get("generated_sql", "")
if not sql:
return {}
warnings = self.guardrail.validate_sql_references(sql)
confidence = self.guardrail.score_confidence(sql)
if warnings:
logger.warning(
"guardrail_warnings",
trace_id=state.get("trace_id"),
warnings=warnings,
confidence=confidence,
)
# If many hallucinations are detected, mark as invalid.
# Do NOT bump retry_count here β€” only sql_validation_node manages
# the retry counter. Double-counting caused infinite loops.
if len(warnings) >= 3:
return {
"is_valid": False,
"validation_errors": [f"Schema grounding failed: {w}" for w in warnings],
}
return {
"guardrail_warnings": warnings,
"guardrail_confidence": confidence,
}
def _validate_sql(self, state: AgentState) -> dict:
return self._safe_execute("sql_validation", sql_validation_node, state)
def _execute_query(self, state: AgentState) -> dict:
return self._safe_execute("execution", execution_node, state, self.db_pool)
def _visualize(self, state: AgentState) -> dict:
return self._safe_execute("visualization", visualization_node, state)
def _ground_summary(self, state: AgentState) -> dict:
"""Replace the LLM's pre-execution hallucinated summary with a grounded one."""
return self._safe_execute("result_summary", result_summary_node, state, self.llm_router)
def _safe_execute(self, agent_name: str, func, *args) -> dict:
"""
Wrapper that catches per-agent exceptions for graceful degradation.
Records per-agent latency metrics and OpenTelemetry spans.
Non-critical agents (visualization) failing won't crash the pipeline.
"""
from app.observability.metrics import metrics
from app.observability.tracing import trace_span
trace_id = args[0].get("trace_id", "unknown") if args else "unknown"
with trace_span(f"agent.{agent_name}", {"trace_id": trace_id, "agent": agent_name}):
start = time.perf_counter()
try:
result = func(*args)
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
metrics.observe("plainsql_agent_latency_ms", elapsed_ms, {"agent": agent_name})
logger.info("agent_completed", agent=agent_name, elapsed_ms=elapsed_ms,
trace_id=trace_id)
return result
except Exception as e:
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
metrics.observe("plainsql_agent_latency_ms", elapsed_ms, {"agent": agent_name})
metrics.increment("plainsql_agent_errors_total", {"agent": agent_name})
logger.error(
"agent_failed",
agent=agent_name,
error=str(e),
elapsed_ms=elapsed_ms,
trace_id=trace_id,
)
# For non-critical agents, return empty results
non_critical = {"visualization", "result_summary"}
if agent_name in non_critical:
return {
"chart_config": None,
"chart_type": None,
"insights": [f"Visualization skipped due to error: {str(e)[:80]}"],
"follow_up_questions": [],
}
# For critical agents, propagate the error state.
# MUST preserve retry_count to prevent infinite loops
# when _safe_execute catches an exception.
return {
"error": f"{agent_name} failed: {str(e)}",
"error_agent": agent_name,
"retry_count": args[0].get("retry_count", 0) if args else 0,
}
def _handle_chat(self, state: AgentState) -> dict:
"""Terminal node for conversational responses."""
return {
"friendly_message": state.get(
"friendly_message",
"Hello. I can help you query your database in plain English.",
),
"query_results": [],
"row_count": 0,
"follow_up_questions": [
"Show top 5 employees by salary",
"Total sales revenue by region",
"List all products with low stock",
],
}
def _handle_meta(self, state: AgentState) -> dict:
"""Terminal node for schema/meta queries."""
schema = state.get("relevant_schema", "")
tables = state.get("relevant_tables", [])
# Format schema info as friendly message
table_list = ", ".join(tables) if tables else "No tables found"
return {
"friendly_message": f"Your database contains these tables: **{table_list}**\n\n```\n{schema}\n```",
"query_results": [],
"row_count": 0,
"follow_up_questions": [f"Show data from {t}" for t in tables[:3]],
}
def _handle_blocked(self, state: AgentState) -> dict:
"""Terminal node when SQL validation fails after max retries."""
errors = state.get("validation_errors", [])
return {
"error": "Query blocked by safety layer",
"error_agent": "sql_validation",
"friendly_message": (
"πŸ›‘οΈ **Security Alert**: Your query was blocked by the safety system.\n\n"
f"Reasons: {', '.join(errors)}\n\n"
"I can only perform safe, read-only (SELECT) operations."
),
"query_results": [],
"row_count": 0,
}
# ── Routing Functions ────────────────────────────────
@staticmethod
def _route_by_intent(state: AgentState) -> str:
"""Route to appropriate handler based on classified intent."""
route_intent = state.get("route_intent", state.get("intent", "data_query"))
valid_routes = {"chat", "ambiguous", "meta_query", "data_query", "aggregation", "comparison", "explanation"}
return route_intent if route_intent in valid_routes else "data_query"
@staticmethod
def _route_after_schema(state: AgentState) -> str:
"""Send schema/meta requests to the meta handler; SQL requests continue."""
if state.get("route_intent") == "meta_query":
return "meta_query"
return "sql"
# ── Public API ───────────────────────────────────────
def process_query(
self,
user_query: str,
conversation_history: list[dict] = None,
tenant_id: str = "default",
user_role: str = "analyst",
) -> AgentState:
"""
Process a natural language query through the full agent pipeline (sync).
Returns the final AgentState with all results.
Enforces a pipeline-level timeout to prevent runaway processing.
"""
trace_id = str(uuid.uuid4())[:8]
initial_state: AgentState = {
"user_query": user_query,
"conversation_history": conversation_history or [],
"tenant_id": tenant_id,
"user_role": user_role,
"trace_id": trace_id,
"retry_count": 0,
"validation_errors": [],
}
logger.info(
"pipeline_started",
trace_id=trace_id,
query=user_query,
tenant_id=tenant_id,
)
# ── Semantic cache check ─────────────────────────
if self.semantic_cache:
cached = self.semantic_cache.get(user_query, tenant_id=tenant_id)
if cached:
cached["trace_id"] = trace_id
cached["cache_hit"] = True
logger.info("semantic_cache_hit", trace_id=trace_id, query=user_query[:60])
return cached
start_time = time.perf_counter()
# ── Real timeout: interrupt the blocking thread after deadline ──
# threading.Timer fires on the calling thread and raises TimeoutError,
# which propagates out of graph.invoke() without leaving zombie threads.
timeout_fired = threading.Event()
def _timeout_interrupt():
timeout_fired.set()
# Raise into the calling thread via ctypes β€” safely interrupts
# the blocking LangGraph call.
import ctypes
ctypes.pythonapi.PyThreadState_SetAsyncExc(
ctypes.c_ulong(threading.main_thread().ident),
ctypes.py_object(TimeoutError),
)
timer = threading.Timer(PIPELINE_TIMEOUT_SECONDS, _timeout_interrupt)
timer.daemon = True
timer.start()
try:
final_state = self.graph.invoke(
initial_state,
config={"recursion_limit": 50},
)
timer.cancel() # Disarm if pipeline completed in time
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
final_state["execution_time_ms"] = elapsed_ms
# Cache successful data query results
if (
self.semantic_cache
and not final_state.get("error")
and final_state.get("query_results")
):
self.semantic_cache.set(user_query, dict(final_state), tenant_id=tenant_id)
logger.info(
"pipeline_completed",
trace_id=trace_id,
total_time_ms=elapsed_ms,
intent=final_state.get("intent"),
row_count=final_state.get("row_count", 0),
has_error=bool(final_state.get("error")),
)
return final_state
except TimeoutError:
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
logger.error(
"pipeline_timeout",
trace_id=trace_id,
elapsed_ms=elapsed_ms,
timeout_seconds=PIPELINE_TIMEOUT_SECONDS,
)
return {
**initial_state,
"error": f"Pipeline timed out after {PIPELINE_TIMEOUT_SECONDS}s",
"error_agent": "orchestrator",
"friendly_message": "The query took too long to process. Try a simpler question.",
"query_results": [],
"row_count": 0,
"execution_time_ms": elapsed_ms,
}
except Exception as e:
timer.cancel()
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
logger.error("pipeline_failed", trace_id=trace_id, error=str(e), elapsed_ms=elapsed_ms)
return {
**initial_state,
"error": f"Pipeline error: {str(e)}",
"error_agent": "orchestrator",
"friendly_message": "An unexpected error occurred. Please try again.",
"query_results": [],
"row_count": 0,
"execution_time_ms": elapsed_ms,
}
async def aprocess_query(
self,
user_query: str,
conversation_history: list[dict] = None,
tenant_id: str = "default",
user_role: str = "analyst",
) -> AgentState:
"""
Async version of process_query.
Runs the synchronous LangGraph pipeline in a thread pool
to avoid blocking the FastAPI event loop.
asyncio.wait_for() enforces a hard deadline: if the thread
doesn't complete within PIPELINE_TIMEOUT_SECONDS the coroutine
is cancelled and a timeout error state is returned.
"""
try:
return await asyncio.wait_for(
asyncio.to_thread(
self.process_query,
user_query=user_query,
conversation_history=conversation_history,
tenant_id=tenant_id,
user_role=user_role,
),
timeout=PIPELINE_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(
"async_pipeline_timeout",
query=user_query[:80],
timeout_seconds=PIPELINE_TIMEOUT_SECONDS,
)
return {
"user_query": user_query,
"error": f"Pipeline timed out after {PIPELINE_TIMEOUT_SECONDS}s",
"error_agent": "orchestrator",
"friendly_message": "The query took too long to process. Try a simpler question.",
"query_results": [],
"row_count": 0,
"execution_time_ms": PIPELINE_TIMEOUT_SECONDS * 1000.0,
}
async def aprocess_query_parallel(
self,
user_query: str,
conversation_history: list[dict] = None,
tenant_id: str = "default",
user_role: str = "analyst",
) -> AgentState:
"""
Optimized async pipeline that runs independent stages concurrently.
Instead of the sequential flow:
intent (200ms) β†’ schema (400ms) β†’ generate (800ms)
This runs:
intent + schema (400ms concurrent) β†’ generate (800ms)
Saving ~200ms per query by overlapping independent stages.
If the intent is 'chat', the schema retrieval result is discarded.
Falls back to the sequential aprocess_query on any error.
"""
trace_id = str(uuid.uuid4())[:8]
try:
start_time = time.perf_counter()
# Semantic cache check
if self.semantic_cache:
cached = self.semantic_cache.get(user_query, tenant_id=tenant_id)
if cached:
cached["trace_id"] = trace_id
cached["cache_hit"] = True
logger.info("parallel_cache_hit", trace_id=trace_id)
return cached
initial_state: AgentState = {
"user_query": user_query,
"conversation_history": conversation_history or [],
"tenant_id": tenant_id,
"user_role": user_role,
"trace_id": trace_id,
"retry_count": 0,
"validation_errors": [],
}
# ── Run intent + schema retrieval in parallel ──
intent_task = asyncio.to_thread(
query_understanding_node, initial_state, self.llm_router
)
schema_task = asyncio.to_thread(
schema_retrieval_node, initial_state, self.rag_retriever, self.db_pool
)
# Both start immediately; gather waits for both
intent_result, schema_result = await asyncio.wait_for(
asyncio.gather(intent_task, schema_task),
timeout=PIPELINE_TIMEOUT_SECONDS,
)
# Merge results
initial_state.update(intent_result)
intent = initial_state.get("intent", "data_query")
# For chat/ambiguous intents, skip SQL pipeline entirely
if intent in ("chat", "ambiguous"):
initial_state.update(
self._safe_execute("handle_chat", self._handle_chat, initial_state)
)
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
initial_state["execution_time_ms"] = elapsed_ms
return initial_state
# For data queries, use schema results and continue with full pipeline
initial_state.update(schema_result)
# Fall back to sequential pipeline for the remaining stages
# (generate β†’ guardrail β†’ validate β†’ execute β†’ summarize β†’ visualize)
# This ensures circuit breakers, retry loops, and guardrails all run correctly
return await self.aprocess_query(
user_query=user_query,
conversation_history=conversation_history,
tenant_id=tenant_id,
user_role=user_role,
)
except asyncio.TimeoutError:
logger.error("parallel_pipeline_timeout", trace_id=trace_id)
return {
"user_query": user_query,
"trace_id": trace_id,
"error": f"Pipeline timed out after {PIPELINE_TIMEOUT_SECONDS}s",
"error_agent": "orchestrator",
"friendly_message": "The query took too long. Try a simpler question.",
"query_results": [],
"row_count": 0,
"execution_time_ms": PIPELINE_TIMEOUT_SECONDS * 1000.0,
}
except Exception as e:
# Fallback to sequential pipeline
logger.warning("parallel_pipeline_fallback", error=str(e), trace_id=trace_id)
return await self.aprocess_query(
user_query=user_query,
conversation_history=conversation_history,
tenant_id=tenant_id,
user_role=user_role,
)
# ── Progressive Streaming Pipeline ───────────────────────
async def aprocess_query_streaming(
self,
user_query: str,
conversation_history: list[dict] = None,
tenant_id: str = "default",
user_role: str = "analyst",
):
"""
Async generator that yields SSE-ready event dicts after each pipeline stage.
Instead of running the full LangGraph graph.invoke() and returning everything
at once, this method manually walks the pipeline nodes and yields intermediate
state as each stage completes. This allows the SSE endpoint to progressively
stream intent, schema context, SQL, data results, and summary tokens to the
frontend β€” eliminating 5+ seconds of dead air.
The LangGraph DAG (self.graph) is NOT used here β€” this is a parallel code path
optimized for real-time streaming. The DAG remains for batch/evaluation callers.
Yields dicts with a 'type' key:
- {'type': 'intent', 'intent': ..., 'complexity': ...}
- {'type': 'stage', 'stage': 'retrieval', 'message': ...}
- {'type': 'sql', 'sql': ..., 'explanation': ...}
- {'type': 'results', 'data': [...], 'row_count': ..., ...}
- {'type': 'summary_token', 'token': ...}
- {'type': 'message', 'message': ..., 'insights': [...], ...}
- {'type': 'done', 'total_time_ms': ...}
"""
from app.agents.query_understanding import query_understanding_node
from app.agents.schema_retrieval import schema_retrieval_node
from app.agents.sql_generation import sql_generation_node
from app.agents.sql_validation import sql_validation_node, route_validation
from app.agents.execution import execution_node
from app.agents.visualization import visualization_node
trace_id = str(uuid.uuid4())[:8]
start_time = time.perf_counter()
state: AgentState = {
"user_query": user_query,
"conversation_history": conversation_history or [],
"tenant_id": tenant_id,
"user_role": user_role,
"trace_id": trace_id,
"retry_count": 0,
"validation_errors": [],
}
logger.info("streaming_pipeline_started", trace_id=trace_id, query=user_query[:80])
# ── Semantic cache check ─────────────────────────
if self.semantic_cache:
cached = self.semantic_cache.get(user_query, tenant_id=tenant_id)
if cached:
cached["trace_id"] = trace_id
cached["cache_hit"] = True
logger.info("streaming_cache_hit", trace_id=trace_id)
yield {"type": "stage", "stage": "cache_hit", "message": "Retrieved from cache..."}
yield {"type": "intent", "intent": cached.get("intent", ""), "complexity": cached.get("complexity", "")}
sql = cached.get("sanitized_sql") or cached.get("generated_sql", "")
if sql:
yield {"type": "sql", "sql": sql, "explanation": cached.get("sql_explanation", "")}
yield {
"type": "results",
"data": cached.get("query_results", [])[:100],
"row_count": cached.get("row_count", 0),
"execution_time_ms": 0,
}
yield {
"type": "message",
"message": cached.get("friendly_message", ""),
"insights": cached.get("insights", []),
"follow_ups": cached.get("follow_up_questions", []),
}
elapsed = round((time.perf_counter() - start_time) * 1000, 2)
yield {"type": "done", "total_time_ms": elapsed, "cached": True}
return
try:
# ── Stage 1: Intent Classification (~5ms heuristic) ──
yield {"type": "stage", "stage": "classifying", "message": "Understanding your question..."}
intent_result = await asyncio.to_thread(
query_understanding_node, state, self.llm_router
)
state.update(intent_result)
intent = state.get("intent", "data_query")
route_intent = state.get("route_intent", intent)
yield {
"type": "intent",
"intent": intent,
"complexity": state.get("complexity", ""),
"route_intent": route_intent,
}
# ── Chat fast-path ───────────────────────────────
if intent in ("chat", "ambiguous"):
chat_result = self._handle_chat(state)
state.update(chat_result)
yield {
"type": "message",
"message": state.get("friendly_message", ""),
"insights": [],
"follow_ups": state.get("follow_up_questions", []),
}
elapsed = round((time.perf_counter() - start_time) * 1000, 2)
yield {"type": "done", "total_time_ms": elapsed, "chat_mode": True}
return
# ── Stage 2: Schema Retrieval (~100ms) ───────────
yield {"type": "stage", "stage": "retrieving", "message": "Retrieving schema context..."}
schema_result = await asyncio.to_thread(
schema_retrieval_node, state, self.rag_retriever, self.db_pool
)
state.update(schema_result)
# Handle meta_query
if route_intent == "meta_query":
meta_result = self._handle_meta(state)
state.update(meta_result)
yield {
"type": "message",
"message": state.get("friendly_message", ""),
"insights": [],
"follow_ups": state.get("follow_up_questions", []),
}
elapsed = round((time.perf_counter() - start_time) * 1000, 2)
yield {"type": "done", "total_time_ms": elapsed}
return
# ── Stage 3: SQL Generation (~2s LLM) ────────────
yield {"type": "stage", "stage": "Generating", "message": "Generating SQL..."}
max_retries = 3
for attempt in range(max_retries):
gen_result = await asyncio.to_thread(
sql_generation_node, state, self.llm_router
)
state.update(gen_result)
sql = state.get("generated_sql", "")
if not sql:
break
# ── Guardrail check ──────────────────────────
guardrail_result = self._run_guardrail(state)
state.update(guardrail_result)
# ── Validation ───────────────────────────────
yield {"type": "stage", "stage": "Validating", "message": "Validating SQL safety..."}
val_result = await asyncio.to_thread(sql_validation_node, state)
state.update(val_result)
route = route_validation(state)
if route == "valid":
break
elif route == "blocked":
blocked = self._handle_blocked(state)
state.update(blocked)
yield {
"type": "message",
"message": state.get("friendly_message", ""),
"insights": [],
"follow_ups": [],
}
elapsed = round((time.perf_counter() - start_time) * 1000, 2)
yield {"type": "done", "total_time_ms": elapsed, "blocked": True}
return
else: # retry
state["retry_count"] = state.get("retry_count", 0) + 1
yield {"type": "stage", "stage": "Generating", "message": f"Regenerating SQL (attempt {attempt + 2})..."}
# Stream SQL to frontend immediately
sql = state.get("sanitized_sql") or state.get("generated_sql", "")
if sql:
yield {
"type": "sql",
"sql": sql,
"explanation": state.get("sql_explanation", ""),
}
# ── Stage 4: Database Execution (~100ms) ─────────
yield {"type": "stage", "stage": "Executing", "message": "Executing query..."}
exec_result = await asyncio.to_thread(
execution_node, state, self.db_pool
)
state.update(exec_result)
# Stream data results IMMEDIATELY β€” don't wait for summary
yield {
"type": "results",
"data": state.get("query_results", [])[:100],
"row_count": state.get("row_count", 0),
"execution_time_ms": round((time.perf_counter() - start_time) * 1000, 2),
}
# ── Stage 5: Summary with Token Streaming (~2s, progressive) ──
yield {"type": "stage", "stage": "Preparing response", "message": "Generating insights..."}
results = state.get("query_results", [])
columns = state.get("column_names", [])
if results and columns and self.llm_router:
try:
from app.agents.result_summary import astream_summary
summary_text = ""
async for token in astream_summary(state, self.llm_router):
summary_text += token
yield {"type": "summary_token", "token": token}
state["friendly_message"] = summary_text
except Exception as e:
logger.warning("streaming_summary_failed", error=str(e), trace_id=trace_id)
# Fallback to deterministic summary
from app.agents.result_summary import result_summary_node
summary_result = result_summary_node(state, self.llm_router)
state.update(summary_result)
# ── Stage 6: Visualization (non-blocking) ────────
try:
viz_result = await asyncio.to_thread(visualization_node, state)
state.update(viz_result)
except Exception:
pass # Non-critical
# ── Final message event ──────────────────────────
yield {
"type": "message",
"message": state.get("friendly_message", ""),
"insights": state.get("insights", []),
"follow_ups": state.get("follow_up_questions", []),
}
elapsed = round((time.perf_counter() - start_time) * 1000, 2)
state["execution_time_ms"] = elapsed
# Cache successful results
if self.semantic_cache and not state.get("error") and state.get("query_results"):
self.semantic_cache.set(user_query, dict(state), tenant_id=tenant_id)
yield {"type": "done", "total_time_ms": elapsed}
logger.info(
"streaming_pipeline_completed",
trace_id=trace_id,
total_time_ms=elapsed,
intent=intent,
row_count=state.get("row_count", 0),
)
except Exception as e:
elapsed = round((time.perf_counter() - start_time) * 1000, 2)
logger.error("streaming_pipeline_failed", trace_id=trace_id, error=str(e), elapsed_ms=elapsed)
yield {"type": "error", "error": f"Pipeline error: {str(e)[:200]}"}
yield {"type": "done", "total_time_ms": elapsed, "error": True}