bi_agent / backend /agents /analysis.py
Prerna43's picture
agent
38830c1
Raw
History Blame Contribute Delete
4.72 kB
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