from __future__ import annotations from dataclasses import dataclass, field from typing import Any from backend.agents.planner import PlannerOutput from backend.agents.sql_agent import SQLAgentOutput from backend.services.llm_service import LLMService @dataclass class AnalysisOutput: summary: str insights: list[str] = field(default_factory=list) follow_ups: list[str] = field(default_factory=list) confidence: str = "medium" def to_dict(self) -> dict[str, Any]: return { "summary": self.summary, "insights": self.insights, "follow_ups": self.follow_ups, "confidence": self.confidence, } class AnalysisAgent: def __init__(self, llm: LLMService) -> None: self.llm = llm def run(self, question: str, plan: PlannerOutput, sql_output: SQLAgentOutput) -> AnalysisOutput: rows = sql_output.result.get("rows", []) columns = sql_output.result.get("columns", []) if sql_output.error: return AnalysisOutput( summary="The SQL was generated, but execution failed before a business answer could be produced.", insights=[sql_output.error], follow_ups=["Check the generated SQL and confirm the selected ClickHouse columns are valid."], confidence="low", ) if not rows: return AnalysisOutput( summary="The query ran successfully but returned no rows for the current question.", insights=["No matching data was returned from ClickHouse."], follow_ups=["Try widening the date range or asking with a more specific business metric."], confidence="medium", ) fallback = self._fallback_summary(question=question, plan=plan, sql_output=sql_output) payload = self.llm.invoke_json( system_prompt=( "You are a BI analysis agent. Return strict JSON with keys " "summary, insights, follow_ups, confidence. Keep claims grounded in the provided rows." ), user_prompt=( f"Question: {question}\n" f"Intent: {plan.intent}\n" f"SQL: {sql_output.sql}\n" f"Columns: {columns}\n" f"Sample rows: {rows[:10]}\n" f"Row count: {sql_output.result.get('row_count', 0)}\n" ), fallback=fallback, ) return AnalysisOutput( summary=payload.get("summary") or fallback["summary"], insights=self._normalize_list(payload.get("insights"), fallback["insights"]), follow_ups=self._normalize_list(payload.get("follow_ups"), fallback["follow_ups"]), confidence=str(payload.get("confidence") or fallback["confidence"]), ) def _fallback_summary( self, question: str, plan: PlannerOutput, sql_output: SQLAgentOutput, ) -> dict[str, Any]: rows = sql_output.result.get("rows", []) columns = sql_output.result.get("columns", []) first_row = rows[0] if rows else {} numeric_columns = [ column for column in columns if isinstance(first_row.get(column), (int, float)) ] preview = ", ".join(f"{key}={value}" for key, value in list(first_row.items())[:3]) or "no preview" summary = ( f"The database question was handled as {plan.intent.replace('_', ' ')}. " f"The query returned {len(rows)} rows with columns {', '.join(columns)}. " f"The first row looks like {preview}." ) insights = [ "The result is ready to render directly in the UI as a table.", f"Numeric columns detected for analysis: {', '.join(numeric_columns) if numeric_columns else 'none'}.", "A chart can be chosen from the same returned columns without another warehouse call.", ] follow_ups = [ "Ask for a narrower segment or date window if you want a more targeted explanation.", "If the SQL looks right but the answer feels off, inspect the selected tables and columns below it.", ] return { "summary": summary, "insights": insights, "follow_ups": follow_ups, "confidence": "high" if rows else "medium", } @staticmethod def _normalize_list(value: Any, fallback: list[str]) -> list[str]: if isinstance(value, list): return [str(item) for item in value if str(item).strip()] if isinstance(value, str) and value.strip(): return [value.strip()] return fallback