Spaces:
Sleeping
Sleeping
sekpona kokou commited on
Commit ·
bd97fec
1
Parent(s): fa765a2
add client
Browse files- app.py +145 -0
- charts/file.png +0 -0
- prompt.py +26 -0
- requirements.txt +6 -0
- style.css +185 -0
- templates.py +106 -0
- 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""})
|
| 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}"
|