sekpona kokou commited on
Commit
bd97fec
·
1 Parent(s): fa765a2

add client

Browse files
Files changed (7) hide show
  1. app.py +145 -0
  2. charts/file.png +0 -0
  3. prompt.py +26 -0
  4. requirements.txt +6 -0
  5. style.css +185 -0
  6. templates.py +106 -0
  7. tools.py +72 -0
app.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import base64
4
+ from pathlib import Path
5
+ from dotenv import load_dotenv
6
+ import gradio as gr
7
+ from smolagents import ToolCallingAgent, AzureOpenAIModel
8
+ from smolagents.mcp_client import MCPClient
9
+
10
+ from tools import generate_chart, LAST_CHART, AGENT_STEPS
11
+ from prompt import AGENT_INSTRUCTIONS
12
+ from templates import SIDEBAR_HTML, WELCOME_HTML, INPUT_HTML, FILL_JS, steps_to_html
13
+
14
+ # Config
15
+
16
+ BASE_DIR = Path(__file__).parent
17
+ load_dotenv(BASE_DIR.parent / ".env")
18
+
19
+ MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "https://sitsope-mcp-server-test.hf.space/gradio_api/mcp/sse")
20
+ AZURE_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT", "https://collier-llm.openai.azure.com/")
21
+ AZURE_API_KEY = os.getenv("AZURE_OPENAI_API_KEY")
22
+ AZURE_API_VER = os.getenv("AZURE_OPENAI_API_VERSION", "2024-12-01-preview")
23
+ AZURE_MODEL = os.getenv("AZURE_OPENAI_MODEL", "gpt-4o")
24
+
25
+ CSS = (BASE_DIR / "style.css").read_text()
26
+
27
+ # Agent setup
28
+
29
+ mcp_client = MCPClient({"url": MCP_SERVER_URL, "transport": "sse"}, structured_output=False)
30
+ tools = mcp_client.get_tools()
31
+
32
+ model = AzureOpenAIModel(
33
+ model_id=AZURE_MODEL,
34
+ azure_endpoint=AZURE_ENDPOINT,
35
+ api_key=AZURE_API_KEY,
36
+ api_version=AZURE_API_VER,
37
+ )
38
+
39
+ agent = ToolCallingAgent(
40
+ tools=[*tools, generate_chart],
41
+ model=model,
42
+ instructions=AGENT_INSTRUCTIONS,
43
+ )
44
+
45
+ # Agent runner
46
+
47
+ def run_agent(question: str):
48
+ LAST_CHART["path"] = None
49
+ AGENT_STEPS.clear()
50
+
51
+ response = agent.run(question)
52
+
53
+ if hasattr(agent, "memory") and hasattr(agent.memory, "steps"):
54
+ for step in agent.memory.steps:
55
+ tool_name = getattr(step, "tool_name", None) or getattr(step, "action", None)
56
+ tool_input = getattr(step, "tool_arguments", None) or getattr(step, "tool_input", "")
57
+ observation = getattr(step, "observations", None) or getattr(step, "observation", "")
58
+ if tool_name and tool_name != "final_answer":
59
+ input_str = str(tool_input)[:300] + ("…" if len(str(tool_input)) > 300 else "")
60
+ obs_str = str(observation)[:500] + ("…" if len(str(observation)) > 500 else "")
61
+ AGENT_STEPS.append((str(tool_name), input_str, obs_str))
62
+
63
+ chart_path = LAST_CHART["path"]
64
+ if chart_path is None:
65
+ match = re.search(r"(chart_[^\s]+\.png)", str(response))
66
+ if match:
67
+ chart_path = match.group(1)
68
+
69
+ return str(response), chart_path
70
+
71
+ # Event handlers
72
+
73
+ def img_to_base64(path: str) -> str:
74
+ with open(path, "rb") as f:
75
+ return base64.b64encode(f.read()).decode()
76
+
77
+
78
+ def ask_agent(question: str, history: list):
79
+ if not question.strip():
80
+ return history, gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), ""
81
+
82
+ response, chart_path = run_agent(question)
83
+
84
+ history.append({"role": "user", "content": question})
85
+ if chart_path and os.path.exists(chart_path):
86
+ b64 = img_to_base64(chart_path)
87
+ history.append({"role": "assistant", "content": f"![chart](data:image/png;base64,{b64})"})
88
+ history.append({"role": "assistant", "content": response})
89
+
90
+ steps_html = steps_to_html(AGENT_STEPS)
91
+
92
+ return (
93
+ history,
94
+ gr.update(visible=False),
95
+ gr.update(visible=True),
96
+ gr.update(visible=bool(AGENT_STEPS), value=steps_html),
97
+ "",
98
+ )
99
+
100
+
101
+ def new_chat(_history):
102
+ return [], gr.update(visible=True), gr.update(visible=False), gr.update(visible=False, value="")
103
+
104
+
105
+ # Gradio app
106
+
107
+ with gr.Blocks(title="SO Data Analyst", fill_height=True) as demo:
108
+ gr.HTML(f"<script>{FILL_JS}</script>")
109
+ gr.HTML(SIDEBAR_HTML)
110
+ new_btn = gr.Button("_", elem_id="new-chat-btn", visible=False)
111
+
112
+ with gr.Column(elem_id="main-col"):
113
+ welcome_html = gr.HTML(WELCOME_HTML, visible=True)
114
+ chatbot = gr.Chatbot(
115
+ height=None, elem_id="chatbot", show_label=False, visible=False,
116
+ avatar_images=(None, "https://huggingface.co/front/assets/huggingface_logo-noborder.svg"),
117
+ layout="bubble", sanitize_html=False,
118
+ )
119
+ steps_display = gr.HTML(visible=False, elem_id="steps-box")
120
+
121
+ with gr.Column(elem_id="input-bar"):
122
+ with gr.Group(elem_id="input-container"):
123
+ question_box = gr.Textbox(
124
+ placeholder="Ask anything about the Stack Overflow survey…",
125
+ show_label=False, lines=1, max_lines=4, elem_id="msg-box",
126
+ )
127
+ gr.HTML(INPUT_HTML)
128
+ send_btn = gr.Button("↑", variant="primary", elem_id="send-btn", visible=False)
129
+ gr.HTML('<div id="input-note">Generated content may be inaccurate or false.</div>')
130
+
131
+ state = gr.State([])
132
+
133
+ outputs = [chatbot, welcome_html, chatbot, steps_display, question_box]
134
+ send_btn.click(ask_agent, inputs=[question_box, state], outputs=outputs)
135
+ question_box.submit(ask_agent, inputs=[question_box, state], outputs=outputs)
136
+ new_btn.click(new_chat, inputs=[state], outputs=[chatbot, welcome_html, chatbot, steps_display])
137
+
138
+ if __name__ == "__main__":
139
+ demo.launch(
140
+ theme=gr.themes.Base(),
141
+ css=CSS,
142
+ server_name="0.0.0.0",
143
+ server_port=int(os.getenv("PORT", 7860)),
144
+ share=False,
145
+ )
charts/file.png ADDED
prompt.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ AGENT_INSTRUCTIONS = """
2
+ You are an expert data analyst for Stack Overflow survey data.
3
+
4
+ !!CRITICAL!! The table name is ALWAYS "surveyData" with capital D and double quotes.
5
+ NEVER use "survey" — it does not exist. ALWAYS write: FROM "surveyData"
6
+
7
+ Columns in "surveyData":
8
+ - qid : question identifier (e.g. "QID18")
9
+ - qname : question code name (e.g. "TechEndorse_1")
10
+ - question : full question text
11
+ - type : question type (RO = rank order, ...)
12
+ - sub : sub-category or answer option
13
+ - sq_id : sub-question index (integer)
14
+
15
+ When calling get_from_db, the result is a JSON string — a LIST of row dicts.
16
+
17
+ CHART DECISION — decide autonomously:
18
+ - Call generate_chart whenever data has distributions, counts, rankings, comparisons or proportions.
19
+ - Do NOT wait for the user to ask for a chart — use your own judgment.
20
+ - Use 'bar' for counts/rankings/comparisons, 'pie' for proportions, 'line' for trends.
21
+
22
+ RESPONSE FORMAT:
23
+ - Start with a short, direct answer (1-2 sentences max).
24
+ - Then, only if useful, add a brief explanation or detail (2-3 sentences max).
25
+ - Never start with preambles like "Based on the data..." — go straight to the answer.
26
+ """
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio==6.12.0
2
+ smolagents==1.24.0
3
+ openai==2.24.0
4
+ matplotlib==3.10.8
5
+ seaborn==0.13.2
6
+ python-dotenv==1.0.1
style.css ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ html, body, .gradio-container { height:100% !important; margin:0 !important; padding:0 !important; }
2
+ .gradio-container { max-width:100% !important; }
3
+ footer { display:none !important; }
4
+ .app { padding:0 !important; }
5
+
6
+ /* ── sidebar style ── */
7
+ #sidebar {
8
+ position: fixed; left:0; top:0; bottom:0; width:260px;
9
+ background:#171717; display:flex; flex-direction:column;
10
+ padding:8px; box-sizing:border-box; z-index:200;
11
+ }
12
+ .sb-top {
13
+ display:flex; align-items:center; justify-content:space-between;
14
+ padding:6px 6px 4px 6px; margin-bottom:4px;
15
+ }
16
+ .sb-logo { color:#fff; font-size:0.95rem; font-weight:600; display:flex; align-items:center; gap:8px; }
17
+ .sb-logo svg { width:18px; height:18px; fill:#fff; flex-shrink:0; }
18
+ .sb-icon-btn {
19
+ width:36px; height:36px; border-radius:8px; background:transparent;
20
+ border:none; color:#8e8ea0; cursor:pointer;
21
+ display:flex; align-items:center; justify-content:center; font-size:1rem;
22
+ }
23
+ .sb-icon-btn:hover { background:#2a2a2a; color:#fff; }
24
+ .sb-new-chat {
25
+ display:flex; align-items:center; gap:10px;
26
+ padding:10px 10px; border-radius:10px; cursor:pointer;
27
+ color:#ececf1; font-size:0.88rem; margin-bottom:8px;
28
+ border:none; background:transparent; width:100%; text-align:left;
29
+ }
30
+ .sb-new-chat:hover { background:#2a2a2a; }
31
+ .sb-new-chat svg { width:16px; height:16px; stroke:#ececf1; flex-shrink:0; }
32
+ .sb-section-label {
33
+ color:#8e8ea0; font-size:0.7rem; font-weight:600;
34
+ letter-spacing:.06em; padding:8px 10px 4px 10px;
35
+ }
36
+ .sb-item {
37
+ padding:8px 10px; border-radius:8px; cursor:pointer;
38
+ color:#ececf1; font-size:0.83rem; white-space:nowrap;
39
+ overflow:hidden; text-overflow:ellipsis;
40
+ }
41
+ .sb-item:hover { background:#2a2a2a; }
42
+ .sb-spacer { flex:1; }
43
+ .sb-divider { border:none; border-top:1px solid #2a2a2a; margin:6px 0; }
44
+ .sb-bottom { padding:4px 0; }
45
+ .sb-user {
46
+ display:flex; align-items:center; gap:10px;
47
+ padding:10px; border-radius:10px; cursor:pointer;
48
+ }
49
+ .sb-user:hover { background:#2a2a2a; }
50
+ .sb-avatar {
51
+ width:32px; height:32px; border-radius:50%;
52
+ background:#FF9D00; display:flex; align-items:center;
53
+ justify-content:center; color:#fff; font-size:0.8rem; font-weight:700; flex-shrink:0;
54
+ }
55
+ .sb-username { color:#ececf1; font-size:0.85rem; font-weight:500; }
56
+ .sb-dots { margin-left:auto; color:#8e8ea0; font-size:1rem; }
57
+
58
+ /* ── main area ── */
59
+ #main-col {
60
+ display:flex; flex-direction:column; height:100vh;
61
+ background:#212121 !important; padding:0 !important;
62
+ margin-left:260px; width:calc(100vw - 260px) !important;
63
+ }
64
+ .gradio-container > .main > .wrap { padding:0 !important; gap:0 !important; }
65
+
66
+ /* ── welcome screen ── */
67
+ #welcome {
68
+ flex:1; display:flex; flex-direction:column;
69
+ align-items:center; justify-content:center; gap:20px;
70
+ padding-bottom:160px; background:#212121;
71
+ }
72
+ #welcome-logo { font-size:3rem; }
73
+ #welcome-title { font-size:1.5rem; font-weight:700; color:#fff; margin:0; }
74
+ #welcome-sub { font-size:0.88rem; color:#666; margin:0; }
75
+ #pills-row { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; max-width:620px; }
76
+ .pill {
77
+ background:#2f2f2f; border:1px solid #3a3a3a; border-radius:20px;
78
+ padding:6px 14px; font-size:0.8rem; color:#bbb; cursor:pointer;
79
+ transition: background .15s;
80
+ }
81
+ .pill:hover { background:#3a3a3a; border-color:#555; color:#fff; }
82
+
83
+ /* ── chatbot ── */
84
+ #chatbot {
85
+ flex:1; overflow-y:auto;
86
+ border:none !important; border-radius:0 !important;
87
+ box-shadow:none !important; background:#212121 !important;
88
+ padding-bottom:160px !important;
89
+ }
90
+ #chatbot > div { max-width:760px; margin:0 auto; padding:16px 8px; background:#212121 !important; }
91
+ #chatbot .bubble-wrap { background:#212121 !important; }
92
+ #chatbot [data-testid="bot"] .prose { color:#e0e0e0 !important; }
93
+ #chatbot [data-testid="user"] .prose { color:#fff !important; }
94
+ #chatbot img { border-radius:10px; max-width:100%; margin-top:8px; }
95
+
96
+ /* ── reasoning steps ── */
97
+ #steps-box {
98
+ max-width:760px; margin:0 auto 8px auto; width:100%;
99
+ background:transparent !important; border:none !important; padding:0 !important;
100
+ }
101
+ .steps-toggle {
102
+ background:none; border:none; color:#444; font-size:0.7rem;
103
+ cursor:pointer; padding:2px 0 2px 2px;
104
+ display:flex; align-items:center; gap:4px; line-height:1;
105
+ }
106
+ .steps-toggle:hover { color:#888; }
107
+ .steps-content {
108
+ display:none; margin-top:6px; padding:10px 12px;
109
+ background:#161616; border-radius:7px; border:1px solid #252525;
110
+ font-size:0.78rem; color:#aaa; overflow-x:auto;
111
+ }
112
+ .steps-content pre {
113
+ background:#1e1e1e; padding:6px 8px; border-radius:5px;
114
+ font-size:0.72rem; color:#ccc; overflow-x:auto;
115
+ }
116
+ .step-tool {
117
+ font-family:monospace; background:#2a2a2a; padding:1px 5px;
118
+ border-radius:4px; font-size:0.72rem; color:#FF9D00;
119
+ }
120
+
121
+ /* ── input bar (fixed bottom) ── */
122
+ #input-bar {
123
+ position:fixed !important;
124
+ bottom:0; left:260px; right:0;
125
+ background:linear-gradient(to top, #212121 70%, transparent);
126
+ padding:20px calc(50% - 390px + 130px) 12px;
127
+ z-index:100;
128
+ }
129
+ #input-container {
130
+ display:flex; flex-direction:column;
131
+ background:#2f2f2f; border:1px solid #424242;
132
+ border-radius:16px; overflow:hidden;
133
+ transition:border-color .2s;
134
+ }
135
+ #input-container:focus-within { border-color:#666; }
136
+ #input-container #msg-box { flex:1; background:transparent; border:none !important; box-shadow:none !important; }
137
+ #input-container #msg-box textarea {
138
+ background:transparent !important; border:none !important;
139
+ box-shadow:none !important; outline:none !important;
140
+ color:#ececf1 !important; font-size:0.93rem !important;
141
+ padding:12px 16px 4px 16px !important; resize:none !important;
142
+ min-height:44px !important;
143
+ }
144
+ #input-container #msg-box textarea::placeholder { color:#555 !important; }
145
+ #input-bottom {
146
+ display:flex; align-items:center; justify-content:space-between;
147
+ padding:4px 10px 8px 10px;
148
+ }
149
+ .input-left { display:flex; align-items:center; gap:8px; }
150
+ .add-btn {
151
+ width:28px; height:28px; border-radius:50%;
152
+ background:#3a3a3a; border:1px solid #4a4a4a;
153
+ color:#888; font-size:1rem; cursor:pointer;
154
+ display:flex; align-items:center; justify-content:center;
155
+ }
156
+ .add-btn:hover { background:#444; color:#ccc; }
157
+ .mcp-pill {
158
+ display:flex; align-items:center; gap:5px;
159
+ background:#3a3a3a; border:1px solid #4a4a4a;
160
+ border-radius:20px; padding:3px 10px;
161
+ font-size:0.74rem; color:#aaa; cursor:pointer;
162
+ }
163
+ .mcp-pill:hover { background:#444; }
164
+ .mcp-dot { width:7px; height:7px; border-radius:50%; background:#FF9D00; }
165
+ .input-right { display:flex; align-items:center; gap:6px; }
166
+ .mic-btn {
167
+ width:32px; height:32px; border-radius:50%;
168
+ background:transparent; border:none;
169
+ color:#555; font-size:1rem; cursor:pointer;
170
+ display:flex; align-items:center; justify-content:center;
171
+ }
172
+ .mic-btn:hover { color:#888; }
173
+ #send-btn { display:none !important; }
174
+ .send-visual-btn {
175
+ width:32px; height:32px; border-radius:50%;
176
+ background:#FF9D00; border:none;
177
+ color:#fff; font-size:1rem; font-weight:700;
178
+ cursor:pointer; display:flex; align-items:center; justify-content:center;
179
+ transition:background .15s;
180
+ }
181
+ .send-visual-btn:hover { background:#e68c00; }
182
+ #input-note {
183
+ text-align:center; font-size:0.68rem; color:#3a3a3a;
184
+ padding:4px 0 8px 0;
185
+ }
templates.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SUGGESTIONS = [
2
+ "How many unique questions?",
3
+ "Sub-options per question",
4
+ "Questions above average sub-options",
5
+ "Distribution by question type",
6
+ "Which question has the most sub-options?",
7
+ "Percentage of rows per question",
8
+ ]
9
+
10
+ FILL_JS = """
11
+ function fillInput(text) {
12
+ const ta = document.querySelector('#msg-box textarea');
13
+ if (!ta) return;
14
+ const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
15
+ nativeSetter.call(ta, text);
16
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
17
+ }
18
+ """
19
+
20
+ SIDEBAR_HTML = """
21
+ <div id="sidebar">
22
+ <div class="sb-top">
23
+ <div class="sb-logo">
24
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
25
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
26
+ </svg>
27
+ SO Analyst
28
+ </div>
29
+ <button class="sb-icon-btn" onclick="document.getElementById('new-chat-btn').click()" title="New chat">
30
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
31
+ <path d="M12 5v14M5 12h14"/>
32
+ </svg>
33
+ </button>
34
+ </div>
35
+
36
+ <button class="sb-new-chat" onclick="document.getElementById('new-chat-btn').click()">
37
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
38
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
39
+ </svg>
40
+ New chat
41
+ </button>
42
+
43
+ <div class="sb-section-label">TODAY</div>
44
+ <div class="sb-item">Sub-options per question</div>
45
+ <div class="sb-item">Distribution by question type</div>
46
+ <div class="sb-item">Questions above average</div>
47
+
48
+ <div class="sb-spacer"></div>
49
+ <hr class="sb-divider">
50
+
51
+ <div class="sb-bottom">
52
+ <div class="sb-user">
53
+ <div class="sb-avatar">S</div>
54
+ <span class="sb-username">sitsope</span>
55
+ <span class="sb-dots">···</span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ """
60
+
61
+ WELCOME_HTML = """
62
+ <div id="welcome">
63
+ <div id="welcome-logo">📊</div>
64
+ <p id="welcome-title">Stack Overflow Analyst</p>
65
+ <p id="welcome-sub">Ask anything about the survey data — charts generated automatically.</p>
66
+ <div id="pills-row">
67
+ {pills}
68
+ </div>
69
+ </div>
70
+ """.format(pills="\n".join(
71
+ f'<span class="pill" onclick="fillInput(\'{s}\')">{s}</span>'
72
+ for s in SUGGESTIONS
73
+ ))
74
+
75
+ INPUT_HTML = """
76
+ <div id="input-bottom">
77
+ <div class="input-left">
78
+ <button class="add-btn">+</button>
79
+ <div class="mcp-pill"><span class="mcp-dot"></span> MCP (2)</div>
80
+ </div>
81
+ <div class="input-right">
82
+ <button class="mic-btn">🎤</button>
83
+ <button class="send-visual-btn" onclick="document.getElementById('send-btn').click()">↑</button>
84
+ </div>
85
+ </div>
86
+ """
87
+
88
+ def steps_to_html(agent_steps: list) -> str:
89
+ if not agent_steps:
90
+ return ""
91
+ rows = "".join(f"""
92
+ <div style="margin-bottom:10px">
93
+ <span style="font-size:0.72rem;color:#888">Step {i}</span>
94
+ <span class="step-tool">{name}</span>
95
+ <pre style="margin:4px 0 2px 0">{inp}</pre>
96
+ <pre style="margin:0">{obs}</pre>
97
+ </div>""" for i, (name, inp, obs) in enumerate(agent_steps, 1))
98
+ return f"""
99
+ <div>
100
+ <button class="steps-toggle"
101
+ onclick="var c=this.nextElementSibling;c.style.display=c.style.display==='block'?'none':'block';
102
+ this.querySelector('span').textContent=c.style.display==='block'?'▾':'▸'">
103
+ · reasoning steps <span>▸</span>
104
+ </button>
105
+ <div class="steps-content">{rows}</div>
106
+ </div>"""
tools.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import matplotlib.pyplot as plt
3
+ import matplotlib
4
+ matplotlib.use("Agg")
5
+ import seaborn as sns
6
+ sns.set_theme(style="whitegrid", palette="muted")
7
+ from typing import List
8
+ from pathlib import Path
9
+ from smolagents import tool
10
+
11
+ CHARTS_DIR = Path(__file__).parent / "charts"
12
+ CHARTS_DIR.mkdir(exist_ok=True)
13
+
14
+ LAST_CHART: dict = {"path": None}
15
+
16
+
17
+ @tool
18
+ def generate_chart(
19
+ labels: List[str],
20
+ values: List[float],
21
+ chart_type: str,
22
+ title: str,
23
+ xlabel: str = "",
24
+ ylabel: str = "",
25
+ ) -> str:
26
+ """
27
+ Generate a bar, pie, or line chart from data and save it as a PNG file.
28
+ Call this whenever the data contains a distribution, ranking, comparison,
29
+ count breakdown, or proportion that would be clearer as a visual.
30
+
31
+ Args:
32
+ labels: List of category labels (strings).
33
+ values: List of numeric values matching each label.
34
+ chart_type: 'bar' for counts/rankings/comparisons, 'pie' for proportions, 'line' for trends.
35
+ title: Title displayed on the chart.
36
+ xlabel: X-axis label (bar / line only).
37
+ ylabel: Y-axis label (bar / line only).
38
+
39
+ Returns:
40
+ The file path of the saved PNG image.
41
+ """
42
+ fig, ax = plt.subplots(figsize=(10, 5))
43
+
44
+ if chart_type == "pie":
45
+ ax.pie(values, labels=labels, autopct="%1.1f%%", startangle=140,
46
+ colors=sns.color_palette("muted", len(labels)))
47
+ ax.axis("equal")
48
+ elif chart_type == "line":
49
+ sns.lineplot(x=labels, y=values, marker="o", linewidth=2.5, ax=ax)
50
+ ax.set_xlabel(xlabel)
51
+ ax.set_ylabel(ylabel)
52
+ plt.xticks(rotation=45, ha="right")
53
+ else:
54
+ sns.barplot(x=labels, y=values, hue=labels, palette="muted", legend=False, ax=ax)
55
+ ax.set_xlabel(xlabel)
56
+ ax.set_ylabel(ylabel)
57
+ plt.xticks(rotation=45, ha="right")
58
+ for p in ax.patches:
59
+ ax.annotate(f"{p.get_height():.0f}",
60
+ (p.get_x() + p.get_width() / 2, p.get_height()),
61
+ ha="center", va="bottom", fontsize=9)
62
+
63
+ ax.set_title(title, fontsize=13, fontweight="bold", pad=12)
64
+ fig.tight_layout()
65
+
66
+ safe_title = re.sub(r"[^\w\s-]", "", title[:40]).strip().replace(" ", "_")
67
+ filename = str(CHARTS_DIR / f"chart_{safe_title}.png")
68
+ plt.savefig(filename, dpi=150)
69
+ plt.close()
70
+
71
+ LAST_CHART["path"] = filename
72
+ return f"Chart saved → {filename}"