sanjaymalladi commited on
Commit
d1fcb83
·
verified ·
1 Parent(s): 0d178f6

Rollback app.py to 7d1f6af (last stable before chart experiment)

Browse files
Files changed (1) hide show
  1. app.py +453 -395
app.py CHANGED
@@ -1,414 +1,472 @@
1
- """Lightweight execution-grounded agent for the HF Space demo."""
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
- import os
6
  import re
7
- import subprocess
8
- import sys
9
- import tempfile
10
- import uuid
11
  from pathlib import Path
12
- from typing import Any, Optional
13
-
14
- from config import AGENT_EXEC_TIMEOUT, AGENT_MAX_NEW_TOKENS, AGENT_MAX_STEPS, AGENT_TEMPERATURE, DONE_MARKERS
15
- from prompts import SYSTEM_PROMPT
16
-
17
-
18
- class ContextManager:
19
- def __init__(self, system_prompt: str, max_tokens: int = 6000):
20
- self.system_prompt = system_prompt
21
- self.max_tokens = max_tokens
22
- self.messages: list[dict] = []
23
- self.pinned_first_msg: Optional[dict] = None
24
-
25
- def add_user(self, content: str) -> None:
26
- msg = {"role": "user", "content": content}
27
- if self.pinned_first_msg is None:
28
- self.pinned_first_msg = msg
29
- self.messages.append(msg)
30
-
31
- def add_assistant(self, content: str) -> None:
32
- self.messages.append({"role": "assistant", "content": content})
33
-
34
- def add_result(self, result: str) -> None:
35
- self.messages.append({
36
- "role": "user",
37
- "content": f"<result>\n[EXEC:real]\n{result[:2000]}\n</result>",
38
- })
39
-
40
- def get_messages(self) -> list[dict]:
41
- recent = self._trim_to_budget()
42
- full: list[dict] = [{"role": "system", "content": self.system_prompt}]
43
- if self.pinned_first_msg:
44
- full.append(self.pinned_first_msg)
45
- if recent and recent[0].get("content") == self.pinned_first_msg.get("content"):
46
- recent = recent[1:]
47
- full.extend(recent)
48
- return full
49
-
50
- def _trim_to_budget(self) -> list[dict]:
51
- budget = self.max_tokens
52
- trimmed: list[dict] = []
53
- for msg in reversed(self.messages):
54
- tokens = len(msg["content"].split()) * 1.3
55
- if budget - tokens < 0:
56
- break
57
- trimmed.insert(0, msg)
58
- budget -= tokens
59
- return trimmed
60
-
61
-
62
- def extract_code_blocks(text: str) -> list[str]:
63
- blocks = re.findall(r"```python\n(.*?)```", text, re.DOTALL)
64
- if not blocks:
65
- blocks = re.findall(r"```\n(.*?)```", text, re.DOTALL)
66
- return blocks
67
-
68
-
69
- def detect_output_files(code: str) -> list[str]:
70
- files: list[str] = []
71
- for pattern in (
72
- r'savefig\(["\']([^"\']+)["\']\)',
73
- r'write_html\(["\']([^"\']+)["\']\)',
74
- r'to_csv\(["\']([^"\']+)["\']\)',
75
- ):
76
- files.extend(re.findall(pattern, code))
77
- return files
78
-
79
-
80
- _PLACEHOLDER_STDOUT = frozenset({"(no output)", "(execution failed)"})
81
-
82
-
83
- def _is_placeholder_stdout(stdout: str) -> bool:
84
- first = (stdout or "").strip().splitlines()[0].strip() if stdout else ""
85
- return first in _PLACEHOLDER_STDOUT
86
-
87
-
88
- def format_exec_result(result: dict) -> str:
89
- if result["success"]:
90
- out = result["stdout"] or "(no output)"
91
- if result["files"]:
92
- out += f"\nFiles saved: {list(result['files'].keys())}"
93
- else:
94
- out = result["stderr"] or result["stdout"] or "(execution failed)"
95
- return out
96
-
97
-
98
- def execute_python(code: str, working_dir: str, timeout: int = 30) -> dict:
99
- os.makedirs(working_dir, exist_ok=True)
100
- safe_dir = working_dir.replace("\\", "/").replace("'", "\\'")
101
- preamble = (
102
- f"import os\nos.chdir('{safe_dir}')\n"
103
- "import matplotlib\nmatplotlib.use('Agg')\n"
104
- "import warnings\nwarnings.filterwarnings('ignore')\n"
105
- )
106
- with tempfile.NamedTemporaryFile(
107
- mode="w", suffix=".py", dir=working_dir, delete=False, encoding="utf-8",
108
- ) as f:
109
- f.write(preamble + code)
110
- tmp_path = f.name
111
- try:
112
- proc = subprocess.run(
113
- [sys.executable, tmp_path],
114
- capture_output=True,
115
- text=True,
116
- timeout=timeout,
117
- cwd=working_dir,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  )
119
- return {
120
- "stdout": (proc.stdout or "")[:3000],
121
- "stderr": (proc.stderr or "")[:1500],
122
- "files": {},
123
- "success": proc.returncode == 0,
124
- }
125
- except subprocess.TimeoutExpired:
126
- return {"stdout": "", "stderr": f"TimeoutError: exceeded {timeout}s", "files": {}, "success": False}
127
- finally:
128
- if os.path.exists(tmp_path):
129
- os.unlink(tmp_path)
130
-
131
-
132
- def _read_tabular(path: Path, nrows: int = 200):
133
- import pandas as pd
134
-
135
- suffix = path.suffix.lower()
136
- if suffix in (".xlsx", ".xls"):
137
- return pd.read_excel(path, nrows=nrows)
138
- return pd.read_csv(path, nrows=nrows)
139
-
140
-
141
- def inspect_data(path: Path) -> dict[str, str]:
142
- df = _read_tabular(path, nrows=200)
143
- schema = "\n".join(f" {c}: {df[c].dtype}" for c in df.columns)
144
- sample = df.head(5).to_string(index=False)
145
- kind = "excel" if path.suffix.lower() in (".xlsx", ".xls") else "csv"
146
- return {
147
- "type": kind,
148
- "schema": schema,
149
- "sample": sample,
150
- "row_counts": f"preview_rows={len(df)} (file may be larger)",
151
- }
152
-
153
-
154
- def inspect_csv(path: Path) -> dict[str, str]:
155
- return inspect_data(path)
156
-
157
-
158
- def build_user_message(data_path: Path, task: str) -> str:
159
- info = inspect_data(data_path)
160
- filename = data_path.name
161
- read_hint = (
162
- f"pd.read_excel('{filename}')"
163
- if info["type"] == "excel"
164
- else f"pd.read_csv('{filename}')"
165
  )
166
- lines = [
167
- f"Data source: {filename}",
168
- f"Working directory contains: {filename}",
169
- f"Type: {info['type']}",
170
- "",
171
- "Schema:",
172
- info["schema"],
173
- "",
174
- "Sample rows:",
175
- info["sample"],
176
- "",
177
- info["row_counts"],
178
- "",
179
- f"Task: {task}",
180
- "",
181
- f"Read the file with pandas: {read_hint}",
182
- ]
183
- return "\n".join(lines)
184
-
185
-
186
- DONE_MARKERS = ("**Summary:**", "**Finding:**", "**Conclusion:**", "**Results:**")
187
- FINISH_MARKERS = DONE_MARKERS + (
188
- "**Answer:**",
189
- "**ANSWER:**",
190
- "Final Answer:",
191
- "final answer:",
192
- )
193
 
194
- _GEMMA_TOKEN_RE = re.compile(r"<(?:start_of_turn|end_of_turn|turn)[^>]*>|<\|[^|]+\|>")
195
- _THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
196
 
 
 
 
 
197
 
198
- def _strip_model_noise(text: str) -> str:
199
- text = _THINK_RE.sub("", text)
200
- text = _GEMMA_TOKEN_RE.sub("", text)
201
- return text.strip()
202
 
203
 
204
- def _answer_from_stdout(stdout: str) -> str:
205
- """Best-effort answer from verified execution output."""
206
- if not stdout or _is_placeholder_stdout(stdout):
207
- return ""
208
- if "Files saved:" in stdout:
209
- m = re.search(r"Files saved:\s*\[([^\]]+)\]", stdout)
210
- if m:
211
- names = m.group(1).strip().strip("'\"")
212
- return f"Saved output: {names}"
213
- label_patterns = [
214
- r"(?:Product|product) with highest (?:total )?revenue:\s*(.+)",
215
- r"(?:Top product|top product)(?:\s+by revenue)?:\s*(.+)",
216
- r"(?:The answer is|Answer|Result|Final answer):\s*(.+)",
217
- r"(?:Maximum|Max) revenue:\s*([\d.,]+)",
218
- ]
219
- for line in stdout.splitlines():
220
- line = line.strip()
221
- if not line or line.startswith("Name:") or "dtype:" in line:
222
- continue
223
- for pat in label_patterns:
224
- m = re.search(pat, line, re.IGNORECASE)
225
- if m:
226
- val = m.group(1).strip().strip(".")
227
- if val and val.lower() not in ("nan", "none"):
228
- return val
229
- lines = [ln.strip() for ln in stdout.splitlines() if ln.strip() and "dtype:" not in ln]
230
- if lines and lines[-1] not in _PLACEHOLDER_STDOUT:
231
- return lines[-1]
232
- return ""
233
-
234
-
235
- def _prose_fallback(cleaned: str) -> str:
236
- """Use narrative text when there is no scalar **Answer:** or stdout."""
237
- text = re.sub(r"```.*?```", "", cleaned, flags=re.DOTALL).strip()
238
  if not text:
239
  return ""
240
- for prefix in ("**Summary:**", "**Finding:**", "**Conclusion:**", "**Results:**"):
241
- if prefix in text:
242
- tail = text.split(prefix, 1)[1].strip()
243
- line = tail.split("\n")[0].strip()
244
- if line:
245
- return line[:500]
246
- paragraph = re.split(r"\n\s*\n", text, maxsplit=1)[0].strip()
247
- paragraph = re.sub(r"\s+", " ", paragraph)
248
- if paragraph and len(paragraph) <= 500:
249
- return paragraph
250
- return paragraph[:500].rsplit(" ", 1)[0] + "…" if len(paragraph) > 500 else paragraph
251
-
252
-
253
- def extract_answer(final_text: str, exec_outputs: list[str] | None = None) -> str:
254
- """Parse answer: **Answer:** / Final Answer: → execution stdout → last line."""
255
- exec_outputs = exec_outputs or []
256
- cleaned = _strip_model_noise(final_text)
257
-
258
- tag_patterns = [
259
- r"\*\*Answer:\*\*\s*(.+?)(?:\n|$)",
260
- r"\*\*ANSWER:\*\*\s*(.+?)(?:\n|$)",
261
- r"Final Answer:\s*(.+?)(?:\n|$)",
262
- r"final answer:\s*(.+?)(?:\n|$)",
263
- ]
264
- for pat in tag_patterns:
265
- m = re.search(pat, cleaned, re.IGNORECASE)
266
- if m:
267
- ans = m.group(1).strip().strip("*").strip()
268
- if ans and not ans.startswith("```"):
269
- return ans
270
-
271
- for stdout in reversed(exec_outputs):
272
- if _is_placeholder_stdout(stdout):
273
- continue
274
- from_exec = _answer_from_stdout(stdout)
275
- if from_exec:
276
- return from_exec
277
-
278
- summary = extract_summary(cleaned)
279
  if summary:
280
- return summary
281
-
282
- prose = _prose_fallback(cleaned)
283
- if prose:
284
- return prose
285
-
286
- lines = [ln.strip() for ln in cleaned.splitlines() if ln.strip()]
287
- if lines:
288
- last = lines[-1]
289
- if len(last) < 200 and not last.startswith("```"):
290
- return last
291
- return ""
292
-
293
-
294
- def extract_summary(final_text: str) -> str:
295
- cleaned = _strip_model_noise(final_text)
296
- for prefix in ("**Summary:**", "**Finding:**", "**Conclusion:**", "**Results:**"):
297
- if prefix in cleaned:
298
- tail = cleaned.split(prefix, 1)[1].strip()
299
- line = tail.split("\n")[0].strip()
300
- if line:
301
- return line[:1500]
302
- return ""
303
-
304
-
305
- def generate_response(messages: list, model, tokenizer) -> str:
306
- import torch
307
-
308
- input_ids = tokenizer.apply_chat_template(
309
- messages,
310
- tokenize=True,
311
- add_generation_prompt=True,
312
- return_tensors="pt",
313
- ).to(model.device)
314
-
315
- with torch.no_grad():
316
- output_ids = model.generate(
317
- input_ids,
318
- max_new_tokens=AGENT_MAX_NEW_TOKENS,
319
- temperature=AGENT_TEMPERATURE,
320
- do_sample=AGENT_TEMPERATURE > 0,
321
- pad_token_id=tokenizer.eos_token_id,
322
- )
323
- return tokenizer.decode(output_ids[0][input_ids.shape[-1] :], skip_special_tokens=False)
324
 
 
325
 
326
- def run_agent(
327
- model,
328
- tokenizer,
329
- data_path: Path,
 
 
330
  task: str,
331
- *,
332
- max_steps: int = AGENT_MAX_STEPS,
333
- progress: Optional[Any] = None,
334
- stream: bool = False,
335
- ) -> dict:
336
- """Run generate → execute loop. Returns steps log + final text."""
337
- workspace = Path(tempfile.gettempdir()) / f"datasense_{uuid.uuid4().hex[:10]}"
338
- workspace.mkdir(parents=True, exist_ok=True)
339
-
340
- # Copy dataset into isolated workspace
341
- dest = workspace / data_path.name
342
- dest.write_bytes(data_path.read_bytes())
343
-
344
- context = ContextManager(system_prompt=SYSTEM_PROMPT)
345
- context.add_user(build_user_message(dest, task))
346
-
347
- step_logs: list[str] = []
348
- exec_outputs: list[str] = []
349
- final_text = ""
350
-
351
- for step in range(max_steps):
352
- if progress is not None:
353
- progress((step + 1) / max_steps, desc=f"Step {step + 1}/{max_steps}")
354
-
355
- response = generate_response(context.get_messages(), model, tokenizer)
356
- context.add_assistant(response)
357
- final_text = response
358
-
359
- preview = _strip_model_noise(response).replace("\n", " ")[:180]
360
- step_logs.append(f"### Step {step + 1}\n{preview}...\n")
361
-
362
- if any(m in response for m in FINISH_MARKERS):
363
- step_logs.append("✅ Agent finished.\n")
364
- if stream:
365
- yield ("progress", step + 1, max_steps, "\n".join(step_logs))
366
- break
367
-
368
- code_blocks = extract_code_blocks(response)
369
- if not code_blocks:
370
- if exec_outputs:
371
- step_logs.append("ℹ️ No more code — answer from execution output.\n")
372
- else:
373
- step_logs.append("ℹ️ No code block — stopping.\n")
374
- if stream:
375
- yield ("progress", step + 1, max_steps, "\n".join(step_logs))
376
- break
377
-
378
- result_str = ""
379
- for code_block in code_blocks:
380
- result = execute_python(
381
- code=code_block,
382
- working_dir=str(workspace),
383
- timeout=AGENT_EXEC_TIMEOUT,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  )
385
- result_str = format_exec_result(result)
386
- if result["success"]:
387
- raw = (result["stdout"] or "").strip()
388
- if raw or result["files"]:
389
- stored = raw
390
- if result["files"]:
391
- stored += f"\nFiles saved: {list(result['files'].keys())}"
392
- exec_outputs.append(stored)
393
- status = "" if result["success"] else "❌"
394
- step_logs.append(f"{status} **Execution**\n```\n{result_str[:1200]}\n```\n")
395
-
396
- context.add_result(result_str)
397
-
398
- if stream:
399
- yield ("progress", step + 1, max_steps, "\n".join(step_logs))
400
-
401
- answer = extract_answer(final_text, exec_outputs)
402
- summary = extract_summary(final_text)
403
-
404
- result = {
405
- "steps_markdown": "\n".join(step_logs),
406
- "final_response": final_text,
407
- "answer": answer,
408
- "summary": summary,
409
- "workspace": str(workspace),
410
- }
411
- if stream:
412
- yield ("final", result)
413
- else:
414
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DataSense E2B — Hugging Face Space demo
3
+ Execution-grounded data agent (SFT v1) with bundled or uploaded CSVs/Excel.
4
+ """
5
 
6
  from __future__ import annotations
7
 
8
+ import html
9
  import re
10
+ import spaces # must be first — before any torch/CUDA import
11
+
 
 
12
  from pathlib import Path
13
+
14
+ import gradio as gr
15
+ import pandas as pd
16
+
17
+ from agent import run_agent
18
+ from config import ADAPTER_MODEL, AGENT_MAX_STEPS, DATA_DIR
19
+ from examples import DEMO_DATASETS, DEMO_EXAMPLES
20
+
21
+ MODEL, TOKENIZER = None, None
22
+ STORY_URL = "https://datasense-e2b.netlify.app/"
23
+ DEMO_VIDEO_URL = "https://youtu.be/ucFoCdMK7sE"
24
+ LINKEDIN_POST_URL = (
25
+ "https://www.linkedin.com/posts/sanjaymalladi_buildsmall-huggingface-modal-share-7471993638814654464-47hY/"
26
+ )
27
+
28
+ CUSTOM_CSS = """
29
+ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&display=swap');
30
+
31
+ .gradio-container {
32
+ --ds-bg: #0c1117;
33
+ --ds-surface: #151c26;
34
+ --ds-border: #2a3544;
35
+ --ds-accent: #3ecfae;
36
+ --ds-accent-dim: #1f6f5c;
37
+ --ds-text: #e8eef5;
38
+ --ds-muted: #8b9cb3;
39
+ max-width: 1280px !important;
40
+ font-family: 'Newsreader', Georgia, serif !important;
41
+ }
42
+ #ds-header {
43
+ background: linear-gradient(135deg, #0f1a24 0%, #122a2a 55%, #0c1117 100%);
44
+ border: 1px solid var(--ds-border);
45
+ border-radius: 16px;
46
+ padding: 1.5rem 1.75rem;
47
+ margin-bottom: 1rem;
48
+ }
49
+ #ds-header h1 {
50
+ font-family: 'Newsreader', Georgia, serif;
51
+ font-size: 2rem;
52
+ font-weight: 600;
53
+ margin: 0 0 0.35rem 0;
54
+ color: var(--ds-text);
55
+ }
56
+ #ds-badge {
57
+ display: inline-block;
58
+ font-family: 'IBM Plex Mono', monospace;
59
+ font-size: 0.72rem;
60
+ letter-spacing: 0.08em;
61
+ text-transform: uppercase;
62
+ color: var(--ds-accent);
63
+ border: 1px solid var(--ds-accent-dim);
64
+ border-radius: 999px;
65
+ padding: 0.2rem 0.65rem;
66
+ margin-bottom: 0.75rem;
67
+ }
68
+ #ds-panel, #ds-results {
69
+ background: var(--ds-surface);
70
+ border: 1px solid var(--ds-border);
71
+ border-radius: 14px;
72
+ padding: 1.1rem;
73
+ min-height: 520px;
74
+ }
75
+ #ds-preview-box {
76
+ margin-top: 0.5rem;
77
+ border: 1px solid var(--ds-border);
78
+ border-radius: 10px;
79
+ overflow: hidden;
80
+ }
81
+ #ds-preview-box .label-wrap { padding: 0.5rem 0.75rem !important; }
82
+ #run-btn {
83
+ background: linear-gradient(90deg, #1f6f5c, #3ecfae) !important;
84
+ border: none !important;
85
+ font-weight: 600 !important;
86
+ letter-spacing: 0.02em;
87
+ }
88
+ #ds-progress-label {
89
+ font-family: 'IBM Plex Mono', monospace;
90
+ font-size: 0.82rem;
91
+ color: var(--ds-accent);
92
+ margin: 0 0 0.35rem 0;
93
+ }
94
+ .ds-progress-wrap {
95
+ margin: 0 0 1rem 0;
96
+ padding: 0.85rem 1rem;
97
+ background: #0f1419;
98
+ border: 1px solid #2a3544;
99
+ border-radius: 10px;
100
+ }
101
+ .ds-progress-text {
102
+ font-family: 'IBM Plex Mono', monospace;
103
+ font-size: 0.82rem;
104
+ color: #3ecfae;
105
+ margin-bottom: 0.55rem;
106
+ line-height: 1.4;
107
+ }
108
+ .ds-progress-text .ds-pct {
109
+ color: #8b9cb3;
110
+ font-size: 0.75rem;
111
+ }
112
+ .ds-progress-track {
113
+ height: 6px;
114
+ background: #1a2330;
115
+ border-radius: 999px;
116
+ overflow: hidden;
117
+ }
118
+ .ds-progress-fill {
119
+ height: 100%;
120
+ background: linear-gradient(90deg, #1f6f5c, #3ecfae);
121
+ border-radius: 999px;
122
+ transition: width 0.35s ease;
123
+ }
124
+ .ds-progress-wrap.ds-idle .ds-progress-track { display: none; }
125
+ #ds-results .tabitem { padding-top: 0.75rem !important; }
126
+ .ds-answer-card {
127
+ background: linear-gradient(145deg, #122a2a 0%, #151c26 100%);
128
+ border: 1px solid #2a3544;
129
+ border-left: 4px solid #3ecfae;
130
+ border-radius: 12px;
131
+ padding: 1.5rem 1.75rem;
132
+ margin: 0;
133
+ min-height: 200px;
134
+ }
135
+ .ds-answer-label {
136
+ font-family: 'IBM Plex Mono', monospace;
137
+ font-size: 0.72rem;
138
+ letter-spacing: 0.1em;
139
+ text-transform: uppercase;
140
+ color: #3ecfae;
141
+ margin-bottom: 0.75rem;
142
+ }
143
+ .ds-answer-value {
144
+ font-family: 'Newsreader', Georgia, serif;
145
+ font-size: 1.35rem;
146
+ font-weight: 400;
147
+ color: #c5d0de;
148
+ line-height: 1.55;
149
+ word-break: break-word;
150
+ }
151
+ .ds-answer-value strong {
152
+ color: #ffffff;
153
+ font-weight: 700;
154
+ }
155
+ .ds-summary {
156
+ margin-top: 1.25rem;
157
+ padding-top: 1rem;
158
+ border-top: 1px solid #2a3544;
159
+ font-size: 1.05rem;
160
+ color: #8b9cb3;
161
+ line-height: 1.55;
162
+ }
163
+ .ds-answer-empty {
164
+ color: #8b9cb3;
165
+ font-style: italic;
166
+ padding: 1rem 0;
167
+ }
168
+ .ds-trace-wrap { margin: 0; padding: 0; }
169
+ .ds-trace-wrap h3 { margin-top: 1rem; color: #3ecfae; font-size: 1rem; }
170
+ footer { visibility: hidden; }
171
+ """
172
+
173
+
174
+ def build_theme() -> gr.Theme:
175
+ return (
176
+ gr.themes.Base(
177
+ primary_hue=gr.themes.colors.emerald,
178
+ secondary_hue=gr.themes.colors.slate,
179
+ neutral_hue=gr.themes.colors.gray,
180
+ font=gr.themes.GoogleFont("Newsreader"),
181
+ font_mono=gr.themes.GoogleFont("IBM Plex Mono"),
182
+ )
183
+ .set(
184
+ body_background_fill="#0c1117",
185
+ body_background_fill_dark="#0c1117",
186
+ block_background_fill="#151c26",
187
+ block_background_fill_dark="#151c26",
188
+ block_border_width="1px",
189
+ block_border_color="#2a3544",
190
+ block_border_color_dark="#2a3544",
191
+ body_text_color="#e8eef5",
192
+ body_text_color_dark="#e8eef5",
193
+ button_primary_background_fill="linear-gradient(90deg, #1f6f5c, #3ecfae)",
194
+ button_primary_background_fill_hover="linear-gradient(90deg, #2a8a72, #4de0c0)",
195
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
 
 
198
 
199
+ def _load_model():
200
+ global MODEL, TOKENIZER
201
+ if MODEL is None or TOKENIZER is None:
202
+ from model_loader import load_model_and_tokenizer
203
 
204
+ MODEL, TOKENIZER = load_model_and_tokenizer()
205
+ return MODEL, TOKENIZER
 
 
206
 
207
 
208
+ def _resolve_data_path(data_mode: str, dataset_name: str, upload_file) -> Path | None:
209
+ if data_mode == "Upload your file":
210
+ if upload_file is None:
211
+ return None
212
+ path_str = upload_file[0] if isinstance(upload_file, list) else upload_file
213
+ if not path_str:
214
+ return None
215
+ path = Path(path_str)
216
+ return path if path.is_file() else None
217
+ return DEMO_DATASETS.get(dataset_name)
218
+
219
+
220
+ def _load_preview(data_mode: str, dataset_name: str, upload_file):
221
+ path = _resolve_data_path(data_mode, dataset_name, upload_file)
222
+ if path is None:
223
+ return pd.DataFrame(), "_Select or upload a file to preview._"
224
+ try:
225
+ suffix = path.suffix.lower()
226
+ if suffix in (".xlsx", ".xls"):
227
+ df = pd.read_excel(path, nrows=100)
228
+ else:
229
+ df = pd.read_csv(path, nrows=100)
230
+ meta = f"**{path.name}** · {len(df)} rows · {len(df.columns)} columns"
231
+ return df, meta
232
+ except Exception as exc:
233
+ return pd.DataFrame(), f"_Could not preview file: {exc}_"
234
+
235
+
236
+ def _inline_markdown_to_html(text: str) -> str:
237
+ """Turn **bold** into <strong>; escape everything else."""
 
 
 
 
238
  if not text:
239
  return ""
240
+ parts = re.split(r"\*\*(.+?)\*\*", text)
241
+ chunks: list[str] = []
242
+ for i, part in enumerate(parts):
243
+ safe = html.escape(part)
244
+ chunks.append(f"<strong>{safe}</strong>" if i % 2 == 1 else safe)
245
+ return "".join(chunks).replace("**", "")
246
+
247
+
248
+ def _format_answer_html(answer: str, summary: str = "") -> str:
249
+ if not answer:
250
+ return '<div class="ds-answer-empty">Could not parse an answer check the execution trace tab.</div>'
251
+ answer_html = _inline_markdown_to_html(answer)
252
+ summary_block = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  if summary:
254
+ summary_block = f'<p class="ds-summary">{_inline_markdown_to_html(summary)}</p>'
255
+ return (
256
+ f'<div class="ds-answer-card">'
257
+ f'<div class="ds-answer-label">Verified answer</div>'
258
+ f'<div class="ds-answer-value">{answer_html}</div>'
259
+ f"{summary_block}"
260
+ f"</div>"
261
+ )
262
+
263
+
264
+ def _toggle_data_inputs(data_mode: str):
265
+ is_upload = data_mode == "Upload your file"
266
+ return (
267
+ gr.update(visible=not is_upload),
268
+ gr.update(visible=is_upload),
269
+ )
270
+
271
+
272
+ def _wrap_trace(trace: str) -> str:
273
+ if not trace:
274
+ return ""
275
+ return f'<div class="ds-trace-wrap">\n\n{trace}\n\n</div>'
276
+
277
+
278
+ def _progress_html(pct: int | None, label: str) -> str:
279
+ safe_label = html.escape(label)
280
+ if pct is None:
281
+ return f'<div class="ds-progress-wrap ds-idle"><div class="ds-progress-text">{safe_label}</div></div>'
282
+ pct = max(0, min(100, pct))
283
+ return (
284
+ f'<div class="ds-progress-wrap">'
285
+ f'<div class="ds-progress-text">{safe_label} <span class="ds-pct">{pct}%</span></div>'
286
+ f'<div class="ds-progress-track"><div class="ds-progress-fill" style="width:{pct}%"></div></div>'
287
+ f"</div>"
288
+ )
289
+
290
+
291
+ def _progress_update(pct: int | None, label: str, trace: str = "", answer: str = ""):
292
+ return (
293
+ _progress_html(pct, label),
294
+ _wrap_trace(trace),
295
+ answer,
296
+ )
297
+
298
 
299
+ IDLE_PROGRESS_HTML = _progress_html(None, "Ready — click Run DataSense")
300
 
301
+
302
+ @spaces.GPU(duration=180)
303
+ def run_task(
304
+ data_mode: str,
305
+ dataset_name: str,
306
+ upload_file,
307
  task: str,
308
+ max_steps: int,
309
+ progress=gr.Progress(),
310
+ ):
311
+ if not task.strip():
312
+ yield _progress_update(None, "Enter a question to run DataSense", "", '<div class="ds-answer-empty">Enter a task question.</div>')
313
+ return
314
+
315
+ yield _progress_update(5, "Reading your dataset…")
316
+ data_path = _resolve_data_path(data_mode, dataset_name, upload_file)
317
+ if data_path is None:
318
+ msg = "⚠️ Upload a `.csv` or `.xlsx` file first." if data_mode == "Upload your file" else f"⚠️ Dataset not found: {dataset_name}"
319
+ yield _progress_update(0, "Dataset missing", "", f'<div class="ds-answer-empty">{html.escape(msg)}</div>')
320
+ return
321
+
322
+ try:
323
+ yield _progress_update(12, "Loading DataSense on GPU…")
324
+ progress(0.15, desc="Loading DataSense…")
325
+ model, tokenizer = _load_model()
326
+
327
+ yield _progress_update(22, "DataSense is starting…")
328
+ agent_stream = run_agent(
329
+ model,
330
+ tokenizer,
331
+ data_path,
332
+ task.strip(),
333
+ max_steps=int(max_steps),
334
+ progress=progress,
335
+ stream=True,
336
+ )
337
+
338
+ for event in agent_stream:
339
+ if event[0] == "progress":
340
+ _, step, total, trace_md = event
341
+ pct = int(22 + (73 * step / max(total, 1)))
342
+ yield _progress_update(
343
+ pct,
344
+ f"DataSense · step {step}/{total}",
345
+ trace_md,
346
+ "",
347
+ )
348
+ elif event[0] == "final":
349
+ result = event[1]
350
+ answer_html = _format_answer_html(result.get("answer", ""), result.get("summary", ""))
351
+ yield _progress_update(
352
+ 100,
353
+ "DataSense finished",
354
+ result["steps_markdown"],
355
+ answer_html,
356
+ )
357
+ except Exception as exc:
358
+ yield _progress_update(0, "Error", "", f'<div class="ds-answer-empty">Error: {html.escape(str(exc))}</div>')
359
+
360
+
361
+ @spaces.GPU(duration=300)
362
+ def preload_model():
363
+ _load_model()
364
+
365
+
366
+ def build_ui() -> gr.Blocks:
367
+ dataset_choices = list(DEMO_DATASETS.keys())
368
+ default_df, default_meta = _load_preview("Bundled examples", dataset_choices[0], None)
369
+
370
+ with gr.Blocks(title="DataSense E2B") as demo:
371
+ with gr.Column(elem_id="ds-header"):
372
+ gr.HTML('<div id="ds-badge">Execution-verified · Gemma / DataBench</div>')
373
+ gr.Markdown(
374
+ """
375
+ # DataSense E2B
376
+ **Live inference** — Gemma-4 2B + SFT v1 writes Python, runs it on your data, reads real stdout/errors.
377
+ """
378
  )
379
+ gr.Markdown(
380
+ f"📖 [Full project story]({STORY_URL}) · "
381
+ f"🎬 [Demo video]({DEMO_VIDEO_URL}) · "
382
+ f"[LinkedIn post]({LINKEDIN_POST_URL}) · "
383
+ f"LoRA [`DataSense-Modal-E2B-SFT`](https://huggingface.co/{ADAPTER_MODEL})",
384
+ )
385
+
386
+ with gr.Row(equal_height=False):
387
+ with gr.Column(scale=5, elem_id="ds-panel"):
388
+ gr.Markdown("### Configure")
389
+ data_mode = gr.Radio(
390
+ choices=["Bundled examples", "Upload your file"],
391
+ value="Bundled examples",
392
+ label="Data source",
393
+ )
394
+ dataset = gr.Dropdown(
395
+ choices=dataset_choices,
396
+ value=dataset_choices[0],
397
+ label="Demo dataset",
398
+ )
399
+ upload = gr.File(
400
+ label="Your CSV or Excel file",
401
+ file_types=[".csv", ".xlsx", ".xls"],
402
+ type="filepath",
403
+ visible=False,
404
+ )
405
+
406
+ gr.Markdown("### Data preview")
407
+ preview_meta = gr.Markdown(default_meta)
408
+ with gr.Group(elem_id="ds-preview-box"):
409
+ preview_df = gr.Dataframe(
410
+ value=default_df,
411
+ interactive=False,
412
+ wrap=True,
413
+ max_height=280,
414
+ )
415
+
416
+ task = gr.Textbox(
417
+ label="Question / task",
418
+ placeholder="e.g. Which product had the highest total revenue?",
419
+ lines=3,
420
+ )
421
+ max_steps = gr.Slider(
422
+ minimum=3,
423
+ maximum=12,
424
+ value=AGENT_MAX_STEPS,
425
+ step=1,
426
+ label="Max agent steps",
427
+ )
428
+ run_btn = gr.Button("▶ Run DataSense", variant="primary", elem_id="run-btn")
429
+
430
+ gr.Examples(
431
+ examples=DEMO_EXAMPLES,
432
+ inputs=[dataset, task],
433
+ label="Quick examples (bundled data)",
434
+ )
435
+
436
+ with gr.Column(scale=7, elem_id="ds-results"):
437
+ gr.Markdown("### Results")
438
+ progress_out = gr.HTML(value=IDLE_PROGRESS_HTML)
439
+ with gr.Tabs():
440
+ with gr.Tab("🔍 Execution trace", id="trace_tab"):
441
+ steps_out = gr.Markdown()
442
+ with gr.Tab("✅ Answer", id="answer_tab"):
443
+ answer_out = gr.HTML()
444
+
445
+ preview_inputs = [data_mode, dataset, upload]
446
+ data_mode.change(_toggle_data_inputs, data_mode, [dataset, upload]).then(
447
+ _load_preview, preview_inputs, [preview_df, preview_meta]
448
+ )
449
+ dataset.change(_load_preview, preview_inputs, [preview_df, preview_meta])
450
+ upload.change(_load_preview, preview_inputs, [preview_df, preview_meta])
451
+
452
+ run_btn.click(
453
+ fn=run_task,
454
+ inputs=[data_mode, dataset, upload, task, max_steps],
455
+ outputs=[progress_out, steps_out, answer_out],
456
+ show_progress="hidden",
457
+ )
458
+
459
+ demo.load(_load_preview, preview_inputs, [preview_df, preview_meta])
460
+
461
+ return demo
462
+
463
+
464
+ try:
465
+ preload_model()
466
+ except Exception as exc:
467
+ print(f"Startup preload skipped (will load on first run): {exc}")
468
+
469
+ demo = build_ui()
470
+
471
+ if __name__ == "__main__":
472
+ demo.queue(max_size=8).launch(theme=build_theme(), css=CUSTOM_CSS)