Spaces:
Sleeping
Sleeping
JamesDominiqueAI commited on
Commit ·
9379c69
0
Parent(s):
Initial Hugging Face Space deployment
Browse files- .gitignore +3 -0
- README.md +39 -0
- analyst.py +477 -0
- analyst_tools.py +337 -0
- app.py +412 -0
- requirements.txt +15 -0
- 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 & 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
|