JamesDominiqueAI commited on
Commit
9379c69
·
0 Parent(s):

Initial Hugging Face Space deployment

Browse files
Files changed (7) hide show
  1. .gitignore +3 -0
  2. README.md +39 -0
  3. analyst.py +477 -0
  4. analyst_tools.py +337 -0
  5. app.py +412 -0
  6. requirements.txt +15 -0
  7. sample_sales_data.csv +73 -0
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ sandbox/
2
+ __pycache__/
3
+ *.pyc
README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Data Analyst Agent
3
+ emoji: "📊"
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ app_file: app.py
8
+ pinned: false
9
+ ---
10
+
11
+ # Data Analyst Agent
12
+
13
+ An autonomous data analyst built with LangGraph, OpenAI, and Gradio.
14
+ Upload a CSV, ask a question, and the agent writes Python, analyzes the data,
15
+ generates charts, and returns practical insights.
16
+
17
+ ## Features
18
+
19
+ - CSV upload and sandboxed analysis workflow
20
+ - Correlation analysis, anomaly detection, and trend exploration
21
+ - Chart generation with downloadable notebook output
22
+ - Gradio interface for interactive analysis
23
+
24
+ ## Required Secret
25
+
26
+ - `OPENAI_API_KEY`
27
+
28
+ ## Optional Secrets
29
+
30
+ - `SERPER_API_KEY`
31
+ - `OPENROUTER_API_KEY`
32
+
33
+ ## Local Files Included
34
+
35
+ - `app.py`
36
+ - `analyst.py`
37
+ - `analyst_tools.py`
38
+ - `requirements.txt`
39
+ - `sample_sales_data.csv`
analyst.py ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import re
4
+ from typing import Annotated, List, Any, Optional, Dict
5
+ from typing_extensions import TypedDict
6
+ from langgraph.graph import StateGraph, START, END
7
+ from langgraph.graph.message import add_messages
8
+ from dotenv import load_dotenv
9
+ from langgraph.prebuilt import ToolNode
10
+ from langchain_openai import ChatOpenAI
11
+ from langgraph.checkpoint.memory import MemorySaver
12
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
13
+ from pydantic import BaseModel, Field
14
+ from analyst_tools import (
15
+ get_analyst_tools,
16
+ get_session_sandbox_dir,
17
+ normalize_message_text,
18
+ build_notebook,
19
+ build_html_report,
20
+ extract_python_snippets,
21
+ collect_charts,
22
+ recover_orphaned_charts,
23
+ )
24
+ import uuid
25
+ import logging
26
+ from datetime import datetime
27
+ load_dotenv(override=True)
28
+ logger = logging.getLogger(__name__)
29
+ # ── State ──────────────────────────────────────────────────────────────────────
30
+ class State(TypedDict):
31
+ messages: Annotated[List[Any], add_messages]
32
+ success_criteria: str
33
+ dataset_filename: Optional[str] # filename inside sandbox/
34
+ session_dir: str
35
+ feedback_on_work: Optional[str]
36
+ success_criteria_met: bool
37
+ user_input_needed: bool
38
+ max_iterations: int
39
+ iteration_count: int
40
+ max_worker_turns: int
41
+ worker_turn_count: int
42
+ tool_calls_made: List[str]
43
+ tool_outputs_observed: List[str]
44
+ # ── Evaluator schema ───────────────────────────────────────────────────────────
45
+ class EvaluatorOutput(BaseModel):
46
+ feedback: str = Field(
47
+ description="Detailed feedback on the analyst's response"
48
+ )
49
+ success_criteria_met: bool = Field(
50
+ description="True only when the success criteria are fully met"
51
+ )
52
+ user_input_needed: bool = Field(
53
+ description=(
54
+ "True if the analyst needs user clarification, is stuck, "
55
+ "or the task cannot proceed without more information"
56
+ )
57
+ )
58
+ insights_are_non_trivial: bool = Field(
59
+ description=(
60
+ "True if the insights go beyond trivial descriptive stats "
61
+ "(e.g. include correlations, anomalies, trends, or recommendations)"
62
+ )
63
+ )
64
+ # ── Agent ──────────────────────────────────────────────────────────────────────
65
+ class DataAnalystAgent:
66
+ def __init__(self, max_iterations: int = 3, max_worker_turns: int = 12):
67
+ self.graph = None
68
+ self.tools = None
69
+ self._tool_node = None
70
+ self.worker_llm_with_tools = None
71
+ self.evaluator_llm = None
72
+ self.agent_id = str(uuid.uuid4())
73
+ self.session_dir = str(get_session_sandbox_dir(self.agent_id))
74
+ self.max_iterations = max_iterations
75
+ self.max_worker_turns = max_worker_turns
76
+ self.memory = MemorySaver()
77
+ def _build_llm(self, *, evaluator: bool = False) -> ChatOpenAI:
78
+ using_openrouter = bool(os.getenv("OPENROUTER_API_KEY"))
79
+ if using_openrouter:
80
+ model = os.getenv(
81
+ "OPENROUTER_EVALUATOR_MODEL" if evaluator else "OPENROUTER_MODEL",
82
+ "anthropic/claude-3.7-sonnet",
83
+ )
84
+ reasoning = {"exclude": True}
85
+ reasoning_effort = os.getenv("OPENROUTER_REASONING_EFFORT")
86
+ reasoning_max_tokens = os.getenv("OPENROUTER_REASONING_MAX_TOKENS")
87
+ if reasoning_max_tokens:
88
+ reasoning["max_tokens"] = int(reasoning_max_tokens)
89
+ elif reasoning_effort:
90
+ reasoning["effort"] = reasoning_effort
91
+ elif not evaluator:
92
+ reasoning["max_tokens"] = 2048
93
+ return ChatOpenAI(
94
+ model=model,
95
+ api_key=os.getenv("OPENROUTER_API_KEY"),
96
+ base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
97
+ default_headers={
98
+ "HTTP-Referer": os.getenv("OPENROUTER_SITE_URL", "http://localhost:7860"),
99
+ "X-Title": os.getenv("OPENROUTER_APP_NAME", "Data Analyst Agent"),
100
+ },
101
+ reasoning=reasoning,
102
+ )
103
+ model = os.getenv(
104
+ "OPENAI_EVALUATOR_MODEL" if evaluator else "OPENAI_MODEL",
105
+ "gpt-4o-mini",
106
+ )
107
+ return ChatOpenAI(model=model)
108
+ def setup(self):
109
+ self.tools = get_analyst_tools(self.session_dir)
110
+ worker_llm = self._build_llm(evaluator=False)
111
+ self.worker_llm_with_tools = worker_llm.bind_tools(self.tools)
112
+ self.evaluator_llm = self._build_llm(evaluator=True)
113
+ self.build_graph()
114
+ # ── Worker ───────��─────────────────────────────────────────────────────────
115
+ def _is_python_tool(self, tool_name: str) -> bool:
116
+ return "python" in (tool_name or "").lower()
117
+ def worker(self, state: State) -> Dict[str, Any]:
118
+ next_worker_turn = state.get("worker_turn_count", 0) + 1
119
+ if next_worker_turn > state["max_worker_turns"]:
120
+ return {
121
+ "messages": [
122
+ AIMessage(
123
+ content=(
124
+ "I could not complete the analysis within the allowed tool-execution "
125
+ "limit. Please refine the request or inspect the dataset manually."
126
+ )
127
+ )
128
+ ],
129
+ "worker_turn_count": next_worker_turn,
130
+ }
131
+ session_dir = state["session_dir"].replace("\\", "/")
132
+ tool_calls_made = state.get("tool_calls_made", [])
133
+ tool_outputs_observed = state.get("tool_outputs_observed", [])
134
+ python_already_used = any(self._is_python_tool(name) for name in (tool_calls_made + tool_outputs_observed))
135
+ dataset_hint = ""
136
+ if state.get("dataset_filename"):
137
+ if python_already_used:
138
+ dataset_hint = f"""
139
+ A dataset has been uploaded by the user and you have already used Python to inspect it.
140
+ It is available in the session sandbox directory as: {state['dataset_filename']}
141
+ Its full path for Python code is: {session_dir}/{state['dataset_filename']}
142
+ You have already gathered execution evidence from Python.
143
+ Do not restart the analysis from scratch.
144
+ Only call tools again if you need one missing detail for the final answer.
145
+ Prefer producing the final response now, including:
146
+ - non-trivial findings
147
+ - anomalies or outliers
148
+ - trends or patterns
149
+ - actionable recommendations
150
+ - chart filenames if any were saved
151
+ """
152
+ else:
153
+ dataset_hint = f"""
154
+ A dataset has been uploaded by the user.
155
+ It is available in the session sandbox directory as: {state['dataset_filename']}
156
+ Its full path for Python code is: {session_dir}/{state['dataset_filename']}
157
+ MANDATORY RULES when a dataset is present:
158
+ 1. You MUST use the Python REPL tool to inspect and analyse the data before answering.
159
+ 2. Always start with:
160
+ import pandas as pd
161
+ import matplotlib.pyplot as plt
162
+ df = pd.read_csv(r'{session_dir}/{state["dataset_filename"]}')
163
+ numeric_df = df.select_dtypes(include='number')
164
+ print(df.shape)
165
+ print(df.dtypes)
166
+ print(df.describe(include='all'))
167
+ print(numeric_df.corr(numeric_only=True))
168
+ 3. Your analysis MUST cover:
169
+ - Basic statistics (already done above)
170
+ - Correlation analysis on numeric columns only
171
+ - Outlier / anomaly detection (IQR method or z-score)
172
+ - At least one trend or pattern observation
173
+ - A concrete, actionable recommendation
174
+ 4. For chart generation, use matplotlib and save to {session_dir}/chart_<name>.png,
175
+ then tell the user the filename.
176
+ 5. Always print the outputs you rely on. Bare expressions are not enough in Python REPL.
177
+ 6. Never call plt.show(). Save the figure and then call plt.close().
178
+ """
179
+ system_message = f"""You are an expert data analyst AI assistant.
180
+ You have access to a Python REPL, file tools, optional web search, and Wikipedia.
181
+ The current date and time is {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
182
+ The current session sandbox directory is: {session_dir}
183
+ {dataset_hint}
184
+ Your success criteria:
185
+ {state["success_criteria"]}
186
+ When you have finished your analysis, present:
187
+ 1. A clear summary of key findings (bullet points)
188
+ 2. Any anomalies or outliers found
189
+ 3. Trends or patterns
190
+ 4. Actionable recommendations
191
+ 5. Names of any chart files saved
192
+ If you need clarification, state: "Question: <your question>"
193
+ """
194
+ if python_already_used:
195
+ system_message += """
196
+ You have already used Python in this run.
197
+ Unless a crucial fact is still missing, stop calling tools and deliver the final answer.
198
+ Treat the recorded Python tool outputs as authoritative evidence; summarize them clearly in the final answer.
199
+ """
200
+ if state.get("feedback_on_work"):
201
+ system_message += f"""
202
+ A previous attempt was rejected. Feedback:
203
+ {state['feedback_on_work']}
204
+ Address this feedback and improve your analysis.
205
+ """
206
+ # Build a new message list with an updated system message
207
+ # (avoid mutating shared state objects in-place).
208
+ new_messages: list = []
209
+ sys_replaced = False
210
+ for msg in state["messages"]:
211
+ if isinstance(msg, SystemMessage) and not sys_replaced:
212
+ new_messages.append(SystemMessage(content=system_message))
213
+ sys_replaced = True
214
+ else:
215
+ new_messages.append(msg)
216
+ if not sys_replaced:
217
+ new_messages.insert(0, SystemMessage(content=system_message))
218
+ try:
219
+ response = self.worker_llm_with_tools.invoke(new_messages)
220
+ except Exception as exc:
221
+ logger.error("Worker LLM call failed: %s", exc, exc_info=True)
222
+ return {
223
+ "messages": [AIMessage(content=f"An error occurred during analysis: {exc}")],
224
+ "worker_turn_count": next_worker_turn,
225
+ }
226
+ tool_calls = [
227
+ tool_call.get("name", "unknown_tool")
228
+ for tool_call in getattr(response, "tool_calls", []) or []
229
+ ]
230
+ return {
231
+ "messages": [response],
232
+ "worker_turn_count": next_worker_turn,
233
+ "tool_calls_made": state.get("tool_calls_made", []) + tool_calls,
234
+ }
235
+ def worker_router(self, state: State) -> str:
236
+ last = state["messages"][-1]
237
+ if hasattr(last, "tool_calls") and last.tool_calls:
238
+ return "tools"
239
+ return "evaluator"
240
+ # ── Evaluator ──────────────────────────────────────────────────────────────
241
+ def _format_conversation(self, messages: List[Any]) -> str:
242
+ out = "Conversation history:\n\n"
243
+ for msg in messages:
244
+ if isinstance(msg, HumanMessage):
245
+ out += f"User: {self._message_text(msg.content)}\n"
246
+ elif isinstance(msg, AIMessage):
247
+ text = self._message_text(msg.content) or "[Tool use]"
248
+ out += f"Analyst: {text}\n"
249
+ elif isinstance(msg, ToolMessage):
250
+ out += f"Tool result ({msg.name or 'tool'}): {self._message_text(msg.content)}\n"
251
+ return out
252
+ def _message_text(self, content: Any) -> str:
253
+ return normalize_message_text(content)
254
+ def _tool_evidence(self, state: State) -> str:
255
+ tool_calls = state.get("tool_calls_made", [])
256
+ tool_outputs = state.get("tool_outputs_observed", [])
257
+ dataset_present = bool(state.get("dataset_filename"))
258
+ python_called = any(self._is_python_tool(tool) for tool in tool_calls)
259
+ python_output_seen = any(self._is_python_tool(tool) for tool in tool_outputs)
260
+ return (
261
+ f"Dataset provided: {dataset_present}\n"
262
+ f"Tool calls made: {tool_calls or ['none']}\n"
263
+ f"Tool outputs observed: {tool_outputs or ['none']}\n"
264
+ f"Python tool called: {python_called}\n"
265
+ f"Python output observed: {python_output_seen}\n"
266
+ )
267
+ def _parse_evaluator_output(self, content: Any) -> EvaluatorOutput:
268
+ if isinstance(content, list):
269
+ normalized_parts = []
270
+ for item in content:
271
+ if isinstance(item, dict):
272
+ normalized_parts.append(item.get("text", str(item)))
273
+ else:
274
+ normalized_parts.append(str(item))
275
+ content = "\n".join(normalized_parts)
276
+ elif content is None:
277
+ content = ""
278
+ else:
279
+ content = str(content)
280
+ try:
281
+ return EvaluatorOutput.model_validate_json(content)
282
+ except Exception:
283
+ pass
284
+ # Try each '{' position to find a valid JSON object.
285
+ for i, ch in enumerate(content):
286
+ if ch == "{":
287
+ try:
288
+ obj = json.loads(content[i:])
289
+ return EvaluatorOutput.model_validate(obj)
290
+ except (json.JSONDecodeError, Exception):
291
+ continue
292
+ fallback_feedback = content.strip() or "Evaluator response could not be parsed."
293
+ lower_content = fallback_feedback.lower()
294
+ return EvaluatorOutput(
295
+ feedback=fallback_feedback,
296
+ success_criteria_met="meets the success criteria" in lower_content,
297
+ user_input_needed="user input" in lower_content or "clarification" in lower_content,
298
+ insights_are_non_trivial="non-trivial" in lower_content or "correlation" in lower_content,
299
+ )
300
+ def evaluator(self, state: State) -> Dict[str, Any]:
301
+ last_response = self._message_text(state["messages"][-1].content)
302
+ current_iteration = state.get("iteration_count", 0) + 1
303
+ system_message = (
304
+ "You are a senior data analyst evaluating whether a junior analyst's "
305
+ "response to a data task meets the required standard. Be rigorous: "
306
+ "reject responses that are only descriptive stats with no deeper insight."
307
+ )
308
+ user_message = f"""Evaluate this data analysis conversation.
309
+ {self._format_conversation(state["messages"])}
310
+ Execution evidence:
311
+ {self._tool_evidence(state)}
312
+ Success criteria: {state["success_criteria"]}
313
+ Final response from the analyst:
314
+ {last_response}
315
+ Evaluate:
316
+ 1. Does it meet the success criteria?
317
+ 2. Are insights non-trivial (correlations, anomalies, trends, recommendations)?
318
+ 3. Does the analyst need user input or appear stuck?
319
+ 4. Was Python actually used to derive the insights (not just described)?
320
+ If a dataset was provided, the analyst MUST have used the Python tool.
321
+ If charts were requested, at least one must have been saved.
322
+ Give the analyst reasonable benefit of the doubt on file saves.
323
+ """
324
+ if state.get("feedback_on_work"):
325
+ user_message += (
326
+ f"\nPrior feedback given: {state['feedback_on_work']}\n"
327
+ "If the analyst is repeating the same mistakes, mark user_input_needed=True."
328
+ )
329
+ try:
330
+ result_message = self.evaluator_llm.invoke([
331
+ SystemMessage(content=system_message),
332
+ HumanMessage(
333
+ content=(
334
+ user_message
335
+ + "\n\nRespond with a single JSON object only using this schema: "
336
+ '{"feedback": string, "success_criteria_met": boolean, '
337
+ '"user_input_needed": boolean, "insights_are_non_trivial": boolean}'
338
+ )
339
+ ),
340
+ ])
341
+ result = self._parse_evaluator_output(result_message.content)
342
+ except Exception as exc:
343
+ logger.error("Evaluator LLM call failed: %s", exc, exc_info=True)
344
+ result = EvaluatorOutput(
345
+ feedback=f"Evaluator error: {exc}",
346
+ success_criteria_met=False,
347
+ user_input_needed=True,
348
+ insights_are_non_trivial=False,
349
+ )
350
+ python_called = any(self._is_python_tool(tool) for tool in state.get("tool_calls_made", []))
351
+ python_output_seen = any(self._is_python_tool(tool) for tool in state.get("tool_outputs_observed", []))
352
+ # Trust actual tool evidence over the evaluator's uncertainty about whether Python was really used.
353
+ criteria_met = (
354
+ result.insights_are_non_trivial
355
+ and (result.success_criteria_met or (python_called and python_output_seen and not result.user_input_needed))
356
+ )
357
+ hit_iteration_limit = current_iteration >= state["max_iterations"] and not criteria_met
358
+ final_feedback = result.feedback
359
+ user_input_needed = result.user_input_needed
360
+ if hit_iteration_limit:
361
+ final_feedback = (
362
+ f"{result.feedback}\n\nMaximum retry limit reached after "
363
+ f"{current_iteration} evaluation attempts."
364
+ )
365
+ user_input_needed = True
366
+ return {
367
+ "messages": [{
368
+ "role": "assistant",
369
+ "content": (
370
+ f"**Evaluator feedback:** {final_feedback}\n\n"
371
+ f"Insights non-trivial: {result.insights_are_non_trivial}"
372
+ ),
373
+ }],
374
+ "feedback_on_work": final_feedback,
375
+ "success_criteria_met": criteria_met,
376
+ "user_input_needed": user_input_needed,
377
+ "iteration_count": current_iteration,
378
+ }
379
+ def route_based_on_evaluation(self, state: State) -> str:
380
+ if state["success_criteria_met"] or state["user_input_needed"]:
381
+ return "END"
382
+ return "worker"
383
+ # ── Graph ──────────────────────────────────────────────────────────────────
384
+ def tools_node(self, state: State) -> Dict[str, Any]:
385
+ tool_node = self._tool_node
386
+ result = tool_node.invoke(state)
387
+ tool_outputs = state.get("tool_outputs_observed", []) + self._extract_tool_outputs(result["messages"])
388
+ return {
389
+ "messages": result["messages"],
390
+ "tool_outputs_observed": tool_outputs,
391
+ }
392
+ def build_graph(self):
393
+ builder = StateGraph(State)
394
+ builder.add_node("worker", self.worker)
395
+ self._tool_node = ToolNode(tools=self.tools)
396
+ builder.add_node("tools", self.tools_node)
397
+ builder.add_node("evaluator", self.evaluator)
398
+ builder.add_edge(START, "worker")
399
+ builder.add_conditional_edges(
400
+ "worker",
401
+ self.worker_router,
402
+ {"tools": "tools", "evaluator": "evaluator"},
403
+ )
404
+ builder.add_edge("tools", "worker")
405
+ builder.add_conditional_edges(
406
+ "evaluator",
407
+ self.route_based_on_evaluation,
408
+ {"worker": "worker", "END": END},
409
+ )
410
+ self.graph = builder.compile(checkpointer=self.memory)
411
+ def _extract_tool_outputs(self, messages: List[Any]) -> List[str]:
412
+ outputs: List[str] = []
413
+ for msg in messages:
414
+ if isinstance(msg, ToolMessage):
415
+ outputs.append(msg.name or "unknown_tool")
416
+ return outputs
417
+ # ── Public API ─────────────────────────────────────────────────────────────
418
+ def run(self, message: str, success_criteria: str, dataset_filename: Optional[str], history: list) -> tuple:
419
+ config = {
420
+ "configurable": {"thread_id": self.agent_id},
421
+ "recursion_limit": max(50, self.max_worker_turns * 4),
422
+ }
423
+ state = {
424
+ "messages": message,
425
+ "success_criteria": success_criteria or "Provide clear, non-trivial insights from the data.",
426
+ "dataset_filename": dataset_filename,
427
+ "session_dir": self.session_dir,
428
+ "feedback_on_work": None,
429
+ "success_criteria_met": False,
430
+ "user_input_needed": False,
431
+ "max_iterations": self.max_iterations,
432
+ "iteration_count": 0,
433
+ "max_worker_turns": self.max_worker_turns,
434
+ "worker_turn_count": 0,
435
+ "tool_calls_made": [],
436
+ "tool_outputs_observed": [],
437
+ }
438
+ result = self.graph.invoke(state, config=config)
439
+ msgs = result["messages"]
440
+ user_msg = {"role": "user", "content": message}
441
+ # Guard against short message lists.
442
+ if len(msgs) >= 2:
443
+ analyst_reply = {"role": "assistant", "content": self._message_text(msgs[-2].content)}
444
+ eval_feedback_text = self._message_text(msgs[-1].content)
445
+ elif msgs:
446
+ analyst_reply = {"role": "assistant", "content": self._message_text(msgs[-1].content)}
447
+ eval_feedback_text = ""
448
+ else:
449
+ analyst_reply = {"role": "assistant", "content": "No response was generated."}
450
+ eval_feedback_text = ""
451
+ # Recover any chart PNGs that the agent saved to the CWD instead
452
+ # of the session sandbox (PythonREPLTool runs in the process CWD).
453
+ recover_orphaned_charts(self.session_dir)
454
+ # Build notebook + HTML report for in-browser preview.
455
+ analyst_text = analyst_reply["content"]
456
+ code_snippets = extract_python_snippets(msgs)
457
+ nb_path = build_notebook(
458
+ self.session_dir, analyst_text, code_snippets, dataset_filename,
459
+ )
460
+ html_report = build_html_report(
461
+ self.session_dir, analyst_text, dataset_filename,
462
+ )
463
+ return (
464
+ history + [user_msg, analyst_reply],
465
+ eval_feedback_text,
466
+ result,
467
+ nb_path,
468
+ html_report,
469
+ )
470
+ def reset(self):
471
+ """Return a fresh agent instance."""
472
+ new_agent = DataAnalystAgent(
473
+ max_iterations=self.max_iterations,
474
+ max_worker_turns=self.max_worker_turns,
475
+ )
476
+ new_agent.setup()
477
+ return new_agent
analyst_tools.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import glob
5
+ import base64
6
+ import shutil
7
+ import logging
8
+ from pathlib import Path
9
+ from dotenv import load_dotenv
10
+ from langchain.agents import Tool
11
+ from langchain_community.agent_toolkits import FileManagementToolkit
12
+ from langchain_community.tools.wikipedia.tool import WikipediaQueryRun
13
+ from langchain_experimental.tools import PythonREPLTool
14
+ from langchain_community.utilities import GoogleSerperAPIWrapper
15
+ from langchain_community.utilities.wikipedia import WikipediaAPIWrapper
16
+ load_dotenv(override=True)
17
+ os.environ.setdefault("MPLBACKEND", "Agg")
18
+ logger = logging.getLogger(__name__)
19
+ try:
20
+ import matplotlib
21
+ matplotlib.use("Agg")
22
+ import matplotlib.pyplot as plt
23
+ plt.show = lambda *args, **kwargs: None
24
+ except Exception:
25
+ matplotlib = None
26
+ # Use an absolute path anchored to this file's directory so the sandbox
27
+ # location is predictable regardless of the working directory at launch.
28
+ _SCRIPT_DIR = Path(__file__).resolve().parent
29
+ SANDBOX_DIR = _SCRIPT_DIR / "sandbox"
30
+ SANDBOX_DIR.mkdir(exist_ok=True)
31
+ # -- Shared text helpers -------------------------------------------------------
32
+ def normalize_message_text(content) -> str:
33
+ """Convert LangChain message content (str | list[dict] | None) to a plain string."""
34
+ if isinstance(content, list):
35
+ parts = []
36
+ for item in content:
37
+ if isinstance(item, dict):
38
+ parts.append(item.get("text", str(item)))
39
+ else:
40
+ parts.append(str(item))
41
+ return "\n".join(parts)
42
+ return "" if content is None else str(content)
43
+ # -- Sandbox helpers ------------------------------------------------------------
44
+ def get_session_sandbox_dir(session_id: str) -> Path:
45
+ session_dir = SANDBOX_DIR / session_id
46
+ session_dir.mkdir(parents=True, exist_ok=True)
47
+ return session_dir
48
+ def cleanup_session_sandbox(session_dir: str | Path) -> None:
49
+ shutil.rmtree(session_dir, ignore_errors=True)
50
+ def get_file_tools(session_dir: str | Path):
51
+ toolkit = FileManagementToolkit(root_dir=str(session_dir))
52
+ return toolkit.get_tools()
53
+ def copy_uploaded_file(src_path: str, filename: str, session_dir: str | Path) -> str:
54
+ """Copy an uploaded Gradio file into the session sandbox so the agent can access it."""
55
+ dest = Path(session_dir) / filename
56
+ shutil.copy(src_path, dest)
57
+ return str(dest)
58
+ def collect_charts(session_dir: str) -> list[str]:
59
+ """Return paths of any .png files saved in the current session sandbox."""
60
+ return sorted(glob.glob(os.path.join(str(session_dir), "*.png")))
61
+ def recover_orphaned_charts(session_dir: str | Path) -> None:
62
+ """
63
+ The PythonREPLTool executes code in the process CWD, which is usually the
64
+ project root — not the session sandbox. Charts saved with a bare filename
65
+ (e.g. ``plt.savefig('chart.png')``) end up in the CWD.
66
+ This helper moves any .png files found in the CWD (and the sandbox root)
67
+ into the session directory so that ``collect_charts`` picks them up.
68
+ """
69
+ session_path = Path(session_dir)
70
+ search_dirs = {Path.cwd(), SANDBOX_DIR}
71
+ # Also check _SCRIPT_DIR in case the user launched from there
72
+ search_dirs.add(_SCRIPT_DIR)
73
+ for search_dir in search_dirs:
74
+ if not search_dir.is_dir() or search_dir == session_path:
75
+ continue
76
+ for png in search_dir.glob("*.png"):
77
+ dest = session_path / png.name
78
+ if not dest.exists():
79
+ try:
80
+ shutil.move(str(png), str(dest))
81
+ logger.info("Recovered orphaned chart %s → %s", png, dest)
82
+ except Exception:
83
+ logger.warning("Could not move %s", png, exc_info=True)
84
+ # -- Notebook generation -------------------------------------------------------
85
+ def _make_nb_cell(cell_type: str, source: str, outputs=None) -> dict:
86
+ """Build a single Jupyter notebook cell dict."""
87
+ cell = {
88
+ "cell_type": cell_type,
89
+ "metadata": {},
90
+ "source": source.splitlines(keepends=True) if source else [],
91
+ }
92
+ if cell_type == "code":
93
+ cell["execution_count"] = None
94
+ cell["outputs"] = outputs or []
95
+ return cell
96
+ def _png_to_nb_output(png_path: str) -> dict:
97
+ """Create a notebook display_data output that embeds a PNG image."""
98
+ with open(png_path, "rb") as f:
99
+ b64 = base64.b64encode(f.read()).decode("ascii")
100
+ return {
101
+ "output_type": "display_data",
102
+ "metadata": {},
103
+ "data": {
104
+ "image/png": b64,
105
+ "text/plain": [f"<Figure: {Path(png_path).name}>"],
106
+ },
107
+ }
108
+ def build_notebook(
109
+ session_dir: str,
110
+ analyst_summary: str,
111
+ code_snippets: list[str] | None = None,
112
+ dataset_filename: str | None = None,
113
+ ) -> str:
114
+ """
115
+ Build an .ipynb file that bundles:
116
+ - a markdown cell with the analyst's summary / findings
117
+ - code cells for each Python snippet the agent ran
118
+ - inline chart images (embedded as base64 display_data outputs)
119
+ Returns the absolute path to the saved notebook.
120
+ """
121
+ cells: list[dict] = []
122
+ cells.append(_make_nb_cell(
123
+ "markdown",
124
+ "# Data Analysis Report\n\n*Auto-generated by Data Analyst Agent*",
125
+ ))
126
+ if dataset_filename:
127
+ cells.append(_make_nb_cell("markdown", f"**Dataset:** `{dataset_filename}`"))
128
+ if code_snippets:
129
+ cells.append(_make_nb_cell("markdown", "## Analysis Code"))
130
+ for snippet in code_snippets:
131
+ cells.append(_make_nb_cell("code", snippet))
132
+ chart_paths = collect_charts(session_dir)
133
+ if chart_paths:
134
+ cells.append(_make_nb_cell("markdown", "## Charts"))
135
+ for chart_path in chart_paths:
136
+ try:
137
+ output = _png_to_nb_output(chart_path)
138
+ chart_name = Path(chart_path).stem
139
+ cells.append(_make_nb_cell(
140
+ "code",
141
+ (
142
+ f"# Chart: {chart_name}\n"
143
+ f"from IPython.display import Image, display\n"
144
+ f"display(Image(filename=r'{chart_path}'))"
145
+ ),
146
+ outputs=[output],
147
+ ))
148
+ except Exception:
149
+ logger.warning("Could not embed chart %s", chart_path, exc_info=True)
150
+ cells.append(_make_nb_cell("markdown", f"## Findings\n\n{analyst_summary}"))
151
+ notebook = {
152
+ "nbformat": 4,
153
+ "nbformat_minor": 5,
154
+ "metadata": {
155
+ "kernelspec": {
156
+ "display_name": "Python 3",
157
+ "language": "python",
158
+ "name": "python3",
159
+ },
160
+ "language_info": {"name": "python", "version": "3.11.0"},
161
+ },
162
+ "cells": cells,
163
+ }
164
+ nb_path = os.path.join(session_dir, "analysis_report.ipynb")
165
+ with open(nb_path, "w", encoding="utf-8") as f:
166
+ json.dump(notebook, f, indent=1, ensure_ascii=False)
167
+ logger.info("Notebook saved to %s", nb_path)
168
+ return nb_path
169
+ # -- HTML report for in-browser preview ----------------------------------------
170
+ def build_html_report(
171
+ session_dir: str,
172
+ analyst_summary: str,
173
+ dataset_filename: str | None = None,
174
+ ) -> str:
175
+ """
176
+ Build a self-contained HTML report with:
177
+ - the analyst's markdown findings rendered as HTML
178
+ - charts embedded as base64 <img> tags
179
+ Returns the HTML string (also saved to disk).
180
+ """
181
+ summary_html = _md_to_simple_html(analyst_summary)
182
+ chart_paths = collect_charts(session_dir)
183
+ charts_html = ""
184
+ for cp in chart_paths:
185
+ try:
186
+ with open(cp, "rb") as f:
187
+ b64 = base64.b64encode(f.read()).decode("ascii")
188
+ name = Path(cp).stem.replace("_", " ").title()
189
+ charts_html += (
190
+ f'<div class="chart"><h3>{name}</h3>'
191
+ f'<img src="data:image/png;base64,{b64}" alt="{name}" /></div>\n'
192
+ )
193
+ except Exception:
194
+ logger.warning("Could not embed chart %s in HTML", cp, exc_info=True)
195
+ ds_label = (
196
+ f"<p class='meta'>Dataset: <code>{dataset_filename}</code></p>"
197
+ if dataset_filename else ""
198
+ )
199
+ html = (
200
+ "<div style='font-family:Inter,Segoe UI,system-ui,sans-serif;"
201
+ "width:100%;padding:1rem;color:#1e293b;box-sizing:border-box;'>"
202
+ "<style>\n"
203
+ ".rpt h1{font-size:1.4rem;color:#3730a3;border-bottom:2px solid #c7d2fe;"
204
+ "padding-bottom:.4rem;margin-top:0}\n"
205
+ ".rpt h2{font-size:1.15rem;color:#3730a3;margin-top:1.5rem}\n"
206
+ ".rpt h3{font-size:1rem;color:#1e293b;font-weight:600}\n"
207
+ ".rpt .meta{color:#475569;font-size:.85rem}\n"
208
+ ".rpt .chart{margin:1rem 0}\n"
209
+ ".rpt .chart img{max-width:100%;border-radius:10px;"
210
+ "box-shadow:0 2px 12px rgba(0,0,0,.06);margin:.4rem 0 1rem}\n"
211
+ ".rpt .findings{background:#f8faff;padding:1.2rem;"
212
+ "border-radius:10px;border:1px solid #e0e7ff;margin-top:.8rem}\n"
213
+ ".rpt .findings h1,.rpt .findings h2,.rpt .findings h3{"
214
+ "color:#1e3a5f}\n"
215
+ ".rpt .findings p,.rpt .findings li,.rpt .findings span{"
216
+ "color:#1e293b}\n"
217
+ ".rpt .findings strong{color:#0f172a}\n"
218
+ ".rpt code{background:#e0e7ff;padding:2px 6px;border-radius:4px;"
219
+ "font-size:.88em;color:#3730a3}\n"
220
+ ".rpt pre{background:#f1f5f9;padding:.8rem;border-radius:8px;"
221
+ "overflow-x:auto;font-size:.85em;color:#1e293b}\n"
222
+ ".rpt ul,.rpt ol{padding-left:1.4rem}"
223
+ ".rpt li{margin-bottom:.25rem;line-height:1.6;color:#1e293b}\n"
224
+ ".rpt p{line-height:1.6;color:#1e293b}\n"
225
+ "</style>\n"
226
+ f"<div class='rpt'>\n<h1>Data Analysis Report</h1>\n{ds_label}\n"
227
+ )
228
+ if charts_html:
229
+ html += f"<h2>Charts</h2>\n{charts_html}\n"
230
+ html += (
231
+ f"<h2>Findings</h2>\n<div class='findings'>\n{summary_html}\n</div>\n"
232
+ "<p class='meta' style='margin-top:2rem;text-align:center;color:#64748b'>"
233
+ "Generated by Data Analyst Agent</p>\n</div>\n</div>"
234
+ )
235
+ html_path = os.path.join(session_dir, "analysis_report.html")
236
+ with open(html_path, "w", encoding="utf-8") as f:
237
+ f.write(html)
238
+ logger.info("HTML report saved to %s", html_path)
239
+ return html
240
+ def _md_to_simple_html(text: str) -> str:
241
+ """Minimal markdown-to-HTML conversion for analyst output."""
242
+ if not text:
243
+ return ""
244
+ lines = text.split("\n")
245
+ out: list[str] = []
246
+ in_ul = False
247
+ in_ol = False
248
+ in_code = False
249
+ for line in lines:
250
+ s = line.strip()
251
+ if s.startswith("```"):
252
+ if in_code:
253
+ out.append("</code></pre>")
254
+ else:
255
+ out.append("<pre><code>")
256
+ in_code = not in_code
257
+ continue
258
+ if in_code:
259
+ out.append(line)
260
+ continue
261
+ if s.startswith("### "):
262
+ if in_ul: out.append("</ul>"); in_ul = False
263
+ if in_ol: out.append("</ol>"); in_ol = False
264
+ out.append(f"<h3>{_inline_fmt(s[4:])}</h3>"); continue
265
+ if s.startswith("## "):
266
+ if in_ul: out.append("</ul>"); in_ul = False
267
+ if in_ol: out.append("</ol>"); in_ol = False
268
+ out.append(f"<h2>{_inline_fmt(s[3:])}</h2>"); continue
269
+ if s.startswith("# "):
270
+ if in_ul: out.append("</ul>"); in_ul = False
271
+ if in_ol: out.append("</ol>"); in_ol = False
272
+ out.append(f"<h1>{_inline_fmt(s[2:])}</h1>"); continue
273
+ if s.startswith("- ") or s.startswith("* "):
274
+ if in_ol: out.append("</ol>"); in_ol = False
275
+ if not in_ul: out.append("<ul>"); in_ul = True
276
+ out.append(f"<li>{_inline_fmt(s[2:])}</li>"); continue
277
+ m = re.match(r"^(\d+)\.\s+(.+)$", s)
278
+ if m:
279
+ if in_ul: out.append("</ul>"); in_ul = False
280
+ if not in_ol: out.append("<ol>"); in_ol = True
281
+ out.append(f"<li>{_inline_fmt(m.group(2))}</li>"); continue
282
+ if in_ul: out.append("</ul>"); in_ul = False
283
+ if in_ol: out.append("</ol>"); in_ol = False
284
+ if not s:
285
+ out.append(""); continue
286
+ out.append(f"<p>{_inline_fmt(s)}</p>")
287
+ if in_ul: out.append("</ul>")
288
+ if in_ol: out.append("</ol>")
289
+ if in_code: out.append("</code></pre>")
290
+ return "\n".join(out)
291
+ def _inline_fmt(text: str) -> str:
292
+ """Apply bold and inline-code markdown formatting."""
293
+ text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
294
+ text = re.sub(r"`(.+?)`", r"<code>\1</code>", text)
295
+ return text
296
+ # -- Extract code snippets from conversation -----------------------------------
297
+ def extract_python_snippets(messages) -> list[str]:
298
+ """
299
+ Walk through LangChain messages and pull out the Python code that was
300
+ sent to the PythonREPLTool (via tool_calls).
301
+ """
302
+ from langchain_core.messages import AIMessage
303
+ snippets: list[str] = []
304
+ for msg in messages:
305
+ if isinstance(msg, AIMessage) and hasattr(msg, "tool_calls") and msg.tool_calls:
306
+ for tc in msg.tool_calls:
307
+ name = tc.get("name", "")
308
+ if "python" in name.lower():
309
+ args = tc.get("args", {})
310
+ code = (
311
+ args.get("command")
312
+ or args.get("code")
313
+ or args.get("query")
314
+ or ""
315
+ )
316
+ if code.strip():
317
+ snippets.append(code.strip())
318
+ return snippets
319
+ # -- Tool factory --------------------------------------------------------------
320
+ def get_analyst_tools(session_dir: str | Path, enable_web_search: bool | None = None):
321
+ file_tools = get_file_tools(session_dir)
322
+ tools = list(file_tools)
323
+ if enable_web_search is None:
324
+ enable_web_search = bool(os.getenv("SERPER_API_KEY"))
325
+ if enable_web_search:
326
+ serper = GoogleSerperAPIWrapper()
327
+ search_tool = Tool(
328
+ name="web_search",
329
+ func=serper.run,
330
+ description="Search the web for context about data, industry benchmarks, or methodology questions.",
331
+ )
332
+ tools.append(search_tool)
333
+ wikipedia = WikipediaAPIWrapper()
334
+ wiki_tool = WikipediaQueryRun(api_wrapper=wikipedia)
335
+ python_repl = PythonREPLTool()
336
+ tools.extend([python_repl, wiki_tool])
337
+ return tools
app.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import os
3
+ from analyst import DataAnalystAgent
4
+ from analyst_tools import (
5
+ copy_uploaded_file,
6
+ cleanup_session_sandbox,
7
+ normalize_message_text,
8
+ collect_charts,
9
+ )
10
+ # ── Helpers ────────────────────────────────────────────────────────────────────
11
+ def _init_agent():
12
+ agent = DataAnalystAgent()
13
+ agent.setup()
14
+ return agent
15
+ def _show_status(text: str) -> str:
16
+ """Return a small HTML status badge."""
17
+ return (
18
+ f'<div class="status-badge">'
19
+ f'<span class="pulse"></span> {text}</div>'
20
+ )
21
+ def _status_done(text: str) -> str:
22
+ return f'<div class="status-badge done">{text}</div>'
23
+ def process_message(agent, message, success_criteria, dataset_file, history):
24
+ """Run the agent and return updated UI state with HTML report + notebook."""
25
+ if not message.strip():
26
+ return (
27
+ history, agent, gr.update(),
28
+ gr.update(visible=False),
29
+ gr.update(value="", visible=False),
30
+ gr.update(visible=False),
31
+ gr.update(value=None, visible=False),
32
+ gr.update(value=""),
33
+ )
34
+ dataset_filename = None
35
+ if dataset_file is not None:
36
+ original_name = os.path.basename(dataset_file)
37
+ copy_uploaded_file(dataset_file, original_name, agent.session_dir)
38
+ dataset_filename = original_name
39
+ updated_history, evaluator_feedback, _, nb_path, html_report = agent.run(
40
+ message, success_criteria, dataset_filename, history,
41
+ )
42
+ evaluator_feedback = normalize_message_text(evaluator_feedback)
43
+ # Charts gallery
44
+ charts = collect_charts(agent.session_dir)
45
+ chart_update = gr.update(value=charts, visible=True) if charts else gr.update(visible=False)
46
+ # HTML report rendered in-browser
47
+ report_update = gr.update(value=html_report, visible=True) if html_report else gr.update(visible=False)
48
+ # Notebook download
49
+ nb_update = gr.update(value=nb_path, visible=True) if nb_path else gr.update(value=None, visible=False)
50
+ # Status line
51
+ chart_count = len(charts)
52
+ status_parts = ["Analysis complete"]
53
+ if chart_count:
54
+ status_parts.append(f"{chart_count} chart{'s' if chart_count != 1 else ''}")
55
+ if nb_path:
56
+ status_parts.append("notebook ready")
57
+ status_text = _status_done(" | ".join(status_parts))
58
+ return (
59
+ updated_history,
60
+ agent,
61
+ gr.update(value=""),
62
+ chart_update,
63
+ gr.update(value=evaluator_feedback, visible=bool(evaluator_feedback)),
64
+ report_update,
65
+ nb_update,
66
+ gr.update(value=status_text),
67
+ )
68
+ def reset_agent(agent):
69
+ cleanup_session_sandbox(agent.session_dir)
70
+ new_agent = agent.reset()
71
+ return (
72
+ [],
73
+ new_agent,
74
+ gr.update(value=""),
75
+ gr.update(value=""),
76
+ gr.update(value=None),
77
+ gr.update(visible=False),
78
+ gr.update(value="", visible=False),
79
+ gr.update(visible=False),
80
+ gr.update(value=None, visible=False),
81
+ gr.update(value=""),
82
+ )
83
+ # ── CSS ────────────────────────────────────────────────────────────────────────
84
+ CSS = """
85
+ /* ── Page background ─────────────────────────────────────────────────────── */
86
+ body {
87
+ background:
88
+ radial-gradient(ellipse at 10% 0%, rgba(99,102,241,.12), transparent 50%),
89
+ radial-gradient(ellipse at 90% 0%, rgba(234,179,8,.10), transparent 40%),
90
+ linear-gradient(180deg, #f8faff 0%, #eef2fb 100%);
91
+ }
92
+ .gradio-container { max-width: 1200px !important; }
93
+ /* ── Header ──────────────────────────────────────────────────────────────── */
94
+ #app-header {
95
+ text-align: center;
96
+ padding: 1.2rem 0 .6rem;
97
+ }
98
+ #app-header h1 {
99
+ font-size: 1.8rem;
100
+ background: linear-gradient(135deg, #1e40af 0%, #6366f1 55%, #d97706 100%);
101
+ -webkit-background-clip: text;
102
+ -webkit-text-fill-color: transparent;
103
+ background-clip: text;
104
+ margin: 0;
105
+ letter-spacing: -0.02em;
106
+ }
107
+ #app-header p {
108
+ color: #64748b;
109
+ font-size: .92rem;
110
+ margin: .3rem 0 0;
111
+ }
112
+ /* ── Cards ───────────────────────────────────────────────────────────────── */
113
+ .card {
114
+ background: rgba(255,255,255,.92);
115
+ backdrop-filter: blur(12px);
116
+ border: 1px solid rgba(99,102,241,.10);
117
+ border-radius: 16px;
118
+ box-shadow: 0 4px 24px rgba(30,64,175,.06);
119
+ padding: 1rem;
120
+ }
121
+ /* ── Chatbot ───���─────────────────────────────────────────────────────────── */
122
+ #chatbot {
123
+ border-radius: 16px !important;
124
+ border: 1px solid rgba(99,102,241,.12) !important;
125
+ box-shadow: 0 8px 32px rgba(30,64,175,.08) !important;
126
+ background: linear-gradient(180deg, #fff 0%, #f8faff 100%) !important;
127
+ }
128
+ /* ── Input area ──────────────────────────────────────────────────────────── */
129
+ #input-group {
130
+ background: rgba(255,255,255,.88);
131
+ backdrop-filter: blur(10px);
132
+ border: 1px solid rgba(99,102,241,.10);
133
+ border-radius: 16px;
134
+ box-shadow: 0 4px 20px rgba(30,64,175,.05);
135
+ padding: .8rem 1rem;
136
+ }
137
+ /* ── Buttons ─────────────────────────────────────────────────────────────── */
138
+ #go-btn {
139
+ min-width: 140px;
140
+ background: linear-gradient(135deg, #4f46e5 0%, #6366f1 60%, #d97706 100%) !important;
141
+ color: #fff !important;
142
+ border: none !important;
143
+ border-radius: 12px !important;
144
+ font-weight: 600 !important;
145
+ font-size: .95rem !important;
146
+ box-shadow: 0 4px 16px rgba(79,70,229,.30) !important;
147
+ transition: transform .15s, box-shadow .15s;
148
+ }
149
+ #go-btn:hover {
150
+ transform: translateY(-1px);
151
+ box-shadow: 0 6px 24px rgba(79,70,229,.36) !important;
152
+ }
153
+ #reset-btn {
154
+ border: 1px solid rgba(239,68,68,.25) !important;
155
+ background: rgba(254,242,242,.8) !important;
156
+ color: #b91c1c !important;
157
+ border-radius: 12px !important;
158
+ font-weight: 500 !important;
159
+ transition: background .15s;
160
+ }
161
+ #reset-btn:hover { background: rgba(254,226,226,.9) !important; }
162
+ /* ── Tabs ────────────────────────────────────────────────────────────────── */
163
+ .gr-tab-nav button {
164
+ font-weight: 600 !important;
165
+ color: #475569 !important;
166
+ border-radius: 10px 10px 0 0 !important;
167
+ transition: color .15s;
168
+ }
169
+ .gr-tab-nav button.selected {
170
+ color: #4f46e5 !important;
171
+ border-bottom: 2px solid #4f46e5 !important;
172
+ }
173
+ /* ── Sidebar labels ──────────────────────────────────────────────────────── */
174
+ .sidebar-label {
175
+ font-size: .82rem;
176
+ font-weight: 700;
177
+ text-transform: uppercase;
178
+ letter-spacing: .06em;
179
+ color: #6366f1;
180
+ margin: .6rem 0 .3rem;
181
+ }
182
+ /* ── Status badge ────────────────────────────────────────────────────────── */
183
+ .status-badge {
184
+ display: inline-flex;
185
+ align-items: center;
186
+ gap: 6px;
187
+ font-size: .82rem;
188
+ font-weight: 600;
189
+ color: #4f46e5;
190
+ padding: 4px 12px;
191
+ border-radius: 999px;
192
+ background: rgba(99,102,241,.08);
193
+ }
194
+ .status-badge.done { color: #15803d; background: rgba(22,163,74,.08); }
195
+ .pulse {
196
+ width: 8px; height: 8px;
197
+ border-radius: 50%;
198
+ background: #4f46e5;
199
+ animation: pulse-anim 1.4s infinite;
200
+ }
201
+ @keyframes pulse-anim {
202
+ 0% { opacity: 1; transform: scale(1); }
203
+ 50% { opacity: .4; transform: scale(1.4); }
204
+ 100% { opacity: 1; transform: scale(1); }
205
+ }
206
+ /* ── Gallery ─────────────────────────────────────────────────────────────── */
207
+ .gr-gallery {
208
+ border-radius: 12px !important;
209
+ border: 1px solid rgba(99,102,241,.08) !important;
210
+ }
211
+ /* ── File upload / download ──────────────────────────────────────────────── */
212
+ .gr-file {
213
+ border-radius: 12px !important;
214
+ border: 1px dashed rgba(99,102,241,.20) !important;
215
+ background: rgba(248,250,255,.9) !important;
216
+ }
217
+ /* ── Report embed ────────────────────────────────────────────────────────── */
218
+ #tab-report > div {
219
+ width: 100% !important;
220
+ max-width: 100% !important;
221
+ }
222
+ #tab-report .prose {
223
+ max-width: 100% !important;
224
+ }
225
+ /* Force dark text inside the HTML report for readability */
226
+ #tab-report .rpt,
227
+ #tab-report .rpt * {
228
+ color: #1e293b;
229
+ }
230
+ #tab-report .rpt h1, #tab-report .rpt h2 {
231
+ color: #3730a3;
232
+ }
233
+ #tab-report .rpt .findings {
234
+ background: #f8faff;
235
+ border: 1px solid #e0e7ff;
236
+ }
237
+ /* ── Tab panel backgrounds ───────────────────────────────────────────────── */
238
+ .gr-tabitem {
239
+ background: rgba(255,255,255,.6) !important;
240
+ border-radius: 0 0 12px 12px;
241
+ }
242
+ /* ── Misc ────────────────────────────────────────────────────────────────── */
243
+ footer { display: none !important; }
244
+ .gr-examples .gr-samples-table { border-radius: 12px !important; }
245
+ """
246
+ # ── Build UI ───────────────────────────────────────────────────────────────────
247
+ with gr.Blocks(
248
+ title="Data Analyst Agent",
249
+ theme=gr.themes.Soft(
250
+ primary_hue=gr.themes.colors.indigo,
251
+ secondary_hue=gr.themes.colors.amber,
252
+ neutral_hue=gr.themes.colors.slate,
253
+ font=[
254
+ gr.themes.GoogleFont("Inter"),
255
+ "system-ui",
256
+ "sans-serif",
257
+ ],
258
+ ),
259
+ css=CSS,
260
+ ) as ui:
261
+ # ── Header ─────────────────────────────────────────────────────────────────
262
+ gr.HTML(
263
+ '<div id="app-header">'
264
+ "<h1>Data Analyst Agent</h1>"
265
+ "<p>Upload a CSV, ask a question — the agent writes &amp; runs Python, "
266
+ "then delivers insights you can save as a notebook.</p>"
267
+ "</div>"
268
+ )
269
+ agent_state = gr.State()
270
+ # ── Top bar: dataset upload + status ───────────────────────────────────────
271
+ with gr.Row(equal_height=True):
272
+ with gr.Column(scale=3):
273
+ dataset_upload = gr.File(
274
+ label="Dataset (CSV)",
275
+ file_types=[".csv"],
276
+ type="filepath",
277
+ elem_classes=["card"],
278
+ )
279
+ with gr.Column(scale=2):
280
+ status_html = gr.HTML(value="", elem_id="status-bar")
281
+ # ── Main content: left = chat, right = results tabs ────────────────────────
282
+ with gr.Row(equal_height=False):
283
+ # ── Left: conversation ─────────────────────────────────────────────────
284
+ with gr.Column(scale=5, min_width=440):
285
+ chatbot = gr.Chatbot(
286
+ label="Conversation",
287
+ elem_id="chatbot",
288
+ height=500,
289
+ type="messages",
290
+ show_copy_button=True,
291
+ avatar_images=(
292
+ None,
293
+ "https://api.dicebear.com/9.x/bottts/svg?seed=analyst",
294
+ ),
295
+ )
296
+ # ── Input area ─────────────────────────────────────────────────────
297
+ with gr.Group(elem_id="input-group"):
298
+ message = gr.Textbox(
299
+ show_label=False,
300
+ placeholder="Ask a question about your data ...",
301
+ lines=1,
302
+ max_lines=4,
303
+ scale=4,
304
+ )
305
+ success_criteria = gr.Textbox(
306
+ show_label=False,
307
+ placeholder="Success criteria (optional) — e.g. Include correlation matrix and at least one chart",
308
+ lines=1,
309
+ max_lines=2,
310
+ scale=4,
311
+ )
312
+ with gr.Row():
313
+ reset_btn = gr.Button(
314
+ "Reset",
315
+ variant="stop",
316
+ elem_id="reset-btn",
317
+ size="sm",
318
+ )
319
+ go_btn = gr.Button(
320
+ "Analyse",
321
+ variant="primary",
322
+ elem_id="go-btn",
323
+ size="lg",
324
+ )
325
+ # ── Right: results tabs ────────────────────────────────────────────────
326
+ with gr.Column(scale=4, min_width=360):
327
+ with gr.Tabs():
328
+ # Tab 1 — Report
329
+ with gr.Tab("Report", id="tab-report"):
330
+ report_html = gr.HTML(
331
+ label="Analysis Report",
332
+ visible=False,
333
+ )
334
+ gr.Markdown(
335
+ "<p style='text-align:center;color:#94a3b8;font-size:.85rem'>"
336
+ "Run an analysis to see the report here.</p>",
337
+ elem_id="report-placeholder",
338
+ )
339
+ # Tab 2 — Charts
340
+ with gr.Tab("Charts", id="tab-charts"):
341
+ chart_gallery = gr.Gallery(
342
+ label="Generated Charts",
343
+ columns=2,
344
+ rows=2,
345
+ visible=False,
346
+ height=400,
347
+ object_fit="contain",
348
+ )
349
+ gr.Markdown(
350
+ "<p style='text-align:center;color:#94a3b8;font-size:.85rem'>"
351
+ "Charts will appear here after analysis.</p>",
352
+ elem_id="charts-placeholder",
353
+ )
354
+ # Tab 3 — Save / Download
355
+ with gr.Tab("Save", id="tab-save"):
356
+ gr.HTML(
357
+ '<p class="sidebar-label">Download Notebook</p>'
358
+ '<p style="color:#64748b;font-size:.85rem">'
359
+ "The notebook (.ipynb) bundles your analysis code, "
360
+ "charts, and findings in one file.</p>"
361
+ )
362
+ notebook_download = gr.File(
363
+ label="Notebook (.ipynb)",
364
+ visible=False,
365
+ interactive=False,
366
+ )
367
+ # Tab 4 — Evaluator
368
+ with gr.Tab("Evaluator", id="tab-eval"):
369
+ gr.Markdown(
370
+ "<p style='color:#64748b;font-size:.85rem'>"
371
+ "Internal quality evaluator feedback.</p>"
372
+ )
373
+ evaluator_feedback = gr.Markdown(visible=False)
374
+ # ── Example prompts ────────────────────────────────────────────────────────
375
+ gr.Examples(
376
+ examples=[
377
+ [
378
+ "What are the key trends in this dataset? Detect any outliers.",
379
+ "Include at least one chart",
380
+ ],
381
+ [
382
+ "Run a correlation analysis and identify the top 3 most correlated pairs.",
383
+ "",
384
+ ],
385
+ [
386
+ "Forecast the next 3 periods using a simple linear trend.",
387
+ "",
388
+ ],
389
+ ],
390
+ inputs=[message, success_criteria],
391
+ label="Quick-start prompts",
392
+ )
393
+ # ── Events ─────────────────────────────────────────────────────────────────
394
+ ui.load(_init_agent, [], [agent_state])
395
+ shared_inputs = [agent_state, message, success_criteria, dataset_upload, chatbot]
396
+ shared_outputs = [
397
+ chatbot, agent_state, message, chart_gallery, evaluator_feedback,
398
+ report_html, notebook_download, status_html,
399
+ ]
400
+ go_btn.click(process_message, shared_inputs, shared_outputs)
401
+ message.submit(process_message, shared_inputs, shared_outputs)
402
+ reset_btn.click(
403
+ reset_agent,
404
+ [agent_state],
405
+ [
406
+ chatbot, agent_state, message, success_criteria, dataset_upload,
407
+ chart_gallery, evaluator_feedback, report_html, notebook_download,
408
+ status_html,
409
+ ],
410
+ )
411
+ if __name__ == "__main__":
412
+ ui.launch()
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ langchain>=0.2.0
3
+ langchain-openai>=0.1.0
4
+ langchain-community>=0.2.0
5
+ langchain-experimental>=0.0.60
6
+ langgraph>=0.2.0
7
+ python-dotenv
8
+ openai>=1.0.0
9
+ pandas
10
+ matplotlib
11
+ seaborn
12
+ scipy
13
+ # Optional: provide SERPER_API_KEY to enable web search via LangChain's GoogleSerperAPIWrapper.
14
+ wikipedia
15
+ pydantic>=2.0.0
sample_sales_data.csv ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ date,region,category,revenue,orders,marketing_spend,customer_satisfaction,returns,inventory_days
2
+ 2024-01-01,North,Electronics,42000,210,9000,4.2,8,28
3
+ 2024-02-01,North,Electronics,43800,218,9200,4.3,9,27
4
+ 2024-03-01,North,Electronics,45100,225,9500,4.4,7,26
5
+ 2024-04-01,North,Electronics,46700,232,9800,4.3,10,25
6
+ 2024-05-01,North,Electronics,48900,245,10100,4.5,8,24
7
+ 2024-06-01,North,Electronics,51200,252,10400,4.4,9,23
8
+ 2024-07-01,North,Electronics,54800,268,11000,4.6,8,22
9
+ 2024-08-01,North,Electronics,56200,274,11200,4.5,7,22
10
+ 2024-09-01,North,Electronics,57900,281,11400,4.6,8,21
11
+ 2024-10-01,North,Electronics,60100,289,11700,4.7,7,20
12
+ 2024-11-01,North,Electronics,64800,305,12600,4.6,11,19
13
+ 2024-12-01,North,Electronics,73500,338,13900,4.5,16,18
14
+ 2024-01-01,South,Home,28500,175,6100,4.1,6,34
15
+ 2024-02-01,South,Home,29100,179,6200,4.2,7,33
16
+ 2024-03-01,South,Home,30500,184,6400,4.1,6,32
17
+ 2024-04-01,South,Home,31700,190,6600,4.2,5,31
18
+ 2024-05-01,South,Home,33200,197,6900,4.3,6,30
19
+ 2024-06-01,South,Home,34800,205,7200,4.4,7,29
20
+ 2024-07-01,South,Home,35900,211,7400,4.5,6,28
21
+ 2024-08-01,South,Home,37200,219,7600,4.4,7,28
22
+ 2024-09-01,South,Home,38100,224,7800,4.5,8,27
23
+ 2024-10-01,South,Home,39500,231,8100,4.6,7,26
24
+ 2024-11-01,South,Home,42100,242,8600,4.5,9,25
25
+ 2024-12-01,South,Home,50600,278,9800,4.3,15,24
26
+ 2024-01-01,West,Fashion,19800,160,5200,3.9,12,42
27
+ 2024-02-01,West,Fashion,20500,164,5300,4.0,11,41
28
+ 2024-03-01,West,Fashion,21400,168,5450,4.0,10,40
29
+ 2024-04-01,West,Fashion,22100,171,5600,4.1,9,40
30
+ 2024-05-01,West,Fashion,23600,178,5900,4.2,8,39
31
+ 2024-06-01,West,Fashion,24900,183,6100,4.2,8,38
32
+ 2024-07-01,West,Fashion,25800,188,6300,4.3,9,37
33
+ 2024-08-01,West,Fashion,26600,192,6500,4.2,10,36
34
+ 2024-09-01,West,Fashion,27400,195,6700,4.1,11,36
35
+ 2024-10-01,West,Fashion,28900,201,6950,4.2,10,35
36
+ 2024-11-01,West,Fashion,30100,206,7200,4.1,11,34
37
+ 2024-12-01,West,Fashion,41500,244,8800,3.8,24,33
38
+ 2025-01-01,North,Electronics,45200,226,9700,4.4,8,24
39
+ 2025-02-01,North,Electronics,46600,230,9900,4.3,9,24
40
+ 2025-03-01,North,Electronics,48100,236,10100,4.4,8,23
41
+ 2025-04-01,North,Electronics,50300,244,10500,4.5,7,22
42
+ 2025-05-01,North,Electronics,52700,253,10800,4.5,8,21
43
+ 2025-06-01,North,Electronics,55100,261,11100,4.6,8,20
44
+ 2025-07-01,North,Electronics,58800,278,11800,4.7,7,19
45
+ 2025-08-01,North,Electronics,60300,284,12000,4.6,8,19
46
+ 2025-09-01,North,Electronics,62100,291,12300,4.7,7,18
47
+ 2025-10-01,North,Electronics,64800,299,12600,4.7,8,17
48
+ 2025-11-01,North,Electronics,69400,316,13400,4.6,10,16
49
+ 2025-12-01,North,Electronics,81200,358,15100,4.4,18,15
50
+ 2025-01-01,South,Home,31400,188,6700,4.2,6,30
51
+ 2025-02-01,South,Home,32200,191,6800,4.2,7,30
52
+ 2025-03-01,South,Home,33600,198,7050,4.3,6,29
53
+ 2025-04-01,South,Home,34900,204,7250,4.3,6,28
54
+ 2025-05-01,South,Home,36500,211,7550,4.4,5,27
55
+ 2025-06-01,South,Home,38100,219,7800,4.5,6,26
56
+ 2025-07-01,South,Home,39200,225,8000,4.5,6,26
57
+ 2025-08-01,South,Home,40700,233,8250,4.4,7,25
58
+ 2025-09-01,South,Home,41900,239,8450,4.5,7,24
59
+ 2025-10-01,South,Home,43400,246,8700,4.6,6,23
60
+ 2025-11-01,South,Home,46100,258,9200,4.5,8,22
61
+ 2025-12-01,South,Home,54800,291,10400,4.2,16,21
62
+ 2025-01-01,West,Fashion,22600,172,5600,4.0,10,37
63
+ 2025-02-01,West,Fashion,23400,176,5750,4.1,9,36
64
+ 2025-03-01,West,Fashion,24300,181,5900,4.1,8,35
65
+ 2025-04-01,West,Fashion,25200,185,6050,4.2,8,34
66
+ 2025-05-01,West,Fashion,26800,191,6300,4.2,7,33
67
+ 2025-06-01,West,Fashion,28100,196,6500,4.3,7,32
68
+ 2025-07-01,West,Fashion,29200,201,6700,4.3,8,31
69
+ 2025-08-01,West,Fashion,30100,205,6900,4.2,9,31
70
+ 2025-09-01,West,Fashion,30900,208,7100,4.1,10,30
71
+ 2025-10-01,West,Fashion,32400,214,7350,4.2,9,29
72
+ 2025-11-01,West,Fashion,33700,219,7600,4.1,10,28
73
+ 2025-12-01,West,Fashion,46800,261,9300,3.7,25,27