Spaces:
Runtime error
Runtime error
feat: synchronize text-to-sql-bot codebase with Hugging Face Space repository, including Docker build configurations
6086e71 | """ | |
| 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() | |
| 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 | |
| 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 ββββββββββββββββββββββββββββββββ | |
| 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" | |
| 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} | |