import os import json import re from typing import Annotated, List, Any, Optional, Dict from typing_extensions import TypedDict from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages from dotenv import load_dotenv from langgraph.prebuilt import ToolNode from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from pydantic import BaseModel, Field from analyst_tools import ( get_analyst_tools, get_session_sandbox_dir, normalize_message_text, build_notebook, build_html_report, extract_python_snippets, collect_charts, recover_orphaned_charts, ) import uuid import logging from datetime import datetime load_dotenv(override=True) logger = logging.getLogger(__name__) # ── State ────────────────────────────────────────────────────────────────────── class State(TypedDict): messages: Annotated[List[Any], add_messages] success_criteria: str dataset_filename: Optional[str] # filename inside sandbox/ session_dir: str feedback_on_work: Optional[str] success_criteria_met: bool user_input_needed: bool max_iterations: int iteration_count: int max_worker_turns: int worker_turn_count: int tool_calls_made: List[str] tool_outputs_observed: List[str] # ── Evaluator schema ─────────────────────────────────────────────────────────── class EvaluatorOutput(BaseModel): feedback: str = Field( description="Detailed feedback on the analyst's response" ) success_criteria_met: bool = Field( description="True only when the success criteria are fully met" ) user_input_needed: bool = Field( description=( "True if the analyst needs user clarification, is stuck, " "or the task cannot proceed without more information" ) ) insights_are_non_trivial: bool = Field( description=( "True if the insights go beyond trivial descriptive stats " "(e.g. include correlations, anomalies, trends, or recommendations)" ) ) # ── Agent ────────────────────────────────────────────────────────────────────── class DataAnalystAgent: def __init__(self, max_iterations: int = 3, max_worker_turns: int = 12): self.graph = None self.tools = None self._tool_node = None self.worker_llm_with_tools = None self.evaluator_llm = None self.agent_id = str(uuid.uuid4()) self.session_dir = str(get_session_sandbox_dir(self.agent_id)) self.max_iterations = max_iterations self.max_worker_turns = max_worker_turns self.memory = MemorySaver() def _build_llm(self, *, evaluator: bool = False) -> ChatOpenAI: using_openrouter = bool(os.getenv("OPENROUTER_API_KEY")) if using_openrouter: model = os.getenv( "OPENROUTER_EVALUATOR_MODEL" if evaluator else "OPENROUTER_MODEL", "anthropic/claude-3.7-sonnet", ) reasoning = {"exclude": True} reasoning_effort = os.getenv("OPENROUTER_REASONING_EFFORT") reasoning_max_tokens = os.getenv("OPENROUTER_REASONING_MAX_TOKENS") if reasoning_max_tokens: reasoning["max_tokens"] = int(reasoning_max_tokens) elif reasoning_effort: reasoning["effort"] = reasoning_effort elif not evaluator: reasoning["max_tokens"] = 2048 return ChatOpenAI( model=model, api_key=os.getenv("OPENROUTER_API_KEY"), base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), default_headers={ "HTTP-Referer": os.getenv("OPENROUTER_SITE_URL", "http://localhost:7860"), "X-Title": os.getenv("OPENROUTER_APP_NAME", "Data Analyst Agent"), }, reasoning=reasoning, ) model = os.getenv( "OPENAI_EVALUATOR_MODEL" if evaluator else "OPENAI_MODEL", "gpt-4o-mini", ) return ChatOpenAI(model=model) def setup(self): self.tools = get_analyst_tools(self.session_dir) worker_llm = self._build_llm(evaluator=False) self.worker_llm_with_tools = worker_llm.bind_tools(self.tools) self.evaluator_llm = self._build_llm(evaluator=True) self.build_graph() # ── Worker ───────────────────────────────────────────────────────────────── def _is_python_tool(self, tool_name: str) -> bool: return "python" in (tool_name or "").lower() def worker(self, state: State) -> Dict[str, Any]: next_worker_turn = state.get("worker_turn_count", 0) + 1 if next_worker_turn > state["max_worker_turns"]: return { "messages": [ AIMessage( content=( "I could not complete the analysis within the allowed tool-execution " "limit. Please refine the request or inspect the dataset manually." ) ) ], "worker_turn_count": next_worker_turn, } session_dir = state["session_dir"].replace("\\", "/") tool_calls_made = state.get("tool_calls_made", []) tool_outputs_observed = state.get("tool_outputs_observed", []) python_already_used = any(self._is_python_tool(name) for name in (tool_calls_made + tool_outputs_observed)) dataset_hint = "" if state.get("dataset_filename"): if python_already_used: dataset_hint = f""" A dataset has been uploaded by the user and you have already used Python to inspect it. It is available in the session sandbox directory as: {state['dataset_filename']} Its full path for Python code is: {session_dir}/{state['dataset_filename']} You have already gathered execution evidence from Python. Do not restart the analysis from scratch. Only call tools again if you need one missing detail for the final answer. Prefer producing the final response now, including: - non-trivial findings - anomalies or outliers - trends or patterns - actionable recommendations - chart filenames if any were saved """ else: dataset_hint = f""" A dataset has been uploaded by the user. It is available in the session sandbox directory as: {state['dataset_filename']} Its full path for Python code is: {session_dir}/{state['dataset_filename']} MANDATORY RULES when a dataset is present: 1. You MUST use the Python REPL tool to inspect and analyse the data before answering. 2. Always start with: import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv(r'{session_dir}/{state["dataset_filename"]}') numeric_df = df.select_dtypes(include='number') print(df.shape) print(df.dtypes) print(df.describe(include='all')) print(numeric_df.corr(numeric_only=True)) 3. Your analysis MUST cover: - Basic statistics (already done above) - Correlation analysis on numeric columns only - Outlier / anomaly detection (IQR method or z-score) - At least one trend or pattern observation - A concrete, actionable recommendation 4. For chart generation, use matplotlib and save to {session_dir}/chart_.png, then tell the user the filename. 5. Always print the outputs you rely on. Bare expressions are not enough in Python REPL. 6. Never call plt.show(). Save the figure and then call plt.close(). """ system_message = f"""You are an expert data analyst AI assistant. You have access to a Python REPL, file tools, optional web search, and Wikipedia. The current date and time is {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} The current session sandbox directory is: {session_dir} {dataset_hint} Your success criteria: {state["success_criteria"]} When you have finished your analysis, present: 1. A clear summary of key findings (bullet points) 2. Any anomalies or outliers found 3. Trends or patterns 4. Actionable recommendations 5. Names of any chart files saved If you need clarification, state: "Question: " """ if python_already_used: system_message += """ You have already used Python in this run. Unless a crucial fact is still missing, stop calling tools and deliver the final answer. Treat the recorded Python tool outputs as authoritative evidence; summarize them clearly in the final answer. """ if state.get("feedback_on_work"): system_message += f""" A previous attempt was rejected. Feedback: {state['feedback_on_work']} Address this feedback and improve your analysis. """ # Build a new message list with an updated system message # (avoid mutating shared state objects in-place). new_messages: list = [] sys_replaced = False for msg in state["messages"]: if isinstance(msg, SystemMessage) and not sys_replaced: new_messages.append(SystemMessage(content=system_message)) sys_replaced = True else: new_messages.append(msg) if not sys_replaced: new_messages.insert(0, SystemMessage(content=system_message)) try: response = self.worker_llm_with_tools.invoke(new_messages) except Exception as exc: logger.error("Worker LLM call failed: %s", exc, exc_info=True) return { "messages": [AIMessage(content=f"An error occurred during analysis: {exc}")], "worker_turn_count": next_worker_turn, } tool_calls = [ tool_call.get("name", "unknown_tool") for tool_call in getattr(response, "tool_calls", []) or [] ] return { "messages": [response], "worker_turn_count": next_worker_turn, "tool_calls_made": state.get("tool_calls_made", []) + tool_calls, } def worker_router(self, state: State) -> str: last = state["messages"][-1] if hasattr(last, "tool_calls") and last.tool_calls: return "tools" return "evaluator" # ── Evaluator ────────────────────────────────────────────────────────────── def _format_conversation(self, messages: List[Any]) -> str: out = "Conversation history:\n\n" for msg in messages: if isinstance(msg, HumanMessage): out += f"User: {self._message_text(msg.content)}\n" elif isinstance(msg, AIMessage): text = self._message_text(msg.content) or "[Tool use]" out += f"Analyst: {text}\n" elif isinstance(msg, ToolMessage): out += f"Tool result ({msg.name or 'tool'}): {self._message_text(msg.content)}\n" return out def _message_text(self, content: Any) -> str: return normalize_message_text(content) def _tool_evidence(self, state: State) -> str: tool_calls = state.get("tool_calls_made", []) tool_outputs = state.get("tool_outputs_observed", []) dataset_present = bool(state.get("dataset_filename")) python_called = any(self._is_python_tool(tool) for tool in tool_calls) python_output_seen = any(self._is_python_tool(tool) for tool in tool_outputs) return ( f"Dataset provided: {dataset_present}\n" f"Tool calls made: {tool_calls or ['none']}\n" f"Tool outputs observed: {tool_outputs or ['none']}\n" f"Python tool called: {python_called}\n" f"Python output observed: {python_output_seen}\n" ) def _parse_evaluator_output(self, content: Any) -> EvaluatorOutput: if isinstance(content, list): normalized_parts = [] for item in content: if isinstance(item, dict): normalized_parts.append(item.get("text", str(item))) else: normalized_parts.append(str(item)) content = "\n".join(normalized_parts) elif content is None: content = "" else: content = str(content) try: return EvaluatorOutput.model_validate_json(content) except Exception: pass # Try each '{' position to find a valid JSON object. for i, ch in enumerate(content): if ch == "{": try: obj = json.loads(content[i:]) return EvaluatorOutput.model_validate(obj) except (json.JSONDecodeError, Exception): continue fallback_feedback = content.strip() or "Evaluator response could not be parsed." lower_content = fallback_feedback.lower() return EvaluatorOutput( feedback=fallback_feedback, success_criteria_met="meets the success criteria" in lower_content, user_input_needed="user input" in lower_content or "clarification" in lower_content, insights_are_non_trivial="non-trivial" in lower_content or "correlation" in lower_content, ) def evaluator(self, state: State) -> Dict[str, Any]: last_response = self._message_text(state["messages"][-1].content) current_iteration = state.get("iteration_count", 0) + 1 system_message = ( "You are a senior data analyst evaluating whether a junior analyst's " "response to a data task meets the required standard. Be rigorous: " "reject responses that are only descriptive stats with no deeper insight." ) user_message = f"""Evaluate this data analysis conversation. {self._format_conversation(state["messages"])} Execution evidence: {self._tool_evidence(state)} Success criteria: {state["success_criteria"]} Final response from the analyst: {last_response} Evaluate: 1. Does it meet the success criteria? 2. Are insights non-trivial (correlations, anomalies, trends, recommendations)? 3. Does the analyst need user input or appear stuck? 4. Was Python actually used to derive the insights (not just described)? If a dataset was provided, the analyst MUST have used the Python tool. If charts were requested, at least one must have been saved. Give the analyst reasonable benefit of the doubt on file saves. """ if state.get("feedback_on_work"): user_message += ( f"\nPrior feedback given: {state['feedback_on_work']}\n" "If the analyst is repeating the same mistakes, mark user_input_needed=True." ) try: result_message = self.evaluator_llm.invoke([ SystemMessage(content=system_message), HumanMessage( content=( user_message + "\n\nRespond with a single JSON object only using this schema: " '{"feedback": string, "success_criteria_met": boolean, ' '"user_input_needed": boolean, "insights_are_non_trivial": boolean}' ) ), ]) result = self._parse_evaluator_output(result_message.content) except Exception as exc: logger.error("Evaluator LLM call failed: %s", exc, exc_info=True) result = EvaluatorOutput( feedback=f"Evaluator error: {exc}", success_criteria_met=False, user_input_needed=True, insights_are_non_trivial=False, ) python_called = any(self._is_python_tool(tool) for tool in state.get("tool_calls_made", [])) python_output_seen = any(self._is_python_tool(tool) for tool in state.get("tool_outputs_observed", [])) # Trust actual tool evidence over the evaluator's uncertainty about whether Python was really used. criteria_met = ( result.insights_are_non_trivial and (result.success_criteria_met or (python_called and python_output_seen and not result.user_input_needed)) ) hit_iteration_limit = current_iteration >= state["max_iterations"] and not criteria_met final_feedback = result.feedback user_input_needed = result.user_input_needed if hit_iteration_limit: final_feedback = ( f"{result.feedback}\n\nMaximum retry limit reached after " f"{current_iteration} evaluation attempts." ) user_input_needed = True return { "messages": [{ "role": "assistant", "content": ( f"**Evaluator feedback:** {final_feedback}\n\n" f"Insights non-trivial: {result.insights_are_non_trivial}" ), }], "feedback_on_work": final_feedback, "success_criteria_met": criteria_met, "user_input_needed": user_input_needed, "iteration_count": current_iteration, } def route_based_on_evaluation(self, state: State) -> str: if state["success_criteria_met"] or state["user_input_needed"]: return "END" return "worker" # ── Graph ────────────────────────────────────────────────────────────────── def tools_node(self, state: State) -> Dict[str, Any]: tool_node = self._tool_node result = tool_node.invoke(state) tool_outputs = state.get("tool_outputs_observed", []) + self._extract_tool_outputs(result["messages"]) return { "messages": result["messages"], "tool_outputs_observed": tool_outputs, } def build_graph(self): builder = StateGraph(State) builder.add_node("worker", self.worker) self._tool_node = ToolNode(tools=self.tools) builder.add_node("tools", self.tools_node) builder.add_node("evaluator", self.evaluator) builder.add_edge(START, "worker") builder.add_conditional_edges( "worker", self.worker_router, {"tools": "tools", "evaluator": "evaluator"}, ) builder.add_edge("tools", "worker") builder.add_conditional_edges( "evaluator", self.route_based_on_evaluation, {"worker": "worker", "END": END}, ) self.graph = builder.compile(checkpointer=self.memory) def _extract_tool_outputs(self, messages: List[Any]) -> List[str]: outputs: List[str] = [] for msg in messages: if isinstance(msg, ToolMessage): outputs.append(msg.name or "unknown_tool") return outputs # ── Public API ───────────────────────────────────────────────────────────── def run(self, message: str, success_criteria: str, dataset_filename: Optional[str], history: list) -> tuple: config = { "configurable": {"thread_id": self.agent_id}, "recursion_limit": max(50, self.max_worker_turns * 4), } state = { "messages": message, "success_criteria": success_criteria or "Provide clear, non-trivial insights from the data.", "dataset_filename": dataset_filename, "session_dir": self.session_dir, "feedback_on_work": None, "success_criteria_met": False, "user_input_needed": False, "max_iterations": self.max_iterations, "iteration_count": 0, "max_worker_turns": self.max_worker_turns, "worker_turn_count": 0, "tool_calls_made": [], "tool_outputs_observed": [], } result = self.graph.invoke(state, config=config) msgs = result["messages"] user_msg = {"role": "user", "content": message} # Guard against short message lists. if len(msgs) >= 2: analyst_reply = {"role": "assistant", "content": self._message_text(msgs[-2].content)} eval_feedback_text = self._message_text(msgs[-1].content) elif msgs: analyst_reply = {"role": "assistant", "content": self._message_text(msgs[-1].content)} eval_feedback_text = "" else: analyst_reply = {"role": "assistant", "content": "No response was generated."} eval_feedback_text = "" # Recover any chart PNGs that the agent saved to the CWD instead # of the session sandbox (PythonREPLTool runs in the process CWD). recover_orphaned_charts(self.session_dir) # Build notebook + HTML report for in-browser preview. analyst_text = analyst_reply["content"] code_snippets = extract_python_snippets(msgs) nb_path = build_notebook( self.session_dir, analyst_text, code_snippets, dataset_filename, ) html_report = build_html_report( self.session_dir, analyst_text, dataset_filename, ) return ( history + [user_msg, analyst_reply], eval_feedback_text, result, nb_path, html_report, ) def reset(self): """Return a fresh agent instance.""" new_agent = DataAnalystAgent( max_iterations=self.max_iterations, max_worker_turns=self.max_worker_turns, ) new_agent.setup() return new_agent