DanielRegaladoCardoso commited on
Commit
9d32d56
·
verified ·
1 Parent(s): b945711

UI v2: progress streaming, demo dataset, suggestions, polished CSS for Gradio 5

Browse files
Files changed (1) hide show
  1. app.py +344 -157
app.py CHANGED
@@ -1,21 +1,19 @@
1
  """
2
  SQL Agent — Gradio app for Hugging Face Spaces (ZeroGPU).
3
 
4
- Apple x Claude minimalist design: single column, generous whitespace, warm
5
- accent, monochrome ink, SF font stack, no chrome.
6
-
7
- Entry point file at the repo root because that's the HF Spaces convention.
8
  """
9
 
 
10
  import logging
11
  import os
12
  import sys
13
  from pathlib import Path
14
- from typing import Optional, Tuple
15
 
16
  import pandas as pd
17
 
18
- # Ensure src/ is importable when run from repo root
19
  ROOT = Path(__file__).parent
20
  sys.path.insert(0, str(ROOT))
21
 
@@ -23,9 +21,6 @@ import gradio as gr # noqa: E402
23
 
24
  from src.orchestrator.pipeline import SQLAgentOrchestrator # noqa: E402
25
 
26
- # spaces is a Hugging Face SDK that exposes @spaces.GPU. It only exists on
27
- # HF Spaces with ZeroGPU enabled, so we degrade gracefully when running
28
- # locally.
29
  try:
30
  import spaces # type: ignore
31
  HAS_SPACES = True
@@ -45,7 +40,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(
45
  logger = logging.getLogger(__name__)
46
 
47
 
48
- # ---------------------------------------------------------------- styling
49
  THEME_CSS = """
50
  :root {
51
  --ink: #0E0E0E;
@@ -58,7 +53,7 @@ THEME_CSS = """
58
  --radius: 16px;
59
  --radius-sm: 10px;
60
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
61
- --shadow-md: 0 4px 16px rgba(0,0,0,0.06);
62
  --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
63
  "Helvetica Neue", Arial, sans-serif;
64
  --font-mono: "SF Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -74,72 +69,75 @@ THEME_CSS = """
74
  --accent: #E8866A;
75
  --accent-soft: rgba(232, 134, 106, 0.10);
76
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
77
- --shadow-md: 0 4px 16px rgba(0,0,0,0.35);
78
  }
79
  }
80
 
81
- /* Reset Gradio chrome */
 
 
 
 
 
82
  .gradio-container {
83
  max-width: 760px !important;
84
  margin: 0 auto !important;
85
- padding: 32px 24px 80px !important;
86
- background: var(--surface) !important;
87
- font-family: var(--font) !important;
88
- color: var(--ink) !important;
89
  }
90
- body, .gradio-container, .main, footer { background: var(--surface) !important; }
91
  footer { display: none !important; }
 
92
 
93
  /* Header */
94
  .app-header {
95
- display: flex;
96
- align-items: center;
97
- justify-content: space-between;
98
- margin-bottom: 32px;
99
  padding-bottom: 20px;
100
  border-bottom: 1px solid var(--ink-faint);
 
 
 
 
101
  }
102
- .app-title {
103
- font-size: 17px;
104
- font-weight: 600;
105
- letter-spacing: -0.015em;
106
- color: var(--ink);
107
- }
108
- .app-subtitle {
109
- font-size: 13px;
110
- color: var(--ink-muted);
111
- margin-top: 2px;
112
- }
113
-
114
- /* Cards */
115
- .card {
116
- background: var(--surface-raised);
117
- border: 1px solid var(--ink-faint);
118
- border-radius: var(--radius);
119
- padding: 20px;
120
- margin-bottom: 16px;
121
- box-shadow: var(--shadow-sm);
122
- transition: box-shadow 200ms ease, border-color 200ms ease;
123
- }
124
- .card:hover { box-shadow: var(--shadow-md); }
125
 
126
- /* Upload area */
127
- .upload-zone {
 
 
128
  border: 1.5px dashed var(--ink-faint) !important;
129
  border-radius: var(--radius) !important;
 
130
  background: transparent !important;
131
- padding: 32px 20px !important;
132
- text-align: center !important;
133
- transition: border-color 200ms ease, background 200ms ease !important;
134
  }
135
- .upload-zone:hover {
136
  border-color: var(--accent) !important;
137
  background: var(--accent-soft) !important;
138
  }
139
- .upload-zone .icon-wrap, .upload-zone svg { color: var(--ink-muted) !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
- /* Inputs */
142
- input, textarea, .gr-input, .gr-text-input textarea {
 
143
  background: var(--surface-raised) !important;
144
  border: 1px solid var(--ink-faint) !important;
145
  border-radius: var(--radius-sm) !important;
@@ -149,12 +147,18 @@ input, textarea, .gr-input, .gr-text-input textarea {
149
  padding: 14px 16px !important;
150
  box-shadow: none !important;
151
  transition: border-color 150ms ease !important;
 
 
 
 
 
 
152
  }
153
- input:focus, textarea:focus { border-color: var(--accent) !important; outline: none !important; }
154
- ::placeholder { color: var(--ink-muted) !important; }
155
 
156
  /* Buttons */
157
- button.primary, .gr-button-primary {
158
  background: var(--ink) !important;
159
  color: var(--surface) !important;
160
  border: none !important;
@@ -166,8 +170,8 @@ button.primary, .gr-button-primary {
166
  transition: opacity 150ms ease !important;
167
  box-shadow: none !important;
168
  }
169
- button.primary:hover, .gr-button-primary:hover { opacity: 0.85 !important; }
170
- button.secondary, .gr-button-secondary {
171
  background: transparent !important;
172
  color: var(--ink) !important;
173
  border: 1px solid var(--ink-faint) !important;
@@ -177,35 +181,56 @@ button.secondary, .gr-button-secondary {
177
  }
178
 
179
  /* Conversation */
180
- .turn { margin: 28px 0; }
 
181
  .turn-question {
182
  font-size: 16px;
183
  color: var(--ink);
184
  font-weight: 500;
185
  margin-bottom: 14px;
186
  letter-spacing: -0.01em;
 
187
  }
188
- .turn-status {
 
 
 
189
  font-size: 13px;
190
  color: var(--ink-muted);
191
- font-style: italic;
192
- margin: 6px 0 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  }
194
  .turn-error {
195
  background: var(--accent-soft);
196
- border-left: 2px solid var(--accent);
197
  color: var(--accent);
198
- padding: 10px 14px;
199
  border-radius: var(--radius-sm);
200
  font-size: 13px;
201
  margin: 6px 0;
 
202
  }
 
203
  .chart-wrap {
204
  background: var(--surface-raised);
205
  border: 1px solid var(--ink-faint);
206
  border-radius: var(--radius);
207
  padding: 24px;
208
- margin: 8px 0 12px;
209
  box-shadow: var(--shadow-sm);
210
  }
211
  .chart-wrap svg { width: 100% !important; height: auto !important; display: block; }
@@ -216,13 +241,13 @@ button.secondary, .gr-button-secondary {
216
  border: 1px solid var(--ink-faint);
217
  border-radius: var(--radius-sm);
218
  font-family: var(--font-mono);
219
- font-size: 13px;
220
  color: var(--ink);
221
  padding: 14px 16px;
222
  overflow-x: auto;
223
- white-space: pre;
224
- margin: 6px 0;
225
- line-height: 1.55;
226
  }
227
 
228
  /* Details / collapsibles */
@@ -235,7 +260,7 @@ details {
235
  details summary {
236
  cursor: pointer;
237
  padding: 10px 14px;
238
- font-size: 13px;
239
  color: var(--ink-muted);
240
  list-style: none;
241
  user-select: none;
@@ -245,7 +270,8 @@ details summary::-webkit-details-marker { display: none; }
245
  details summary::before {
246
  content: "›";
247
  display: inline-block;
248
- margin-right: 8px;
 
249
  transition: transform 150ms ease;
250
  color: var(--ink-muted);
251
  }
@@ -265,6 +291,7 @@ details > *:not(summary) { padding: 0 14px 14px; }
265
  color: var(--ink);
266
  padding: 8px 10px;
267
  border-bottom: 1px solid var(--ink-faint);
 
268
  }
269
  .data-table td {
270
  padding: 7px 10px;
@@ -272,36 +299,84 @@ details > *:not(summary) { padding: 0 14px 14px; }
272
  border-bottom: 1px solid var(--ink-faint);
273
  }
274
  .data-table tr:last-child td { border-bottom: none; }
 
275
 
276
- /* File chip */
277
- .file-chip {
278
- display: inline-flex;
279
- align-items: center;
280
- gap: 8px;
281
- padding: 8px 14px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  background: var(--surface-raised);
283
  border: 1px solid var(--ink-faint);
284
- border-radius: 999px;
 
 
 
 
 
 
 
 
 
285
  font-size: 13px;
 
286
  color: var(--ink);
 
 
 
 
 
287
  }
288
- .file-chip-meta { color: var(--ink-muted); }
289
 
290
- /* Empty state */
291
- .empty {
292
- text-align: center;
 
 
 
 
 
 
 
 
 
 
 
293
  color: var(--ink-muted);
294
- font-size: 14px;
295
- padding: 60px 20px;
 
 
 
296
  }
297
 
298
- /* Hide Gradio labels */
299
- .gr-form > label, .gr-block > label, label.svelte-1gfkn6j { display: none !important; }
300
  """
301
 
302
 
303
- # ---------------------------------------------------------- orchestrator
304
- # Single global orchestrator. Models load lazily inside @spaces.GPU.
305
  _AGENT: Optional[SQLAgentOrchestrator] = None
306
 
307
 
@@ -312,38 +387,89 @@ def get_agent() -> SQLAgentOrchestrator:
312
  return _AGENT
313
 
314
 
315
- # --------------------------------------------------------------- helpers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  def _file_chip_html(filename: str, rows: int, cols: int) -> str:
317
  return (
318
- f'<div class="file-chip">'
 
319
  f'<span>{filename}</span>'
320
- f'<span class="file-chip-meta">· {rows:,} rows · {cols} cols</span>'
321
- f'</div>'
322
  )
323
 
324
 
 
 
 
 
 
 
 
 
 
 
325
  def _data_table_html(rows: list[dict], max_rows: int = 10) -> str:
326
  if not rows:
327
- return '<div class="empty">No rows.</div>'
328
  df = pd.DataFrame(rows[:max_rows])
329
  cols = df.columns.tolist()
330
  head = "".join(f"<th>{c}</th>" for c in cols)
331
  body = "".join(
332
- "<tr>" + "".join(f"<td>{r.get(c, '')}</td>" for c in cols) + "</tr>"
 
 
333
  for r in rows[:max_rows]
334
  )
335
  note = (
336
- f'<div style="font-size:11px;color:var(--ink-muted);'
337
- f'margin-top:8px">Showing {min(max_rows, len(rows))} of {len(rows):,} rows</div>'
338
  if len(rows) > max_rows else ""
339
  )
340
  return f'<table class="data-table"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>{note}'
341
 
342
 
343
- def _turn_html(result: dict) -> str:
344
- """Render one full agent turn: chart, then collapsible SQL + data."""
345
- parts: list[str] = []
346
- parts.append(f'<div class="turn-question">{result["question"]}</div>')
 
 
 
 
 
 
 
 
 
347
 
348
  if result.get("error"):
349
  parts.append(f'<div class="turn-error">{result["error"]}</div>')
@@ -353,7 +479,7 @@ def _turn_html(result: dict) -> str:
353
 
354
  if result.get("sql"):
355
  parts.append(
356
- '<details><summary>SQL</summary>'
357
  f'<pre class="sql-block">{result["sql"]}</pre>'
358
  '</details>'
359
  )
@@ -368,76 +494,125 @@ def _turn_html(result: dict) -> str:
368
  return f'<div class="turn">{"".join(parts)}</div>'
369
 
370
 
371
- def _conversation_html(history: list[dict]) -> str:
372
- if not history:
373
- return (
374
- '<div class="empty">'
375
- 'Upload a CSV or JSON file and ask anything about your data.'
376
- '</div>'
377
- )
378
- return "".join(_turn_html(t) for t in history)
379
 
380
 
381
- # ------------------------------------------------------------ event flow
382
- def on_upload(file) -> Tuple[str, str]:
383
- """File uploaded -> register in DuckDB, return chip HTML + status."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  if file is None:
385
- return "", "No file."
386
  agent = get_agent()
387
  agent.reset()
388
  try:
389
  path = Path(file.name if hasattr(file, "name") else file)
390
  table = agent.load_data(path)
391
- sample = agent.sample(table, 1)
392
- rows = len(agent.executor.con.execute(f'SELECT * FROM "{table}"').fetchall())
393
- cols = len(sample.columns)
394
- return _file_chip_html(path.name, rows, cols), f"Ready: table “{table}”."
 
 
 
 
 
 
395
  except Exception as e:
396
  logger.exception("upload failed")
397
- return "", f"Could not load file: {e}"
398
 
399
 
400
- @spaces.GPU(duration=120)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  def _gpu_process(question: str) -> dict:
402
- """The actual GPU-bound call. Models are loaded lazily inside this scope."""
403
  agent = get_agent()
404
  return agent.process(question)
405
 
406
 
407
- def on_ask(question: str, history: list) -> Tuple[str, str, list]:
408
- """Submit a question -> run pipeline -> append to history -> render."""
 
 
 
409
  history = history or []
410
  question = (question or "").strip()
411
  if not question:
412
- return _conversation_html(history), "", history
 
 
413
  if not get_agent().list_tables():
414
  history.append({
415
  "question": question,
416
- "error": "Upload a CSV or JSON file first.",
417
  })
418
- return _conversation_html(history), "", history
 
 
 
 
419
 
420
  try:
 
421
  result = _gpu_process(question)
422
  except Exception as e:
423
  logger.exception("ask failed")
424
  result = {"question": question, "error": str(e)}
425
 
426
  history.append(result)
427
- return _conversation_html(history), "", history
428
 
429
 
430
- def on_reset() -> Tuple[str, str, list]:
431
  get_agent().reset()
432
- return "", "", []
433
 
434
 
435
- # ------------------------------------------------------------------ app
436
  def build_app() -> gr.Blocks:
437
  with gr.Blocks(
438
- theme=gr.themes.Base(
439
- font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
440
- ),
441
  css=THEME_CSS,
442
  title="SQL Agent",
443
  analytics_enabled=False,
@@ -452,37 +627,53 @@ def build_app() -> gr.Blocks:
452
  '</div>'
453
  )
454
 
455
- # Upload card
456
- upload = gr.File(
457
- label="",
458
- file_types=[".csv", ".json", ".parquet", ".xlsx", ".xls"],
459
- elem_classes=["upload-zone"],
460
- )
 
 
461
  chip_html = gr.HTML("")
462
- upload_status = gr.Markdown("", visible=False)
463
-
464
- # Question input
465
- question = gr.Textbox(
466
- placeholder="Ask anything about your data…",
467
- lines=2,
468
- max_lines=8,
469
- show_label=False,
470
- container=False,
471
- )
 
 
 
472
  with gr.Row():
473
  ask_btn = gr.Button("Ask", variant="primary", size="sm")
474
  reset_btn = gr.Button("Clear", variant="secondary", size="sm")
 
 
475
 
476
  # Conversation
477
  history_state = gr.State([])
478
  conversation = gr.HTML(_conversation_html([]))
479
 
480
- # Wire events. api_name=False on each event skips JSON-schema api
481
- # introspection which crashes on Dict[str, Any] returns in gradio<5.
482
  upload.upload(
483
  fn=on_upload,
484
  inputs=upload,
485
- outputs=[chip_html, upload_status],
 
 
 
 
 
 
 
 
 
 
486
  api_name=False,
487
  )
488
  ask_btn.click(
@@ -499,11 +690,7 @@ def build_app() -> gr.Blocks:
499
  )
500
  reset_btn.click(
501
  fn=on_reset,
502
- outputs=[chip_html, question, history_state],
503
- api_name=False,
504
- ).then(
505
- fn=lambda: _conversation_html([]),
506
- outputs=conversation,
507
  api_name=False,
508
  )
509
 
 
1
  """
2
  SQL Agent — Gradio app for Hugging Face Spaces (ZeroGPU).
3
 
4
+ Apple x Claude minimalist design with progressive feedback during the
5
+ multi-step pipeline.
 
 
6
  """
7
 
8
+ import io
9
  import logging
10
  import os
11
  import sys
12
  from pathlib import Path
13
+ from typing import Generator, Optional, Tuple
14
 
15
  import pandas as pd
16
 
 
17
  ROOT = Path(__file__).parent
18
  sys.path.insert(0, str(ROOT))
19
 
 
21
 
22
  from src.orchestrator.pipeline import SQLAgentOrchestrator # noqa: E402
23
 
 
 
 
24
  try:
25
  import spaces # type: ignore
26
  HAS_SPACES = True
 
40
  logger = logging.getLogger(__name__)
41
 
42
 
43
+ # ============================================================ THEME / CSS
44
  THEME_CSS = """
45
  :root {
46
  --ink: #0E0E0E;
 
53
  --radius: 16px;
54
  --radius-sm: 10px;
55
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
56
+ --shadow-md: 0 6px 24px rgba(0,0,0,0.08);
57
  --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
58
  "Helvetica Neue", Arial, sans-serif;
59
  --font-mono: "SF Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
 
69
  --accent: #E8866A;
70
  --accent-soft: rgba(232, 134, 106, 0.10);
71
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
72
+ --shadow-md: 0 6px 24px rgba(0,0,0,0.45);
73
  }
74
  }
75
 
76
+ /* Gradio container reset (Gradio 5 selectors) */
77
+ gradio-app, .gradio-container, .main, .app, .contain, .wrap {
78
+ background: var(--surface) !important;
79
+ color: var(--ink) !important;
80
+ font-family: var(--font) !important;
81
+ }
82
  .gradio-container {
83
  max-width: 760px !important;
84
  margin: 0 auto !important;
85
+ padding: 36px 24px 100px !important;
 
 
 
86
  }
 
87
  footer { display: none !important; }
88
+ .show-api { display: none !important; }
89
 
90
  /* Header */
91
  .app-header {
92
+ margin-bottom: 28px;
 
 
 
93
  padding-bottom: 20px;
94
  border-bottom: 1px solid var(--ink-faint);
95
+ display: flex;
96
+ align-items: baseline;
97
+ justify-content: space-between;
98
+ gap: 16px;
99
  }
100
+ .app-title { font-size: 18px; font-weight: 600; letter-spacing: -0.015em; }
101
+ .app-subtitle { font-size: 13px; color: var(--ink-muted); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
+ /* File upload — compact, Apple-style */
104
+ .upload-row { margin-bottom: 18px; }
105
+ .upload-row .gr-file, .upload-row .file-preview { background: transparent !important; }
106
+ .upload-row [data-testid="file"] {
107
  border: 1.5px dashed var(--ink-faint) !important;
108
  border-radius: var(--radius) !important;
109
+ padding: 24px 16px !important;
110
  background: transparent !important;
111
+ transition: all 200ms ease !important;
 
 
112
  }
113
+ .upload-row [data-testid="file"]:hover {
114
  border-color: var(--accent) !important;
115
  background: var(--accent-soft) !important;
116
  }
117
+ .upload-row [data-testid="file"] * { color: var(--ink-muted) !important; }
118
+
119
+ /* File chip (after upload) */
120
+ .file-chip {
121
+ display: inline-flex;
122
+ align-items: center;
123
+ gap: 10px;
124
+ padding: 8px 14px 8px 12px;
125
+ background: var(--surface-raised);
126
+ border: 1px solid var(--ink-faint);
127
+ border-radius: 999px;
128
+ font-size: 13px;
129
+ color: var(--ink);
130
+ }
131
+ .file-chip-meta { color: var(--ink-muted); font-size: 12px; }
132
+ .file-chip-dot {
133
+ width: 6px; height: 6px;
134
+ background: var(--accent);
135
+ border-radius: 50%;
136
+ }
137
 
138
+ /* Question input */
139
+ .question-row { margin: 14px 0 8px; }
140
+ textarea, .gr-text-input textarea, [data-testid="textbox"] textarea {
141
  background: var(--surface-raised) !important;
142
  border: 1px solid var(--ink-faint) !important;
143
  border-radius: var(--radius-sm) !important;
 
147
  padding: 14px 16px !important;
148
  box-shadow: none !important;
149
  transition: border-color 150ms ease !important;
150
+ line-height: 1.5 !important;
151
+ }
152
+ textarea:focus, [data-testid="textbox"] textarea:focus {
153
+ border-color: var(--accent) !important;
154
+ outline: none !important;
155
+ box-shadow: 0 0 0 3px var(--accent-soft) !important;
156
  }
157
+ textarea::placeholder { color: var(--ink-muted) !important; }
158
+ .kb-hint { font-size: 11px; color: var(--ink-muted); margin: 4px 4px 0; }
159
 
160
  /* Buttons */
161
+ button.primary, button[variant="primary"], .gr-button.primary {
162
  background: var(--ink) !important;
163
  color: var(--surface) !important;
164
  border: none !important;
 
170
  transition: opacity 150ms ease !important;
171
  box-shadow: none !important;
172
  }
173
+ button.primary:hover { opacity: 0.85 !important; }
174
+ button.secondary, button[variant="secondary"] {
175
  background: transparent !important;
176
  color: var(--ink) !important;
177
  border: 1px solid var(--ink-faint) !important;
 
181
  }
182
 
183
  /* Conversation */
184
+ .turn { margin: 32px 0; }
185
+ .turn:first-child { margin-top: 16px; }
186
  .turn-question {
187
  font-size: 16px;
188
  color: var(--ink);
189
  font-weight: 500;
190
  margin-bottom: 14px;
191
  letter-spacing: -0.01em;
192
+ line-height: 1.5;
193
  }
194
+ .turn-progress {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 10px;
198
  font-size: 13px;
199
  color: var(--ink-muted);
200
+ padding: 12px 16px;
201
+ background: var(--surface-raised);
202
+ border: 1px solid var(--ink-faint);
203
+ border-radius: var(--radius-sm);
204
+ margin: 6px 0;
205
+ }
206
+ .turn-progress::before {
207
+ content: "";
208
+ width: 8px; height: 8px;
209
+ background: var(--accent);
210
+ border-radius: 50%;
211
+ animation: pulse 1.2s ease-in-out infinite;
212
+ }
213
+ @keyframes pulse {
214
+ 0%, 100% { opacity: 0.3; transform: scale(1); }
215
+ 50% { opacity: 1; transform: scale(1.3); }
216
  }
217
  .turn-error {
218
  background: var(--accent-soft);
219
+ border-left: 3px solid var(--accent);
220
  color: var(--accent);
221
+ padding: 12px 14px;
222
  border-radius: var(--radius-sm);
223
  font-size: 13px;
224
  margin: 6px 0;
225
+ font-family: var(--font-mono);
226
  }
227
+
228
  .chart-wrap {
229
  background: var(--surface-raised);
230
  border: 1px solid var(--ink-faint);
231
  border-radius: var(--radius);
232
  padding: 24px;
233
+ margin: 8px 0 14px;
234
  box-shadow: var(--shadow-sm);
235
  }
236
  .chart-wrap svg { width: 100% !important; height: auto !important; display: block; }
 
241
  border: 1px solid var(--ink-faint);
242
  border-radius: var(--radius-sm);
243
  font-family: var(--font-mono);
244
+ font-size: 12.5px;
245
  color: var(--ink);
246
  padding: 14px 16px;
247
  overflow-x: auto;
248
+ white-space: pre-wrap;
249
+ margin: 6px 0 0;
250
+ line-height: 1.6;
251
  }
252
 
253
  /* Details / collapsibles */
 
260
  details summary {
261
  cursor: pointer;
262
  padding: 10px 14px;
263
+ font-size: 12.5px;
264
  color: var(--ink-muted);
265
  list-style: none;
266
  user-select: none;
 
270
  details summary::before {
271
  content: "›";
272
  display: inline-block;
273
+ width: 12px;
274
+ margin-right: 4px;
275
  transition: transform 150ms ease;
276
  color: var(--ink-muted);
277
  }
 
291
  color: var(--ink);
292
  padding: 8px 10px;
293
  border-bottom: 1px solid var(--ink-faint);
294
+ white-space: nowrap;
295
  }
296
  .data-table td {
297
  padding: 7px 10px;
 
299
  border-bottom: 1px solid var(--ink-faint);
300
  }
301
  .data-table tr:last-child td { border-bottom: none; }
302
+ .data-table-meta { font-size: 11px; color: var(--ink-muted); margin-top: 8px; padding: 0 4px; }
303
 
304
+ /* Empty state */
305
+ .empty {
306
+ padding: 40px 0 8px;
307
+ text-align: center;
308
+ }
309
+ .empty-title {
310
+ font-size: 15px;
311
+ color: var(--ink);
312
+ font-weight: 500;
313
+ margin-bottom: 6px;
314
+ }
315
+ .empty-sub {
316
+ font-size: 13px;
317
+ color: var(--ink-muted);
318
+ margin-bottom: 28px;
319
+ }
320
+ .example-grid {
321
+ display: grid;
322
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
323
+ gap: 10px;
324
+ max-width: 580px;
325
+ margin: 0 auto;
326
+ }
327
+ .example-card {
328
+ text-align: left;
329
+ padding: 14px 16px;
330
  background: var(--surface-raised);
331
  border: 1px solid var(--ink-faint);
332
+ border-radius: var(--radius-sm);
333
+ cursor: pointer;
334
+ transition: all 150ms ease;
335
+ }
336
+ .example-card:hover {
337
+ border-color: var(--accent);
338
+ background: var(--accent-soft);
339
+ transform: translateY(-1px);
340
+ }
341
+ .example-card-title {
342
  font-size: 13px;
343
+ font-weight: 500;
344
  color: var(--ink);
345
+ margin-bottom: 4px;
346
+ }
347
+ .example-card-meta {
348
+ font-size: 11px;
349
+ color: var(--ink-muted);
350
  }
 
351
 
352
+ /* Suggestions */
353
+ .suggestions {
354
+ display: flex;
355
+ flex-wrap: wrap;
356
+ gap: 6px;
357
+ margin: 14px 0 0;
358
+ }
359
+ .suggestion-chip {
360
+ font-size: 12px;
361
+ padding: 6px 12px;
362
+ background: var(--surface-raised);
363
+ border: 1px solid var(--ink-faint);
364
+ border-radius: 999px;
365
+ cursor: pointer;
366
  color: var(--ink-muted);
367
+ transition: all 150ms ease;
368
+ }
369
+ .suggestion-chip:hover {
370
+ border-color: var(--accent);
371
+ color: var(--ink);
372
  }
373
 
374
+ /* Hide labels Gradio adds */
375
+ .gr-form > label, label.svelte-1gfkn6j, .label-wrap { display: none !important; }
376
  """
377
 
378
 
379
+ # ===================================================== ORCHESTRATOR (lazy)
 
380
  _AGENT: Optional[SQLAgentOrchestrator] = None
381
 
382
 
 
387
  return _AGENT
388
 
389
 
390
+ # =================================================== EXAMPLE DATA (built-in)
391
+ def _make_titanic_csv() -> Path:
392
+ """Tiny embedded Titanic-like sample so first-time users can play with no upload."""
393
+ p = ROOT / "_examples" / "titanic.csv"
394
+ if p.exists():
395
+ return p
396
+ p.parent.mkdir(parents=True, exist_ok=True)
397
+ df = pd.DataFrame({
398
+ "passenger_id": range(1, 21),
399
+ "survived": [0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1],
400
+ "pclass": [3, 1, 3, 1, 3, 3, 1, 3, 3, 2, 3, 1, 3, 3, 3, 2, 3, 2, 3, 3],
401
+ "sex": ["male","female","female","female","male","male","male","male","female","female",
402
+ "female","female","male","male","female","female","male","female","male","female"],
403
+ "age": [22,38,26,35,35,None,54,2,27,14,4,58,20,39,14,55,2,None,31,None],
404
+ "fare": [7.25,71.28,7.92,53.10,8.05,8.46,51.86,21.07,11.13,30.07,
405
+ 16.70,26.55,8.05,31.27,7.85,16.00,29.13,13.00,18.00,7.23],
406
+ "embarked": ["S","C","S","S","S","Q","S","S","S","C","S","S","S","S","Q","S","Q","S","S","Q"],
407
+ })
408
+ df.to_csv(p, index=False)
409
+ return p
410
+
411
+
412
+ SUGGESTED_QUESTIONS = [
413
+ "What's the survival rate by passenger class?",
414
+ "Average fare by embarkation port",
415
+ "Top 5 oldest passengers who survived",
416
+ "Count of male vs female survivors",
417
+ ]
418
+
419
+
420
+ # =================================================== HTML render helpers
421
  def _file_chip_html(filename: str, rows: int, cols: int) -> str:
422
  return (
423
+ '<div class="file-chip">'
424
+ '<span class="file-chip-dot"></span>'
425
  f'<span>{filename}</span>'
426
+ f'<span class="file-chip-meta">{rows:,} rows · {cols} cols</span>'
427
+ '</div>'
428
  )
429
 
430
 
431
+ def _suggestions_html(qs: list[str]) -> str:
432
+ if not qs:
433
+ return ""
434
+ chips = "".join(
435
+ f'<span class="suggestion-chip" onclick="document.querySelector(\'textarea\').value=this.textContent;document.querySelector(\'textarea\').focus();">{q}</span>'
436
+ for q in qs
437
+ )
438
+ return f'<div class="suggestions">{chips}</div>'
439
+
440
+
441
  def _data_table_html(rows: list[dict], max_rows: int = 10) -> str:
442
  if not rows:
443
+ return '<div class="empty-sub" style="padding:8px 0">No rows.</div>'
444
  df = pd.DataFrame(rows[:max_rows])
445
  cols = df.columns.tolist()
446
  head = "".join(f"<th>{c}</th>" for c in cols)
447
  body = "".join(
448
+ "<tr>" + "".join(
449
+ f"<td>{('' if r.get(c) is None else r.get(c, ''))}</td>" for c in cols
450
+ ) + "</tr>"
451
  for r in rows[:max_rows]
452
  )
453
  note = (
454
+ f'<div class="data-table-meta">Showing {min(max_rows, len(rows))} of {len(rows):,} rows</div>'
 
455
  if len(rows) > max_rows else ""
456
  )
457
  return f'<table class="data-table"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>{note}'
458
 
459
 
460
+ def _turn_html_progress(question: str, status: str) -> str:
461
+ """Render a turn that's still in progress (with animated indicator)."""
462
+ return (
463
+ '<div class="turn">'
464
+ f'<div class="turn-question">{question}</div>'
465
+ f'<div class="turn-progress">{status}</div>'
466
+ '</div>'
467
+ )
468
+
469
+
470
+ def _turn_html_complete(result: dict) -> str:
471
+ """Render a finished turn."""
472
+ parts: list[str] = [f'<div class="turn-question">{result["question"]}</div>']
473
 
474
  if result.get("error"):
475
  parts.append(f'<div class="turn-error">{result["error"]}</div>')
 
479
 
480
  if result.get("sql"):
481
  parts.append(
482
+ '<details><summary>SQL query</summary>'
483
  f'<pre class="sql-block">{result["sql"]}</pre>'
484
  '</details>'
485
  )
 
494
  return f'<div class="turn">{"".join(parts)}</div>'
495
 
496
 
497
+ def _conversation_html(history: list[dict], in_progress: tuple[str, str] | None = None) -> str:
498
+ out = "".join(_turn_html_complete(t) for t in history)
499
+ if in_progress:
500
+ out += _turn_html_progress(in_progress[0], in_progress[1])
501
+ if not out:
502
+ return _empty_state_html()
503
+ return out
 
504
 
505
 
506
+ def _empty_state_html() -> str:
507
+ suggestions = _suggestions_html([
508
+ "Try the Titanic example below",
509
+ "Or upload your own CSV/JSON above",
510
+ ])
511
+ return (
512
+ '<div class="empty">'
513
+ '<div class="empty-title">No data yet</div>'
514
+ '<div class="empty-sub">Upload a CSV, JSON, Parquet or Excel file, or load the demo dataset.</div>'
515
+ '<div class="example-grid">'
516
+ '<div class="example-card" onclick="document.getElementById(\'load_demo_btn\').click()">'
517
+ '<div class="example-card-title">Titanic (demo)</div>'
518
+ '<div class="example-card-meta">20 passengers · 7 columns · click to load</div>'
519
+ '</div>'
520
+ '</div>'
521
+ '</div>'
522
+ )
523
+
524
+
525
+ # ============================================================ EVENT HANDLERS
526
+ def on_upload(file) -> Tuple[str, str, list]:
527
+ """Register an uploaded file and clear history."""
528
  if file is None:
529
+ return "", _conversation_html([]), []
530
  agent = get_agent()
531
  agent.reset()
532
  try:
533
  path = Path(file.name if hasattr(file, "name") else file)
534
  table = agent.load_data(path)
535
+ rows = agent.executor.con.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
536
+ cols = len(agent.executor.get_table_schema(table))
537
+ chip = _file_chip_html(path.name, rows, cols)
538
+ # Show suggestions based on the table
539
+ intro = (
540
+ f'<div class="empty-title" style="margin-top:30px">Ready to query <code style="font-family:var(--font-mono);font-size:13px">{table}</code></div>'
541
+ f'<div class="empty-sub">Try a question or one of these:</div>'
542
+ f'{_suggestions_html(SUGGESTED_QUESTIONS[:4])}'
543
+ )
544
+ return chip, intro, []
545
  except Exception as e:
546
  logger.exception("upload failed")
547
+ return "", f'<div class="turn-error">Could not load file: {e}</div>', []
548
 
549
 
550
+ def on_load_demo() -> Tuple[str, str, list]:
551
+ """Load the embedded Titanic example."""
552
+ agent = get_agent()
553
+ agent.reset()
554
+ p = _make_titanic_csv()
555
+ table = agent.load_data(p)
556
+ rows = agent.executor.con.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
557
+ cols = len(agent.executor.get_table_schema(table))
558
+ chip = _file_chip_html("titanic.csv (demo)", rows, cols)
559
+ intro = (
560
+ f'<div class="empty-title" style="margin-top:30px">Loaded <code style="font-family:var(--font-mono);font-size:13px">{table}</code></div>'
561
+ f'<div class="empty-sub">Try one of these questions:</div>'
562
+ f'{_suggestions_html(SUGGESTED_QUESTIONS[:4])}'
563
+ )
564
+ return chip, intro, []
565
+
566
+
567
+ @spaces.GPU(duration=180)
568
  def _gpu_process(question: str) -> dict:
569
+ """The GPU-bound call. Models initialize lazily inside this scope."""
570
  agent = get_agent()
571
  return agent.process(question)
572
 
573
 
574
+ def on_ask(question: str, history: list) -> Generator[Tuple[str, str, list], None, None]:
575
+ """
576
+ Generator: yields conversation HTML at each pipeline step so the user
577
+ sees real-time progress instead of waiting silently.
578
+ """
579
  history = history or []
580
  question = (question or "").strip()
581
  if not question:
582
+ yield _conversation_html(history), "", history
583
+ return
584
+
585
  if not get_agent().list_tables():
586
  history.append({
587
  "question": question,
588
+ "error": "Upload a file first or load the demo dataset.",
589
  })
590
+ yield _conversation_html(history), "", history
591
+ return
592
+
593
+ # First yield: show the question with progress indicator
594
+ yield _conversation_html(history, in_progress=(question, "Loading models (first query takes ~60s)…")), "", history
595
 
596
  try:
597
+ # Run the full pipeline
598
  result = _gpu_process(question)
599
  except Exception as e:
600
  logger.exception("ask failed")
601
  result = {"question": question, "error": str(e)}
602
 
603
  history.append(result)
604
+ yield _conversation_html(history), "", history
605
 
606
 
607
+ def on_reset() -> Tuple[str, str, str, list]:
608
  get_agent().reset()
609
+ return "", "", _conversation_html([]), []
610
 
611
 
612
+ # ====================================================================== APP
613
  def build_app() -> gr.Blocks:
614
  with gr.Blocks(
615
+ theme=gr.themes.Base(),
 
 
616
  css=THEME_CSS,
617
  title="SQL Agent",
618
  analytics_enabled=False,
 
627
  '</div>'
628
  )
629
 
630
+ # Upload
631
+ with gr.Row(elem_classes=["upload-row"]):
632
+ upload = gr.File(
633
+ label="",
634
+ file_types=[".csv", ".json", ".parquet", ".xlsx", ".xls"],
635
+ show_label=False,
636
+ container=False,
637
+ )
638
  chip_html = gr.HTML("")
639
+
640
+ # Question
641
+ with gr.Group(elem_classes=["question-row"]):
642
+ question = gr.Textbox(
643
+ placeholder="Ask anything about your data…",
644
+ lines=2,
645
+ max_lines=8,
646
+ show_label=False,
647
+ container=False,
648
+ autofocus=True,
649
+ )
650
+ gr.HTML('<div class="kb-hint">Press Enter to send · Shift+Enter for newline</div>')
651
+
652
  with gr.Row():
653
  ask_btn = gr.Button("Ask", variant="primary", size="sm")
654
  reset_btn = gr.Button("Clear", variant="secondary", size="sm")
655
+ demo_btn = gr.Button("Load demo dataset", variant="secondary", size="sm",
656
+ elem_id="load_demo_btn")
657
 
658
  # Conversation
659
  history_state = gr.State([])
660
  conversation = gr.HTML(_conversation_html([]))
661
 
662
+ # ------------- events -------------
 
663
  upload.upload(
664
  fn=on_upload,
665
  inputs=upload,
666
+ outputs=[chip_html, conversation, history_state],
667
+ api_name=False,
668
+ )
669
+ upload.clear(
670
+ fn=lambda: ("", _conversation_html([]), []),
671
+ outputs=[chip_html, conversation, history_state],
672
+ api_name=False,
673
+ )
674
+ demo_btn.click(
675
+ fn=on_load_demo,
676
+ outputs=[chip_html, conversation, history_state],
677
  api_name=False,
678
  )
679
  ask_btn.click(
 
690
  )
691
  reset_btn.click(
692
  fn=on_reset,
693
+ outputs=[chip_html, question, conversation, history_state],
 
 
 
 
694
  api_name=False,
695
  )
696