Swethaditya commited on
Commit
9620e6a
·
1 Parent(s): 8da763b

Changes for the intent

Browse files
README.md CHANGED
@@ -96,24 +96,26 @@ reset(dataset, task_id, intent)
96
 
97
  ## Scoring Formulas
98
 
 
 
99
  ### Easy
100
  ```
101
- score = resolution(null + type_error + whitespace + category) × 0.70
102
- + validation × 0.30
103
  − fp_penalty
104
  ```
105
 
106
- ### Medium
107
  ```
108
- score = easy_score × 0.40
109
- + avg(constraint_resolved + outlier_resolved) × 0.60
110
  − fp_penalty
111
  ```
112
 
113
- ### Hard
114
  ```
115
  # With FK violations:
116
- score = medium_score × 0.50 + fk_resolved × 0.50 + reasoning_bonus − trap_penalty
117
 
118
  # Single-table dataset (no FK violations to find):
119
  score = medium_score + reasoning_bonus − trap_penalty
@@ -220,6 +222,36 @@ Dense per-step rewards — every action returns a signal:
220
 
221
  ---
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  ## Quick Start
224
 
225
  ### Docker (recommended)
 
96
 
97
  ## Scoring Formulas
98
 
99
+ **Easy scores most** — the basic cleaning foundation counts for the most weight in every cascade.
100
+
101
  ### Easy
102
  ```
103
+ score = resolution(null + type_error + whitespace + category) × 0.80
104
+ + validation × 0.20
105
  − fp_penalty
106
  ```
107
 
108
+ ### Medium (easy = 70% of the score)
109
  ```
110
+ score = easy_score × 0.70
111
+ + avg(constraint_resolved + outlier_resolved + duplicate_resolved) × 0.30
112
  − fp_penalty
113
  ```
114
 
115
+ ### Hard (medium = 70% of the score)
116
  ```
117
  # With FK violations:
118
+ score = medium_score × 0.70 + fk_resolved × 0.30 + reasoning_bonus − trap_penalty
119
 
120
  # Single-table dataset (no FK violations to find):
121
  score = medium_score + reasoning_bonus − trap_penalty
 
222
 
223
  ---
224
 
225
+ ## Multiple Tables
226
+
227
+ Upload **2–3 files** in the Agent Demo tab. They are merged into a multi-sheet XLSX — each sheet becomes a separate SQLite table. The agent will:
228
+
229
+ 1. `select_tables` — list all available tables
230
+ 2. Inspect schemas and attempt `join_tables` with the best matching key column
231
+ 3. Profile and bulk-fix each table independently
232
+
233
+ **Supported formats per file:** CSV, JSON, JSONL, Parquet, XLSX
234
+
235
+ Alternatively, upload a **single XLSX with multiple sheets** — each sheet loads as a separate table automatically.
236
+
237
+ ---
238
+
239
+ ## Business Query Feature
240
+
241
+ For the **Business Analytics** intent, enter a SQL `SELECT` query in the UI. After the agent finishes cleaning (Hard task), the query runs on the cleaned in-memory database and results are displayed in the UI.
242
+
243
+ ```sql
244
+ -- Example
245
+ SELECT "Pclass", COUNT(*) AS passengers, AVG("Fare") AS avg_fare
246
+ FROM dataset
247
+ GROUP BY "Pclass"
248
+ ORDER BY avg_fare DESC
249
+ ```
250
+
251
+ Works on any intent — not just business_query.
252
+
253
+ ---
254
+
255
  ## Quick Start
256
 
257
  ### Docker (recommended)
inference.py CHANGED
@@ -85,10 +85,14 @@ def log_start(task: str, model: str) -> None:
85
  def log_step(step: int, action: str, reward: float, done: bool,
86
  error: Optional[str] = None) -> None:
87
  action_str = action.replace("\n", " ").replace("\r", " ").strip()[:120]
 
 
 
 
88
  print(
89
  f"[STEP] step={step} action={action_str} "
90
  f"reward={reward:.2f} done={str(done).lower()} "
91
- f"error={error if error else 'null'}",
92
  flush=True,
93
  )
94
 
@@ -195,10 +199,10 @@ def run_task(task_id: str, intent: str = "") -> float:
195
 
196
  budget = STEP_BUDGETS[task_id]
197
  rewards: list[float] = []
198
- steps_taken = 0
199
  score = 0.0
200
  success = False
201
- env = None # initialised inside try so finally can check
202
 
203
  log_start(task=task_id, model=MODEL_NAME)
204
 
@@ -216,8 +220,7 @@ def run_task(task_id: str, intent: str = "") -> float:
216
  columns = obs.tables_summary.get(table, {}).get("columns", [])
217
  visible_cols = [c for c in columns if c not in ("id", "_source_format")]
218
 
219
- done = False
220
- step_num = 0
221
  col_profiles: dict[str, dict] = {}
222
 
223
  llm_messages = [{"role": "system", "content": SYSTEM_PROMPT}]
@@ -322,13 +325,12 @@ def run_task(task_id: str, intent: str = "") -> float:
322
  positive = sum(r for r in rewards if r > 0)
323
  score = max(0.0, min(1.0, positive / max(budget * 0.15, 0.01)))
324
 
325
- success = score >= 0.50
326
- steps_taken = step_num
327
 
328
  except Exception as exc:
329
  # Catch-all — log a terminal error step so judges see what failed
330
  log_step(
331
- max(steps_taken + 1, 1), "error", 0.0, True,
332
  f"{type(exc).__name__}: {str(exc)[:60]}",
333
  )
334
 
@@ -339,7 +341,7 @@ def run_task(task_id: str, intent: str = "") -> float:
339
  env.close()
340
  except Exception:
341
  pass
342
- log_end(success=success, steps=steps_taken, rewards=rewards)
343
 
344
  return score
345
 
 
85
  def log_step(step: int, action: str, reward: float, done: bool,
86
  error: Optional[str] = None) -> None:
87
  action_str = action.replace("\n", " ").replace("\r", " ").strip()[:120]
88
+ error_str = (
89
+ error.replace("\n", " ").replace("\r", " ").strip()[:120]
90
+ if error else "null"
91
+ )
92
  print(
93
  f"[STEP] step={step} action={action_str} "
94
  f"reward={reward:.2f} done={str(done).lower()} "
95
+ f"error={error_str}",
96
  flush=True,
97
  )
98
 
 
199
 
200
  budget = STEP_BUDGETS[task_id]
201
  rewards: list[float] = []
202
+ step_num = 0 # initialised here so except/finally can always read it
203
  score = 0.0
204
  success = False
205
+ env = None # initialised inside try so finally can check
206
 
207
  log_start(task=task_id, model=MODEL_NAME)
208
 
 
220
  columns = obs.tables_summary.get(table, {}).get("columns", [])
221
  visible_cols = [c for c in columns if c not in ("id", "_source_format")]
222
 
223
+ done = False
 
224
  col_profiles: dict[str, dict] = {}
225
 
226
  llm_messages = [{"role": "system", "content": SYSTEM_PROMPT}]
 
325
  positive = sum(r for r in rewards if r > 0)
326
  score = max(0.0, min(1.0, positive / max(budget * 0.15, 0.01)))
327
 
328
+ success = score >= 0.50
 
329
 
330
  except Exception as exc:
331
  # Catch-all — log a terminal error step so judges see what failed
332
  log_step(
333
+ max(step_num + 1, 1), "error", 0.0, True,
334
  f"{type(exc).__name__}: {str(exc)[:60]}",
335
  )
336
 
 
341
  env.close()
342
  except Exception:
343
  pass
344
+ log_end(success=success, steps=step_num, rewards=rewards)
345
 
346
  return score
347
 
sqlsherlock_env/server/graders/task2.py CHANGED
@@ -88,5 +88,6 @@ def grade(
88
  )
89
 
90
  combined = (c_score + o_score + d_score) / 3.0
91
- raw = t1 * 0.40 + combined * 0.60 - fp_penalty
 
92
  return max(0.0, min(1.0, round(raw, 4)))
 
88
  )
89
 
90
  combined = (c_score + o_score + d_score) / 3.0
91
+ # Easy (task1) gets the highest weight — basic cleaning matters most.
92
+ raw = t1 * 0.70 + combined * 0.30 - fp_penalty
93
  return max(0.0, min(1.0, round(raw, 4)))
sqlsherlock_env/server/graders/task3.py CHANGED
@@ -82,18 +82,18 @@ def grade(
82
  # again here to avoid double-counting.
83
 
84
  if fk_issues:
85
- # Dataset has FK violations — score FK resolution as 50% of hard grade
 
86
  fk_score, _ = _resolution_score(fk_issues, cleaned_rows, removed_ids, pk_col, db)
87
  raw = (
88
- t2 * 0.50
89
- + fk_score * 0.50
90
  + r_bonus
91
  - trap_pen
92
  )
93
  else:
94
  # Single-table dataset — no FK issues to resolve.
95
- # Hard difficulty is demonstrated entirely through trap avoidance and reasoning.
96
- # Grade purely on medium performance + trap + reasoning (no free FK credit).
97
  raw = t2 + r_bonus - trap_pen
98
 
99
  return max(0.0, min(1.0, round(raw, 4)))
 
82
  # again here to avoid double-counting.
83
 
84
  if fk_issues:
85
+ # Medium (task2) gets highest weight — medium cleaning is the foundation.
86
+ # FK resolution adds the hard-level component at 30%.
87
  fk_score, _ = _resolution_score(fk_issues, cleaned_rows, removed_ids, pk_col, db)
88
  raw = (
89
+ t2 * 0.70
90
+ + fk_score * 0.30
91
  + r_bonus
92
  - trap_pen
93
  )
94
  else:
95
  # Single-table dataset — no FK issues to resolve.
96
+ # Hard difficulty demonstrated through trap avoidance and reasoning.
 
97
  raw = t2 + r_bonus - trap_pen
98
 
99
  return max(0.0, min(1.0, round(raw, 4)))
sqlsherlock_env/server/requirements.txt CHANGED
@@ -9,3 +9,4 @@ pandas>=2.0.0
9
  pyarrow>=14.0.0
10
  gradio>=4.44.0
11
  scikit-learn>=1.4.0
 
 
9
  pyarrow>=14.0.0
10
  gradio>=4.44.0
11
  scikit-learn>=1.4.0
12
+ openpyxl>=3.1.0
sqlsherlock_env/server/ui.py CHANGED
@@ -2,28 +2,28 @@
2
  SQLSherlock-Env dashboard UI.
3
 
4
  User flow:
5
- 1. Provide a dataset (file upload or HuggingFace name).
6
- 2. Select ONE cleaning intent (visualization / ML training / business analytics).
7
- 3. Select output format (CSV / JSON / Parquet).
8
- 4. The agent runs 3 escalating difficulty tasks for that intent:
9
- Easy → Medium → Hard
10
- 5. Live step log streams as each task runs.
11
- 6. Download the cleaned file from the Hard task when complete.
12
  """
13
 
14
- import os
15
  import re
16
  import tempfile
17
- from typing import Generator
 
18
 
19
  import gradio as gr
 
20
 
21
 
22
  # ---------------------------------------------------------------------------
23
  # Constants
24
  # ---------------------------------------------------------------------------
25
 
26
- # Each intent maps to its 3 difficulty task IDs (easy → medium → hard)
27
  INTENT_TASK_MAP: dict[str, list[str]] = {
28
  "visualization": ["viz_easy", "viz_medium", "viz_hard"],
29
  "ml_training": ["ml_easy", "ml_medium", "ml_hard"],
@@ -36,12 +36,6 @@ INTENT_LABELS: dict[str, str] = {
36
  "business_query": "Business Analytics — clean for SQL reporting & queries",
37
  }
38
 
39
- DIFFICULTY_LABELS: dict[str, str] = {
40
- "easy": "Easy (30 steps) — nulls, type errors, whitespace, categories",
41
- "medium": "Medium (40 steps) — + constraints, outliers",
42
- "hard": "Hard (50 steps) — + duplicates, FK violations, trap",
43
- }
44
-
45
  INTENT_CHOICES = [
46
  ("Visualization — dashboards & charts", "visualization"),
47
  ("ML Training — model training pipelines", "ml_training"),
@@ -50,33 +44,67 @@ INTENT_CHOICES = [
50
 
51
  FORMAT_CHOICES = ["csv", "json", "parquet"]
52
 
53
- _EXAMPLE_HF_DATASETS = [
54
- ["phihung/titanic"],
55
- ["scikit-learn/iris"],
56
- ]
57
 
58
  _AGENT_EXAMPLES = [
59
- ["phihung/titanic", "visualization", "csv"],
60
- ["phihung/titanic", "ml_training", "csv"],
61
- ["phihung/titanic", "business_query", "csv"],
 
 
 
 
62
  ]
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  # ---------------------------------------------------------------------------
66
  # Helpers
67
  # ---------------------------------------------------------------------------
68
 
69
- def _resolve_source(file_path: str | None, hf_name: str) -> tuple[str | None, str | None]:
70
- """Return (source, error_message). File upload takes precedence over HF name."""
71
- if file_path:
72
- return file_path, None
73
  name = (hf_name or "").strip()
74
  if name:
75
  return name, None
76
- return None, (
77
- "**No dataset provided.** "
78
- "Upload a file (Option A) or enter a HuggingFace dataset name (Option B)."
79
- )
80
 
81
 
82
  def _friendly_error(exc: Exception, source: str) -> str:
@@ -87,42 +115,310 @@ def _friendly_error(exc: Exception, source: str) -> str:
87
  return "Could not parse the file. Supported: CSV, JSON, JSONL, Parquet, XLSX."
88
  if "connection" in msg.lower() or "refused" in msg.lower():
89
  return "Cannot reach the server. Make sure the environment is running."
90
- return f"Failed to load dataset ({type(exc).__name__}). Check the name and try again."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
 
93
  # ---------------------------------------------------------------------------
94
  # Tab 1: Data Quality Scanner
95
  # ---------------------------------------------------------------------------
96
 
97
- def scan_dataset(file_path: str | None, hf_name: str) -> tuple[str, list, list, list, list]:
98
  from server.database import DatabaseEngine
99
 
100
  source, err = _resolve_source(file_path, hf_name)
101
  if err:
102
  return err, [], [], [], []
103
-
104
  try:
105
- db = DatabaseEngine(task_id="bq_hard", seed=42, dataset_source=source, max_rows=500)
 
106
  except Exception as exc:
107
  return f"**Error:** {_friendly_error(exc, source)}", [], [], [], []
108
 
109
- table = db.primary_table
110
- issues = db.issue_registry
111
- profiles = db._profiles.get(table, {})
112
- total_rows = len(db.rows(table))
113
- total_cols = sum(1 for c in profiles if c not in ("id", "_source_format"))
114
- total_issues = len(issues)
115
- cells = max(total_rows * total_cols, 1)
116
- quality_pct = max(0.0, min(1.0, 1.0 - total_issues / cells)) * 100
117
- grade = ("Excellent" if quality_pct >= 90 else
118
- "Good" if quality_pct >= 70 else
119
- "Fair" if quality_pct >= 50 else "Poor")
120
 
121
  type_counts: dict[str, int] = {}
122
  for iss in issues:
123
  type_counts[iss.issue_type] = type_counts.get(iss.issue_type, 0) + 1
124
 
125
- breakdown_lines = "\n".join(
126
  f"| `{t}` | {c} |"
127
  for t, c in sorted(type_counts.items(), key=lambda x: -x[1])
128
  )
@@ -134,14 +430,14 @@ def scan_dataset(file_path: str | None, hf_name: str) -> tuple[str, list, list,
134
  | **Table** | `{table}` |
135
  | **Rows** | {total_rows:,} |
136
  | **Columns** | {total_cols} |
137
- | **Issues found** | {total_issues} |
138
  | **Quality score** | **{quality_pct:.1f}% — {grade}** |
139
 
140
  ### Issue Breakdown
141
 
142
  | Issue Type | Count |
143
  |------------|-------|
144
- {breakdown_lines}
145
  """
146
 
147
  col_rows = []
@@ -152,10 +448,11 @@ def scan_dataset(file_path: str | None, hf_name: str) -> tuple[str, list, list,
152
  col_issues = sum(1 for i in issues if i.column == col)
153
  health = ("Good" if null_rate < 0.05 and col_issues == 0 else
154
  "Fair" if null_rate < 0.30 and col_issues <= 2 else "Poor")
155
- col_rows.append([col, p.get("dtype", "?"), f"{null_rate*100:.1f}%", col_issues, health])
 
156
 
157
- issue_rows = [[t, c] for t, c in sorted(type_counts.items(), key=lambda x: -x[1])]
158
- sample_rows = [
159
  [iss.issue_type, iss.column or "-", str(iss.row_id),
160
  f"{iss.confidence:.0%}",
161
  "delete row" if iss.correct is None else str(iss.correct)[:40]]
@@ -169,70 +466,105 @@ def scan_dataset(file_path: str | None, hf_name: str) -> tuple[str, list, list,
169
 
170
 
171
  # ---------------------------------------------------------------------------
172
- # Tab 2: Agent Demo — one intent → 3 difficulty tasks streamed sequentially
173
  # ---------------------------------------------------------------------------
174
 
175
  def run_agent_streaming(
176
- file_path: str | None,
 
 
177
  hf_name: str,
178
  intent: str,
179
  output_format: str,
180
- ) -> Generator[tuple[str, str, str | None], None, None]:
181
- """
182
- Yields (log_text, summary_md, download_path) after every action.
183
- Runs 3 escalating tasks (easy → medium → hard) for the selected intent.
184
- """
185
  from server.environment import SQLSherlockEnvironment
186
  from models import SQLSherlockAction
187
 
188
- source, err = _resolve_source(file_path, hf_name)
189
- if err:
190
- yield err, "", None
191
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
  task_ids = INTENT_TASK_MAP.get(intent, INTENT_TASK_MAP["visualization"])
194
  intent_label = INTENT_LABELS.get(intent, intent)
195
 
196
- log_lines: list[str] = []
197
- all_scores: list[tuple[str, float]] = [] # [(task_id, score)]
198
  download_path: str | None = None
199
- summary_md = "_Running…_"
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  def _log(*parts: str) -> None:
202
- log_lines.append(" ".join(parts))
203
 
204
- def _current_log() -> str:
205
- return "\n".join(log_lines)
 
206
 
 
207
  _log(f"Intent : {intent_label}")
 
208
  _log(f"Format : {output_format}")
209
- _log(f"Dataset: {source}")
 
210
  _log("")
211
- yield _current_log(), summary_md, None
212
 
213
  for task_id in task_ids:
214
  difficulty = task_id.split("_")[-1].upper()
215
  _log(f"{'─'*60}")
216
  _log(f"[{difficulty}] {task_id}")
217
  _log(f"{'─'*60}")
218
- yield _current_log(), summary_md, None
219
 
220
- env = SQLSherlockEnvironment()
221
- score = 0.0
222
- step = 0
223
- done = False
224
- obs = None
225
  rewards: list[float] = []
226
- fixed_cols: list[str] = []
227
 
228
  def _do(action_dict: dict) -> float:
229
  nonlocal step, done, obs
230
  if done:
231
  return 0.0
232
  step += 1
233
- action = SQLSherlockAction(
234
- **{k: v for k, v in action_dict.items() if v is not None}
235
- )
236
  reward = 0.0
237
  try:
238
  obs = env.step(action)
@@ -250,18 +582,51 @@ def run_agent_streaming(
250
  intent=intent,
251
  output_format=output_format,
252
  )
253
- table = list(obs.tables_summary.keys())[0]
254
- summary = obs.tables_summary[table]
255
- visible = [c for c in summary.get("columns", [])
256
- if c not in ("id", "_source_format")]
257
-
258
- _log(f" Table: {table} ({summary.get('row_count','?')} rows, {len(visible)} cols)")
259
- yield _current_log(), summary_md, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
  # Phase 1: Inspect
262
  r = _do({"action_type": "inspect", "table": table})
263
- _log(f" [step {step:2d}] inspect reward={r:+.2f}")
264
- yield _current_log(), summary_md, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
  # Phase 2: Profile + bulk-fix
267
  for col in visible[:12]:
@@ -272,44 +637,88 @@ def run_agent_streaming(
272
  p = obs.query_result[0]
273
  nc = p.get("null_count", 0)
274
  dt = p.get("dtype", "?")
275
- _log(f" [step {step:2d}] profile({col:<12}) type={dt} nulls={nc} reward={r:+.2f}")
276
- yield _current_log(), summary_md, None
277
-
278
  if nc > 0 and not done:
279
- fill = (p.get("median") or p.get("mean") or 0) if dt in ("int", "float") else (p.get("mode") or "Unknown")
280
- strategy = "median" if dt in ("int", "float") else "mode"
 
281
  r2 = _do({
282
  "action_type": "fix_column", "table": table,
283
  "column": col, "value": fill,
284
- "reason": f"bulk fix {nc} nulls, {strategy}={fill}",
285
  })
286
- _log(f" [step {step:2d}] fix_column({col:<12}, {str(fill)[:8]!r:<10}) reward={r2:+.2f}")
287
  if r2 > 0:
288
- fixed_cols.append(col)
289
- yield _current_log(), summary_md, None
290
  else:
291
- _log(f" [step {step:2d}] profile({col:<12}) reward={r:+.2f}")
292
- yield _current_log(), summary_md, None
293
 
294
  # Phase 3: Validate
295
  if not done:
296
  r = _do({"action_type": "validate"})
297
  fb = (obs.last_feedback if obs else "")[:100]
298
- _log(f" [step {step:2d}] validate reward={r:+.2f} {fb}")
299
- yield _current_log(), summary_md, None
300
-
301
- # Phase 4: Export (hard tasks produce a downloadable file)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  if not done:
 
 
 
 
 
303
  r = _do({"action_type": "export"})
304
- fb = (obs.last_feedback if obs else "")[:120]
305
- _log(f" [step {step:2d}] export reward={r:+.2f}")
306
  _log(f" {fb}")
307
  m = re.search(r"[Gg]rader\s+score\s*=?\s*(\d+\.\d+)", fb)
308
  if m:
309
  score = float(m.group(1))
310
- if task_id.endswith("_hard") and hasattr(env, "_export_result") and env._export_result:
311
- download_path = env._export_result.get("filepath")
312
- yield _current_log(), summary_md, download_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
  except Exception as exc:
315
  _log(f" [ERROR] {_friendly_error(exc, source)}")
@@ -319,18 +728,16 @@ def run_agent_streaming(
319
  except Exception:
320
  pass
321
 
322
- total_r = sum(rewards)
323
  success = score >= 0.50
324
- _log(f" Score: {score:.2f} Total reward: {total_r:+.3f} "
325
- f"{'SUCCESS' if success else 'PARTIAL'} Steps: {step}")
326
  _log("")
327
  all_scores.append((task_id, score))
328
- yield _current_log(), summary_md, download_path
329
 
330
  # Final summary table
331
  avg = sum(s for _, s in all_scores) / len(all_scores) if all_scores else 0.0
332
  rows_md = "\n".join(
333
- f"| `{tid}` | {s:.2f} | {'Pass' if s >= 0.50 else 'Fail'} |"
334
  for tid, s in all_scores
335
  )
336
  summary_md = f"""## Results — Intent: `{intent}`
@@ -343,25 +750,31 @@ def run_agent_streaming(
343
 
344
  | Detail | Value |
345
  |--------|-------|
346
- | Dataset | `{source}` |
347
- | Output format | `{output_format}` |
348
  | Tasks run | {len(all_scores)} (easy → medium → hard) |
349
- {"| **Download** | cleaned file available below |" if download_path else ""}
 
350
  """
351
- yield _current_log(), summary_md, download_path
352
 
353
 
354
  # ---------------------------------------------------------------------------
355
- # Build the Gradio Blocks app
356
  # ---------------------------------------------------------------------------
357
 
 
 
 
 
 
358
  def create_ui() -> gr.Blocks:
359
  with gr.Blocks(title="SQLSherlock — AI Data Quality Detective") as demo:
360
 
361
  gr.Markdown(
362
  "# SQLSherlock — AI Data Quality Detective\n"
363
- "Select your **cleaning intent** and let the agent clean your dataset "
364
- "across **3 escalating difficulty levels** — Easy, Medium, then Hard."
365
  )
366
 
367
  # ==================================================================
@@ -369,25 +782,23 @@ def create_ui() -> gr.Blocks:
369
  # ==================================================================
370
  with gr.Tab("Data Quality Scanner"):
371
  gr.Markdown(
372
- "Provide a dataset, then click **Scan** to see a full quality report."
373
- "\n\n> If both file and name are filled, the uploaded file takes priority."
374
  )
375
  with gr.Row():
376
  with gr.Column(scale=1):
377
- gr.Markdown("#### Option A — Upload a file")
378
  scan_file = gr.File(
379
  label="CSV / JSON / JSONL / Parquet / Excel",
380
  file_types=[".csv", ".json", ".jsonl", ".parquet", ".xlsx"],
381
  type="filepath",
382
  )
383
- gr.Markdown("#### Option B — HuggingFace dataset")
384
  scan_hf = gr.Textbox(
385
- label="Dataset name",
386
  placeholder="e.g. phihung/titanic",
387
- value="phihung/titanic",
388
  )
389
- gr.Examples(examples=_EXAMPLE_HF_DATASETS, inputs=[scan_hf],
390
- label="Quick examples")
391
  scan_btn = gr.Button("Scan Dataset", variant="primary", size="lg")
392
 
393
  with gr.Column(scale=2):
@@ -418,38 +829,63 @@ def create_ui() -> gr.Blocks:
418
  )
419
 
420
  # ==================================================================
421
- # Tab 2: Agent Demo — intent → 3 difficulty levels
422
  # ==================================================================
423
  with gr.Tab("Agent Demo"):
424
  gr.Markdown(
425
- "**Choose one cleaning intent** — the agent will automatically run "
426
- "**Easy → Medium → Hard** tasks for that intent.\n\n"
427
- "The cleaned file from the Hard task is available for download."
428
  )
429
 
430
  with gr.Row():
431
- # --- Dataset input ---
432
  with gr.Column(scale=2):
433
- gr.Markdown("#### Dataset")
434
- agent_file = gr.File(
435
- label="Upload file (CSV / JSON / JSONL / Parquet / XLSX)",
436
- file_types=[".csv", ".json", ".jsonl", ".parquet", ".xlsx"],
437
- type="filepath",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  )
439
  agent_hf = gr.Textbox(
440
- label="OR enter HuggingFace dataset name",
441
  value="phihung/titanic",
442
  placeholder="phihung/titanic",
443
  )
444
 
445
- # --- Configuration ---
446
- with gr.Column(scale=1):
447
  gr.Markdown("#### Cleaning Intent")
448
  agent_intent = gr.Radio(
449
  choices=INTENT_CHOICES,
450
  label="What will you use this data for?",
451
  value="visualization",
452
  )
 
 
 
 
 
 
 
 
 
 
453
  gr.Markdown("#### Output Format")
454
  agent_format = gr.Radio(
455
  choices=FORMAT_CHOICES,
@@ -457,26 +893,48 @@ def create_ui() -> gr.Blocks:
457
  value="csv",
458
  )
459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  gr.Markdown(
461
- "> The agent will run 3 tasks for the selected intent:\n"
462
- "> - **Easy** — basic cleaning (nulls, type errors, whitespace, categories)\n"
463
- "> - **Medium** — + constraints, outliers\n"
464
- "> - **Hard** — + duplicates, FK violations, trap detection"
465
  )
466
 
467
  gr.Examples(
468
  examples=_AGENT_EXAMPLES,
469
- inputs=[agent_hf, agent_intent, agent_format],
470
  label="Quick examples",
471
  )
472
 
473
  run_btn = gr.Button("Run Agent (3 Tasks)", variant="primary", size="lg")
474
 
 
475
  with gr.Row():
476
  with gr.Column(scale=3):
477
  agent_log = gr.Textbox(
478
  label="Live Step Log",
479
- lines=30, max_lines=120,
480
  interactive=False,
481
  )
482
  with gr.Column(scale=2):
@@ -484,22 +942,53 @@ def create_ui() -> gr.Blocks:
484
  value="_Results appear here after the agent finishes._"
485
  )
486
 
 
 
487
  gr.Markdown("#### Download Cleaned File (Hard task output)")
488
  download_out = gr.File(
489
- label="Cleaned dataset — available after the Hard task completes",
490
  interactive=False,
491
  )
492
 
493
- def _run(file_path, hf_name, intent, output_format):
494
- for log, results, dl in run_agent_streaming(
495
- file_path, hf_name, intent, output_format
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  ):
497
- yield log, results, dl
498
 
499
  run_btn.click(
500
  fn=_run,
501
- inputs=[agent_file, agent_hf, agent_intent, agent_format],
502
- outputs=[agent_log, agent_results, download_out],
 
 
 
 
503
  show_progress="minimal",
504
  )
505
 
@@ -508,62 +997,39 @@ def create_ui() -> gr.Blocks:
508
  # ==================================================================
509
  with gr.Tab("About"):
510
  gr.Markdown("""
511
- ## SQLSherlock Task Architecture
512
-
513
- Each **cleaning intent** has **3 difficulty levels** — the agent always
514
- runs Easy → Medium → Hard for the chosen intent.
515
-
516
- ### Intents & Their 3 Tasks
517
-
518
- | Intent | Easy | Medium | Hard |
519
- |--------|------|--------|------|
520
- | **Visualization** | `viz_easy` | `viz_medium` | `viz_hard` |
521
- | **ML Training** | `ml_easy` | `ml_medium` | `ml_hard` |
522
- | **Business Analytics** | `bq_easy` | `bq_medium` | `bq_hard` |
523
-
524
- ### Issue Coverage per Difficulty
525
-
526
- | Difficulty | Issues Covered | Max Steps |
527
- |------------|----------------|-----------|
528
- | **Easy** | nulls, type errors, whitespace, inconsistent categories | 30 |
529
- | **Medium** | + constraint violations, statistical outliers (z > 5) | 40 |
530
- | **Hard** | + duplicate rows, FK violations — plus the **trap** | 50 |
531
 
532
- ### Scoring Formulas
 
 
533
 
534
- **Easy:**
535
- ```
536
- score = resolution(null+type+whitespace+category) × 0.70 + validation × 0.30
537
- ```
538
 
539
- **Medium:**
540
- ```
541
- score = easy_score × 0.40 + avg(constraint + outlier) × 0.60
542
- ```
 
543
 
544
- **Hard:**
545
- ```
546
- score = medium_score × 0.50 + fk_resolved × 0.50 + reasoning_bonus − trap_penalty
547
- ```
548
 
549
- ### The Trap (Hard tasks only)
 
550
 
551
- A numeric value is doubled — it *looks* like an outlier but z < 3.
552
- Touching it costs **−0.40**. Always verify z-scores before fixing numeric values.
553
 
554
- ---
 
 
 
555
 
556
- ### API Endpoints
557
 
558
- | Method | Path | Description |
559
- |--------|------|-------------|
560
- | WebSocket | `/ws` | RL episode session |
561
- | POST | `/reset` | Start episode (`task_id`, `intent`, `output_format`) |
562
- | POST | `/step` | Execute one action |
563
- | GET | `/health` | Health check + task list |
564
- | GET | `/tasks` | All 9 task definitions |
565
- | POST | `/upload_dataset` | Upload CSV/JSON/Parquet/XLSX |
566
- | GET | `/download/{id}` | Download cleaned output |
567
  """)
568
 
569
  return demo
 
2
  SQLSherlock-Env dashboard UI.
3
 
4
  User flow:
5
+ 1. Provide dataset(s) — upload up to 3 files OR enter a HuggingFace name.
6
+ 2. Select ONE cleaning intent.
7
+ 3. Describe your specific requirement (dashboard goal / model goal / business question).
8
+ 4. (Optional) enter a SQL query for direct business analytics.
9
+ 5. The agent runs 3 escalating tasks: Easy → Medium → Hard.
10
+ 6. Live step log, before/after comparison, and a final answer are shown.
11
+ 7. Download the cleaned file from the Hard task.
12
  """
13
 
 
14
  import re
15
  import tempfile
16
+ from pathlib import Path
17
+ from typing import Any, Generator
18
 
19
  import gradio as gr
20
+ import pandas as pd
21
 
22
 
23
  # ---------------------------------------------------------------------------
24
  # Constants
25
  # ---------------------------------------------------------------------------
26
 
 
27
  INTENT_TASK_MAP: dict[str, list[str]] = {
28
  "visualization": ["viz_easy", "viz_medium", "viz_hard"],
29
  "ml_training": ["ml_easy", "ml_medium", "ml_hard"],
 
36
  "business_query": "Business Analytics — clean for SQL reporting & queries",
37
  }
38
 
 
 
 
 
 
 
39
  INTENT_CHOICES = [
40
  ("Visualization — dashboards & charts", "visualization"),
41
  ("ML Training — model training pipelines", "ml_training"),
 
44
 
45
  FORMAT_CHOICES = ["csv", "json", "parquet"]
46
 
47
+ _EXAMPLE_HF = [["phihung/titanic"], ["scikit-learn/iris"]]
 
 
 
48
 
49
  _AGENT_EXAMPLES = [
50
+ ["phihung/titanic", "visualization",
51
+ "Build a dashboard showing survival rates by passenger class, gender and age group.", "csv", ""],
52
+ ["phihung/titanic", "ml_training",
53
+ "Train a survival prediction model using Age, Fare and Pclass as features.", "csv", ""],
54
+ ["phihung/titanic", "business_query",
55
+ "What is the average fare and survival rate for each passenger class?", "csv",
56
+ 'SELECT "Pclass", COUNT(*) AS passengers, ROUND(AVG("Fare"),2) AS avg_fare, ROUND(AVG("Survived"),2) AS survival_rate FROM dataset GROUP BY "Pclass" ORDER BY "Pclass"'],
57
  ]
58
 
59
+ # Requirement placeholders per intent
60
+ _REQ_CONFIG: dict[str, dict] = {
61
+ "visualization": {
62
+ "label": "Describe your dashboard (what do you want to visualise?)",
63
+ "placeholder": (
64
+ "e.g. I want to build a dashboard showing passenger survival rates "
65
+ "by class, gender and age group. Key metrics: survival %, average fare, "
66
+ "passenger count per category."
67
+ ),
68
+ },
69
+ "ml_training": {
70
+ "label": "Describe your model (goal, features, target column)",
71
+ "placeholder": (
72
+ "e.g. Building a binary classification model to predict passenger survival. "
73
+ "Features: Age, Fare, Pclass, Sex. Target: Survived. "
74
+ "Need clean numeric features with no nulls or extreme outliers."
75
+ ),
76
+ },
77
+ "business_query": {
78
+ "label": "What business question do you want answered?",
79
+ "placeholder": (
80
+ "e.g. What is the average ticket fare for each passenger class? "
81
+ "Which class had the highest survival rate? "
82
+ "How many passengers embarked from each port?"
83
+ ),
84
+ },
85
+ }
86
+
87
+ _SQL_PLACEHOLDER = (
88
+ 'SELECT "Pclass", COUNT(*) AS passengers,\n'
89
+ ' ROUND(AVG("Fare"), 2) AS avg_fare,\n'
90
+ ' ROUND(AVG("Survived"), 2) AS survival_rate\n'
91
+ 'FROM dataset\n'
92
+ 'GROUP BY "Pclass"\n'
93
+ 'ORDER BY "Pclass"'
94
+ )
95
+
96
 
97
  # ---------------------------------------------------------------------------
98
  # Helpers
99
  # ---------------------------------------------------------------------------
100
 
101
+ def _resolve_source(file1: str | None, hf_name: str) -> tuple[str | None, str | None]:
102
+ if file1:
103
+ return file1, None
 
104
  name = (hf_name or "").strip()
105
  if name:
106
  return name, None
107
+ return None, "**No dataset provided.** Upload a file or enter a HuggingFace dataset name."
 
 
 
108
 
109
 
110
  def _friendly_error(exc: Exception, source: str) -> str:
 
115
  return "Could not parse the file. Supported: CSV, JSON, JSONL, Parquet, XLSX."
116
  if "connection" in msg.lower() or "refused" in msg.lower():
117
  return "Cannot reach the server. Make sure the environment is running."
118
+ return f"Error ({type(exc).__name__}): {str(exc)[:150]}"
119
+
120
+
121
+ def _read_file(path: str) -> pd.DataFrame:
122
+ ext = Path(path).suffix.lower()
123
+ if ext == ".csv":
124
+ return pd.read_csv(path)
125
+ if ext in (".json", ".jsonl"):
126
+ try:
127
+ return pd.read_json(path)
128
+ except Exception:
129
+ return pd.read_json(path, lines=True)
130
+ if ext == ".parquet":
131
+ return pd.read_parquet(path)
132
+ if ext == ".xlsx":
133
+ return pd.read_excel(path, sheet_name=0)
134
+ return pd.read_csv(path)
135
+
136
+
137
+ def _merge_to_xlsx(files: list[str]) -> str:
138
+ """Merge 2–3 uploaded files into one multi-sheet XLSX.
139
+ Each file → one sheet; sheet name = filename stem (≤31 chars).
140
+ Returns path to the temp file.
141
+ """
142
+ tmp = tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False)
143
+ tmp.close()
144
+ with pd.ExcelWriter(tmp.name, engine="openpyxl") as writer:
145
+ for i, fpath in enumerate(files):
146
+ df = _read_file(fpath)
147
+ sheet = (Path(fpath).stem[:31] or f"table{i+1}")
148
+ df.to_excel(writer, sheet_name=sheet, index=False)
149
+ return tmp.name
150
+
151
+
152
+ def _rows_to_df(rows: list[dict]) -> pd.DataFrame:
153
+ if not rows:
154
+ return pd.DataFrame()
155
+ exclude = {"_source_format"}
156
+ cols = [c for c in rows[0].keys() if c not in exclude]
157
+ return pd.DataFrame([{c: r.get(c, "") for c in cols} for r in rows])
158
+
159
+
160
+ def _df_to_md(df: pd.DataFrame, max_rows: int = 25) -> str:
161
+ """Convert DataFrame to a markdown table (no external dependencies)."""
162
+ if df.empty:
163
+ return "_No results_"
164
+ display = df.head(max_rows)
165
+ cols = list(display.columns)
166
+ header = "| " + " | ".join(str(c) for c in cols) + " |"
167
+ sep = "| " + " | ".join("---" for _ in cols) + " |"
168
+ body = [
169
+ "| " + " | ".join(str(display.iloc[i][c]) for c in cols) + " |"
170
+ for i in range(len(display))
171
+ ]
172
+ tail = f"\n_{len(df) - max_rows} more rows…_" if len(df) > max_rows else ""
173
+ return "\n".join([header, sep] + body) + tail
174
+
175
+
176
+ def _schema_md(tables_summary: dict) -> str:
177
+ if not tables_summary or len(tables_summary) <= 1:
178
+ return ""
179
+ lines = ["### Loaded Tables\n"]
180
+ for tname, info in tables_summary.items():
181
+ cols = [c for c in info.get("columns", []) if c != "_source_format"]
182
+ dtypes = info.get("dtypes", {})
183
+ n_rows = info.get("row_count", "?")
184
+ lines.append(f"**`{tname}`** — {n_rows} rows, {len(cols)} columns\n")
185
+ lines.append("| Column | Type |")
186
+ lines.append("|--------|------|")
187
+ for c in cols:
188
+ lines.append(f"| `{c}` | {dtypes.get(c, '?')} |")
189
+ lines.append("")
190
+ return "\n".join(lines)
191
+
192
+
193
+ def _nl_to_sql(question: str, table: str, columns: list[str]) -> str | None:
194
+ """Convert a natural-language business question to a best-effort SQL query.
195
+
196
+ Returns None if the question is already SQL or no pattern is detected.
197
+ """
198
+ q = question.strip()
199
+ if not q:
200
+ return None
201
+ if q.upper().startswith("SELECT"):
202
+ return q # already SQL
203
+
204
+ q_low = q.lower()
205
+
206
+ # Identify likely numeric and categorical columns
207
+ num_cols = [c for c in columns if c not in ("id", "_source_format")
208
+ and any(w in c.lower() for w in
209
+ ["fare", "age", "price", "amount", "rate", "score", "salary",
210
+ "revenue", "cost", "value", "petal", "sepal", "weight",
211
+ "height", "income", "fee", "count", "survived"])]
212
+ cat_cols = [c for c in columns if c not in ("id", "_source_format")
213
+ and any(w in c.lower() for w in
214
+ ["class", "sex", "gender", "type", "category", "status",
215
+ "species", "department", "region", "country", "city",
216
+ "embarked", "cabin", "name", "group", "tier"])]
217
+
218
+ agg_words = ["average", "avg", "mean", "total", "sum", "rate",
219
+ "how many", "count", "number of", "maximum", "minimum",
220
+ "highest", "lowest", "most", "least"]
221
+ grp_words = [" by ", " per ", " each ", " for each ", " across ", " group "]
222
+ has_agg = any(w in q_low for w in agg_words)
223
+ has_group = any(w in q_low for w in grp_words)
224
+
225
+ # Detect which column names are mentioned in the question
226
+ mentioned_num = [c for c in num_cols if c.lower() in q_low]
227
+ mentioned_cat = [c for c in cat_cols if c.lower() in q_low]
228
+
229
+ # Pattern: "average X by Y" / "count per Y"
230
+ if has_group:
231
+ grp_col = mentioned_cat[0] if mentioned_cat else (cat_cols[0] if cat_cols else None)
232
+ agg_col = mentioned_num[0] if mentioned_num else (num_cols[0] if num_cols else None)
233
+ if grp_col:
234
+ if agg_col and has_agg:
235
+ # Multiple aggregations for richness
236
+ return (
237
+ f'SELECT "{grp_col}", COUNT(*) AS count, '
238
+ f'ROUND(AVG("{agg_col}"), 2) AS avg_{agg_col.lower()}, '
239
+ f'ROUND(MIN("{agg_col}"), 2) AS min_{agg_col.lower()}, '
240
+ f'ROUND(MAX("{agg_col}"), 2) AS max_{agg_col.lower()} '
241
+ f'FROM "{table}" GROUP BY "{grp_col}" ORDER BY count DESC LIMIT 20'
242
+ )
243
+ else:
244
+ return (
245
+ f'SELECT "{grp_col}", COUNT(*) AS count '
246
+ f'FROM "{table}" GROUP BY "{grp_col}" ORDER BY count DESC LIMIT 20'
247
+ )
248
+
249
+ # Pattern: "top N …" or "highest / most"
250
+ top_m = re.search(r'\btop\s+(\d+)\b', q_low)
251
+ if top_m:
252
+ n = top_m.group(1)
253
+ sort_col = mentioned_num[0] if mentioned_num else (num_cols[0] if num_cols else "id")
254
+ return f'SELECT * FROM "{table}" ORDER BY "{sort_col}" DESC LIMIT {n}'
255
+
256
+ if any(w in q_low for w in ["highest", "most", "maximum", "largest"]):
257
+ sort_col = mentioned_num[0] if mentioned_num else (num_cols[0] if num_cols else "id")
258
+ return f'SELECT * FROM "{table}" ORDER BY "{sort_col}" DESC LIMIT 10'
259
+
260
+ if any(w in q_low for w in ["lowest", "least", "minimum", "smallest"]):
261
+ sort_col = mentioned_num[0] if mentioned_num else (num_cols[0] if num_cols else "id")
262
+ return f'SELECT * FROM "{table}" ORDER BY "{sort_col}" ASC LIMIT 10'
263
+
264
+ # Fallback: overall summary of numeric columns
265
+ if cat_cols:
266
+ c = cat_cols[0]
267
+ return (
268
+ f'SELECT "{c}", COUNT(*) AS count '
269
+ f'FROM "{table}" GROUP BY "{c}" ORDER BY count DESC LIMIT 20'
270
+ )
271
+
272
+ return None
273
+
274
+
275
+ def _generate_final_answer(
276
+ intent: str,
277
+ user_requirement: str,
278
+ issues_total: int,
279
+ issues_remaining: int,
280
+ columns_fixed: list[str],
281
+ query_df: pd.DataFrame,
282
+ auto_sql: str | None,
283
+ table: str,
284
+ columns: list[str],
285
+ ) -> str:
286
+ """Build the Final Answer markdown block shown at the end of all tasks."""
287
+
288
+ resolved = max(0, issues_total - issues_remaining)
289
+ fixed_cols_unique = sorted(set(columns_fixed))
290
+
291
+ # --- Cleaning summary ---
292
+ if issues_total == 0:
293
+ clean_summary = "Dataset had no detectable issues — already clean."
294
+ elif resolved == issues_total:
295
+ clean_summary = (
296
+ f"**All {issues_total} data quality issues resolved** "
297
+ + (f"across columns: `{'`, `'.join(fixed_cols_unique)}`." if fixed_cols_unique else ".")
298
+ )
299
+ else:
300
+ pct = round(100 * resolved / max(issues_total, 1))
301
+ clean_summary = (
302
+ f"**{resolved}/{issues_total} issues resolved ({pct}%)** "
303
+ + (f"across columns: `{'`, `'.join(fixed_cols_unique)}`." if fixed_cols_unique else ".")
304
+ + (f" {issues_remaining} issue(s) remain." if issues_remaining > 0 else "")
305
+ )
306
+
307
+ md = ["## Final Answer\n"]
308
+ md.append(f"### Cleaning Summary\n{clean_summary}\n")
309
+
310
+ safe_cols = [c for c in columns if c not in ("id", "_source_format")]
311
+
312
+ # --- Intent-specific section ---
313
+ if intent == "visualization":
314
+ req = user_requirement.strip() if user_requirement else "No description provided."
315
+ md.append(f"### Dashboard Requirement\n_{req}_\n")
316
+ num_cols = [c for c in safe_cols
317
+ if any(w in c.lower() for w in
318
+ ["fare", "age", "price", "rate", "score", "count", "survived",
319
+ "amount", "value", "petal", "sepal"])]
320
+ cat_cols = [c for c in safe_cols if c not in num_cols]
321
+ if num_cols:
322
+ md.append(f"**Recommended metric columns:** `{'`, `'.join(num_cols[:6])}`")
323
+ if cat_cols:
324
+ md.append(f"\n**Recommended dimension columns:** `{'`, `'.join(cat_cols[:6])}`")
325
+ md.append(
326
+ "\n\nThe dataset is now ready for your dashboard — "
327
+ "nulls filled, types corrected, categories normalised."
328
+ )
329
+
330
+ elif intent == "ml_training":
331
+ req = user_requirement.strip() if user_requirement else "No description provided."
332
+ md.append(f"### Model Requirement\n_{req}_\n")
333
+ feature_candidates = [c for c in safe_cols
334
+ if c.lower() not in ("name", "cabin", "ticket", "survived")]
335
+ target_candidates = [c for c in safe_cols if "survived" in c.lower() or "target" in c.lower()]
336
+ if feature_candidates:
337
+ md.append(f"**Suggested feature columns:** `{'`, `'.join(feature_candidates[:8])}`")
338
+ if target_candidates:
339
+ md.append(f"\n**Likely target column:** `{target_candidates[0]}`")
340
+ md.append(
341
+ "\n\nThe dataset is now ready for ML training — "
342
+ "nulls imputed with column medians/modes, outliers corrected, "
343
+ "negative constraint violations fixed."
344
+ )
345
+
346
+ elif intent == "business_query":
347
+ req = user_requirement.strip() if user_requirement else "No question provided."
348
+ md.append(f"### Business Question\n_{req}_\n")
349
+
350
+ if not query_df.empty:
351
+ md.append(f"### Answer\n")
352
+ md.append(_df_to_md(query_df))
353
+ md.append("\n")
354
+ # Generate a brief narrative from the first row
355
+ try:
356
+ cols_list = list(query_df.columns)
357
+ if len(cols_list) >= 2 and len(query_df) > 0:
358
+ top = query_df.iloc[0]
359
+ insights = []
360
+ for c in cols_list[1:4]:
361
+ val = top[c]
362
+ if isinstance(val, float):
363
+ val = round(val, 2)
364
+ insights.append(f"{c} = **{val}**")
365
+ md.append(
366
+ f"\n**Top result** — {cols_list[0]} = **{top[cols_list[0]]}**: "
367
+ + ", ".join(insights) + "\n"
368
+ )
369
+ except Exception:
370
+ pass
371
+
372
+ if auto_sql:
373
+ md.append(f"\n<details><summary>SQL used</summary>\n\n```sql\n{auto_sql}\n```\n</details>\n")
374
+ else:
375
+ md.append(
376
+ "\n_No query results available. "
377
+ "Add a SQL query in the **Business SQL Query** field and run again, "
378
+ "or make sure your question contains group-by keywords like "
379
+ "\"by class\", \"per gender\", \"average fare\"._\n"
380
+ )
381
+
382
+ md.append(
383
+ f"\n---\n*Cleaned data available via the Download button above. "
384
+ f"Table: `{table}` · Columns: {len(safe_cols)}*"
385
+ )
386
+ return "\n".join(md)
387
 
388
 
389
  # ---------------------------------------------------------------------------
390
  # Tab 1: Data Quality Scanner
391
  # ---------------------------------------------------------------------------
392
 
393
+ def scan_dataset(file_path: str | None, hf_name: str):
394
  from server.database import DatabaseEngine
395
 
396
  source, err = _resolve_source(file_path, hf_name)
397
  if err:
398
  return err, [], [], [], []
 
399
  try:
400
+ db = DatabaseEngine(task_id="bq_hard", seed=42,
401
+ dataset_source=source, max_rows=500)
402
  except Exception as exc:
403
  return f"**Error:** {_friendly_error(exc, source)}", [], [], [], []
404
 
405
+ table = db.primary_table
406
+ issues = db.issue_registry
407
+ profiles = db._profiles.get(table, {})
408
+ total_rows = len(db.rows(table))
409
+ total_cols = sum(1 for c in profiles if c not in ("id", "_source_format"))
410
+ n_issues = len(issues)
411
+ cells = max(total_rows * total_cols, 1)
412
+ quality_pct = max(0.0, min(1.0, 1.0 - n_issues / cells)) * 100
413
+ grade = ("Excellent" if quality_pct >= 90 else
414
+ "Good" if quality_pct >= 70 else
415
+ "Fair" if quality_pct >= 50 else "Poor")
416
 
417
  type_counts: dict[str, int] = {}
418
  for iss in issues:
419
  type_counts[iss.issue_type] = type_counts.get(iss.issue_type, 0) + 1
420
 
421
+ breakdown = "\n".join(
422
  f"| `{t}` | {c} |"
423
  for t, c in sorted(type_counts.items(), key=lambda x: -x[1])
424
  )
 
430
  | **Table** | `{table}` |
431
  | **Rows** | {total_rows:,} |
432
  | **Columns** | {total_cols} |
433
+ | **Issues found** | {n_issues} |
434
  | **Quality score** | **{quality_pct:.1f}% — {grade}** |
435
 
436
  ### Issue Breakdown
437
 
438
  | Issue Type | Count |
439
  |------------|-------|
440
+ {breakdown}
441
  """
442
 
443
  col_rows = []
 
448
  col_issues = sum(1 for i in issues if i.column == col)
449
  health = ("Good" if null_rate < 0.05 and col_issues == 0 else
450
  "Fair" if null_rate < 0.30 and col_issues <= 2 else "Poor")
451
+ col_rows.append([col, p.get("dtype", "?"),
452
+ f"{null_rate*100:.1f}%", col_issues, health])
453
 
454
+ issue_rows = [[t, c] for t, c in sorted(type_counts.items(), key=lambda x: -x[1])]
455
+ sample_rows = [
456
  [iss.issue_type, iss.column or "-", str(iss.row_id),
457
  f"{iss.confidence:.0%}",
458
  "delete row" if iss.correct is None else str(iss.correct)[:40]]
 
466
 
467
 
468
  # ---------------------------------------------------------------------------
469
+ # Tab 2: Agent Demo
470
  # ---------------------------------------------------------------------------
471
 
472
  def run_agent_streaming(
473
+ file1: str | None,
474
+ file2: str | None,
475
+ file3: str | None,
476
  hf_name: str,
477
  intent: str,
478
  output_format: str,
479
+ user_requirement: str,
480
+ biz_sql: str,
481
+ ) -> Generator[tuple[str, str, str | None, Any, Any, str, Any, str], None, None]:
482
+ """Yields (log, summary_md, download_path, before_df, after_df,
483
+ schema_md, query_df, final_answer_md)."""
484
  from server.environment import SQLSherlockEnvironment
485
  from models import SQLSherlockAction
486
 
487
+ # --- Resolve source ---
488
+ uploaded = [f for f in [file1, file2, file3] if f]
489
+ if len(uploaded) >= 2:
490
+ try:
491
+ source = _merge_to_xlsx(uploaded)
492
+ multi = True
493
+ except Exception as exc:
494
+ _empty = pd.DataFrame()
495
+ yield (f"Could not merge files: {exc}", "", None,
496
+ _empty, _empty, "", _empty, "")
497
+ return
498
+ elif len(uploaded) == 1:
499
+ source, multi = uploaded[0], False
500
+ else:
501
+ source, err = _resolve_source(None, hf_name)
502
+ multi = False
503
+ if not source:
504
+ _empty = pd.DataFrame()
505
+ yield (err or "No dataset provided.", "", None,
506
+ _empty, _empty, "", _empty, "")
507
+ return
508
 
509
  task_ids = INTENT_TASK_MAP.get(intent, INTENT_TASK_MAP["visualization"])
510
  intent_label = INTENT_LABELS.get(intent, intent)
511
 
512
+ log_lines: list[str] = []
513
+ all_scores: list[tuple[str, float]] = []
514
  download_path: str | None = None
515
+ summary_md = "_Running…_"
516
+ before_df = pd.DataFrame()
517
+ after_df = pd.DataFrame()
518
+ schema_md = ""
519
+ query_df = pd.DataFrame()
520
+ final_answer = ""
521
+ auto_sql_used: str | None = None
522
+
523
+ # Track cleaning stats across all tasks (Hard task is what matters for final answer)
524
+ hard_issues_total = 0
525
+ hard_issues_remain = 0
526
+ hard_columns_fixed: list[str] = []
527
+ hard_table = ""
528
+ hard_columns: list[str] = []
529
 
530
  def _log(*parts: str) -> None:
531
+ log_lines.append(" ".join(str(p) for p in parts))
532
 
533
+ def emit():
534
+ return ("\n".join(log_lines), summary_md, download_path,
535
+ before_df, after_df, schema_md, query_df, final_answer)
536
 
537
+ src_label = f"{source} ({len(uploaded)} tables merged)" if multi else source
538
  _log(f"Intent : {intent_label}")
539
+ _log(f"Dataset: {src_label}")
540
  _log(f"Format : {output_format}")
541
+ if user_requirement.strip():
542
+ _log(f"Goal : {user_requirement.strip()[:100]}")
543
  _log("")
544
+ yield emit()
545
 
546
  for task_id in task_ids:
547
  difficulty = task_id.split("_")[-1].upper()
548
  _log(f"{'─'*60}")
549
  _log(f"[{difficulty}] {task_id}")
550
  _log(f"{'─'*60}")
551
+ yield emit()
552
 
553
+ env = SQLSherlockEnvironment()
554
+ score = 0.0
555
+ step = 0
556
+ done = False
557
+ obs = None
558
  rewards: list[float] = []
559
+ cols_fixed_this_task: list[str] = []
560
 
561
  def _do(action_dict: dict) -> float:
562
  nonlocal step, done, obs
563
  if done:
564
  return 0.0
565
  step += 1
566
+ filtered = {k: v for k, v in action_dict.items() if v is not None}
567
+ action = SQLSherlockAction(**filtered)
 
568
  reward = 0.0
569
  try:
570
  obs = env.step(action)
 
582
  intent=intent,
583
  output_format=output_format,
584
  )
585
+ all_tables = list(obs.tables_summary.keys())
586
+ table = all_tables[0]
587
+ t_info = obs.tables_summary[table]
588
+ visible = [c for c in t_info.get("columns", [])
589
+ if c not in ("id", "_source_format")]
590
+
591
+ # Schema for multi-table datasets (built once)
592
+ if len(all_tables) > 1 and not schema_md:
593
+ schema_md = _schema_md(obs.tables_summary)
594
+
595
+ # Before state captured from Easy task (first run only)
596
+ if task_id == task_ids[0] and hasattr(env, "_db") and env._db:
597
+ before_df = _rows_to_df(env._db.original_state())
598
+
599
+ # Hard task stats
600
+ if task_id.endswith("_hard") and hasattr(env, "_db") and env._db:
601
+ hard_issues_total = env._db.total_issues
602
+ hard_table = table
603
+ hard_columns = visible
604
+
605
+ tbl_note = f"Tables: {all_tables}" if len(all_tables) > 1 else f"Table: {table}"
606
+ _log(f" {tbl_note} ({t_info.get('row_count','?')} rows, {len(visible)} cols)")
607
+ yield emit()
608
 
609
  # Phase 1: Inspect
610
  r = _do({"action_type": "inspect", "table": table})
611
+ _log(f" [{step:02d}] inspect reward={r:+.2f}")
612
+ yield emit()
613
+
614
+ # Phase 1b: Multi-table — select + join
615
+ if len(all_tables) > 1:
616
+ r = _do({"action_type": "select_tables", "tables": all_tables})
617
+ _log(f" [{step:02d}] select_tables({all_tables}) reward={r:+.2f}")
618
+ yield emit()
619
+ if len(all_tables) >= 2 and not done:
620
+ t2_name = all_tables[1]
621
+ t2_cols = obs.tables_summary.get(t2_name, {}).get("columns", [])
622
+ shared = [c for c in visible if c in t2_cols
623
+ and c not in ("id", "_source_format")]
624
+ jkey = shared[0] if shared else (visible[0] if visible else None)
625
+ if jkey:
626
+ r = _do({"action_type": "join_tables",
627
+ "table": table, "table2": t2_name, "key": jkey})
628
+ _log(f" [{step:02d}] join({table},{t2_name},key={jkey}) reward={r:+.2f}")
629
+ yield emit()
630
 
631
  # Phase 2: Profile + bulk-fix
632
  for col in visible[:12]:
 
637
  p = obs.query_result[0]
638
  nc = p.get("null_count", 0)
639
  dt = p.get("dtype", "?")
640
+ _log(f" [{step:02d}] profile({col:<12}) type={dt} nulls={nc} reward={r:+.2f}")
641
+ yield emit()
 
642
  if nc > 0 and not done:
643
+ fill = (p.get("median") or p.get("mean") or 0) if dt in ("int","float") \
644
+ else (p.get("mode") or "Unknown")
645
+ strategy = "median" if dt in ("int","float") else "mode"
646
  r2 = _do({
647
  "action_type": "fix_column", "table": table,
648
  "column": col, "value": fill,
649
+ "reason": f"bulk fix {nc} nulls in {col}, {strategy}={fill}",
650
  })
651
+ _log(f" [{step:02d}] fix_column({col:<12}, {str(fill)[:8]!r}) reward={r2:+.2f}")
652
  if r2 > 0:
653
+ cols_fixed_this_task.append(col)
654
+ yield emit()
655
  else:
656
+ _log(f" [{step:02d}] profile({col:<12}) reward={r:+.2f}")
657
+ yield emit()
658
 
659
  # Phase 3: Validate
660
  if not done:
661
  r = _do({"action_type": "validate"})
662
  fb = (obs.last_feedback if obs else "")[:100]
663
+ _log(f" [{step:02d}] validate reward={r:+.2f} {fb}")
664
+ yield emit()
665
+
666
+ # Phase 4: Business query SQL (run before export so DB is still live)
667
+ if not done and task_id.endswith("_hard"):
668
+ # Determine SQL to run
669
+ run_sql = biz_sql.strip() if biz_sql.strip() else None
670
+ if run_sql is None and intent == "business_query" and user_requirement.strip():
671
+ run_sql = _nl_to_sql(user_requirement, table, visible)
672
+ if run_sql:
673
+ auto_sql_used = run_sql
674
+ _log(f" [auto-SQL generated from question]")
675
+ _log(f" → {run_sql[:80]}{'…' if len(run_sql) > 80 else ''}")
676
+ yield emit()
677
+
678
+ if run_sql and run_sql.upper().startswith("SELECT"):
679
+ r = _do({"action_type": "run_sql", "sql": run_sql[:500]})
680
+ if obs and obs.query_result:
681
+ query_df = pd.DataFrame(obs.query_result)
682
+ _log(f" [{step:02d}] run_sql → {len(obs.query_result)} rows reward={r:+.2f}")
683
+ else:
684
+ _log(f" [{step:02d}] run_sql → no results reward={r:+.2f}")
685
+ yield emit()
686
+
687
+ # Phase 5: Export
688
  if not done:
689
+ # Record issues remaining before export (Hard task only)
690
+ if task_id.endswith("_hard") and hasattr(env, "_db") and env._db:
691
+ hard_issues_remain = env._db.issues_remaining()
692
+ hard_columns_fixed = cols_fixed_this_task[:]
693
+
694
  r = _do({"action_type": "export"})
695
+ fb = (obs.last_feedback if obs else "")[:130]
696
+ _log(f" [{step:02d}] export reward={r:+.2f}")
697
  _log(f" {fb}")
698
  m = re.search(r"[Gg]rader\s+score\s*=?\s*(\d+\.\d+)", fb)
699
  if m:
700
  score = float(m.group(1))
701
+
702
+ # Capture after-state and download from Hard task
703
+ if task_id.endswith("_hard") and hasattr(env, "_db") and env._db:
704
+ after_df = _rows_to_df(env._db.current_state())
705
+ if hasattr(env, "_export_result") and env._export_result:
706
+ download_path = env._export_result.get("filepath")
707
+
708
+ # Build final answer now that we have all data
709
+ final_answer = _generate_final_answer(
710
+ intent = intent,
711
+ user_requirement = user_requirement,
712
+ issues_total = hard_issues_total,
713
+ issues_remaining = hard_issues_remain,
714
+ columns_fixed = hard_columns_fixed,
715
+ query_df = query_df,
716
+ auto_sql = auto_sql_used,
717
+ table = hard_table or table,
718
+ columns = hard_columns or visible,
719
+ )
720
+
721
+ yield emit()
722
 
723
  except Exception as exc:
724
  _log(f" [ERROR] {_friendly_error(exc, source)}")
 
728
  except Exception:
729
  pass
730
 
 
731
  success = score >= 0.50
732
+ _log(f" Score: {score:.2f} {'SUCCESS ✓' if success else 'PARTIAL'} Steps: {step}")
 
733
  _log("")
734
  all_scores.append((task_id, score))
735
+ yield emit()
736
 
737
  # Final summary table
738
  avg = sum(s for _, s in all_scores) / len(all_scores) if all_scores else 0.0
739
  rows_md = "\n".join(
740
+ f"| `{tid}` | {s:.2f} | {'Pass ✓' if s >= 0.50 else 'Fail ✗'} |"
741
  for tid, s in all_scores
742
  )
743
  summary_md = f"""## Results — Intent: `{intent}`
 
750
 
751
  | Detail | Value |
752
  |--------|-------|
753
+ | Dataset | `{src_label}` |
754
+ | Format | `{output_format}` |
755
  | Tasks run | {len(all_scores)} (easy → medium → hard) |
756
+ {"| Download | cleaned file ready ↓ |" if download_path else ""}
757
+ {"| Answer | results shown ↓ |" if not query_df.empty else ""}
758
  """
759
+ yield emit()
760
 
761
 
762
  # ---------------------------------------------------------------------------
763
+ # Build Gradio UI
764
  # ---------------------------------------------------------------------------
765
 
766
+ def _intent_req_update(intent: str):
767
+ cfg = _REQ_CONFIG.get(intent, _REQ_CONFIG["visualization"])
768
+ return gr.update(label=cfg["label"], placeholder=cfg["placeholder"])
769
+
770
+
771
  def create_ui() -> gr.Blocks:
772
  with gr.Blocks(title="SQLSherlock — AI Data Quality Detective") as demo:
773
 
774
  gr.Markdown(
775
  "# SQLSherlock — AI Data Quality Detective\n"
776
+ "Describe what you want to do with your data — "
777
+ "the agent cleans it across **3 difficulty levels** and answers your question."
778
  )
779
 
780
  # ==================================================================
 
782
  # ==================================================================
783
  with gr.Tab("Data Quality Scanner"):
784
  gr.Markdown(
785
+ "Scan any dataset for data quality issues before running the agent.\n\n"
786
+ "> If both a file and a name are provided, the uploaded file takes priority."
787
  )
788
  with gr.Row():
789
  with gr.Column(scale=1):
790
+ gr.Markdown("#### Upload a file")
791
  scan_file = gr.File(
792
  label="CSV / JSON / JSONL / Parquet / Excel",
793
  file_types=[".csv", ".json", ".jsonl", ".parquet", ".xlsx"],
794
  type="filepath",
795
  )
796
+ gr.Markdown("#### OR — HuggingFace dataset")
797
  scan_hf = gr.Textbox(
798
+ label="Dataset name", value="phihung/titanic",
799
  placeholder="e.g. phihung/titanic",
 
800
  )
801
+ gr.Examples(examples=_EXAMPLE_HF, inputs=[scan_hf], label="Quick examples")
 
802
  scan_btn = gr.Button("Scan Dataset", variant="primary", size="lg")
803
 
804
  with gr.Column(scale=2):
 
829
  )
830
 
831
  # ==================================================================
832
+ # Tab 2: Agent Demo
833
  # ==================================================================
834
  with gr.Tab("Agent Demo"):
835
  gr.Markdown(
836
+ "**Describe what you want** — the agent cleans your data "
837
+ "and answers your question or prepares data for your specific use case."
 
838
  )
839
 
840
  with gr.Row():
841
+ # --- Dataset ---
842
  with gr.Column(scale=2):
843
+ gr.Markdown("#### Dataset (upload up to 3 tables)")
844
+ with gr.Row():
845
+ agent_file1 = gr.File(
846
+ label="Table 1 (primary)",
847
+ file_types=[".csv", ".json", ".jsonl", ".parquet", ".xlsx"],
848
+ type="filepath",
849
+ )
850
+ agent_file2 = gr.File(
851
+ label="Table 2 (optional)",
852
+ file_types=[".csv", ".json", ".jsonl", ".parquet", ".xlsx"],
853
+ type="filepath",
854
+ )
855
+ agent_file3 = gr.File(
856
+ label="Table 3 (optional)",
857
+ file_types=[".csv", ".json", ".jsonl", ".parquet", ".xlsx"],
858
+ type="filepath",
859
+ )
860
+ gr.Markdown(
861
+ "> Upload **2–3 files** for multi-table mode — they are merged into "
862
+ "one XLSX and the agent will inspect all schemas and attempt joins.\n\n"
863
+ "> OR enter a **HuggingFace dataset** name below (used only when no file is uploaded)."
864
  )
865
  agent_hf = gr.Textbox(
866
+ label="HuggingFace dataset name",
867
  value="phihung/titanic",
868
  placeholder="phihung/titanic",
869
  )
870
 
871
+ # --- Intent + Requirement ---
872
+ with gr.Column(scale=2):
873
  gr.Markdown("#### Cleaning Intent")
874
  agent_intent = gr.Radio(
875
  choices=INTENT_CHOICES,
876
  label="What will you use this data for?",
877
  value="visualization",
878
  )
879
+ # Dynamic text area — label and placeholder change with intent
880
+ requirement_input = gr.Textbox(
881
+ label=_REQ_CONFIG["visualization"]["label"],
882
+ placeholder=_REQ_CONFIG["visualization"]["placeholder"],
883
+ lines=3,
884
+ max_lines=6,
885
+ )
886
+
887
+ # --- Format ---
888
+ with gr.Column(scale=1):
889
  gr.Markdown("#### Output Format")
890
  agent_format = gr.Radio(
891
  choices=FORMAT_CHOICES,
 
893
  value="csv",
894
  )
895
 
896
+ # Update requirement box when intent changes
897
+ agent_intent.change(
898
+ fn=_intent_req_update,
899
+ inputs=[agent_intent],
900
+ outputs=[requirement_input],
901
+ )
902
+
903
+ # Advanced SQL box
904
+ with gr.Accordion("Advanced: Direct SQL Query (optional)", open=False):
905
+ gr.Markdown(
906
+ "Enter a `SELECT` query to run on the cleaned dataset after the Hard task. "
907
+ "If left empty and intent is **Business Analytics**, an SQL query is "
908
+ "auto-generated from your requirement text above.\n\n"
909
+ "Use the table name shown in the step log (e.g. `dataset`, `titanic`)."
910
+ )
911
+ biz_sql_input = gr.Textbox(
912
+ label="SQL Query",
913
+ placeholder=_SQL_PLACEHOLDER,
914
+ lines=5,
915
+ max_lines=12,
916
+ )
917
+
918
  gr.Markdown(
919
+ "> **Scoring cascade — Easy matters most:**\n"
920
+ "> Easy `res×0.80 + val×0.20` → Medium `easy×0.70 + advanced×0.30` "
921
+ "→ Hard `medium×0.70 + FK×0.30`"
 
922
  )
923
 
924
  gr.Examples(
925
  examples=_AGENT_EXAMPLES,
926
+ inputs=[agent_hf, agent_intent, requirement_input, agent_format, biz_sql_input],
927
  label="Quick examples",
928
  )
929
 
930
  run_btn = gr.Button("Run Agent (3 Tasks)", variant="primary", size="lg")
931
 
932
+ # --- Primary outputs ---
933
  with gr.Row():
934
  with gr.Column(scale=3):
935
  agent_log = gr.Textbox(
936
  label="Live Step Log",
937
+ lines=28, max_lines=120,
938
  interactive=False,
939
  )
940
  with gr.Column(scale=2):
 
942
  value="_Results appear here after the agent finishes._"
943
  )
944
 
945
+ schema_out = gr.Markdown(value="", label="Schema (multi-table)")
946
+
947
  gr.Markdown("#### Download Cleaned File (Hard task output)")
948
  download_out = gr.File(
949
+ label="Cleaned dataset",
950
  interactive=False,
951
  )
952
 
953
+ # --- Before / After ---
954
+ gr.Markdown("#### Before / After Comparison")
955
+ with gr.Row():
956
+ before_tbl = gr.Dataframe(
957
+ label="Original Data (before agent)",
958
+ interactive=False, wrap=True,
959
+ )
960
+ after_tbl = gr.Dataframe(
961
+ label="Cleaned Data (after Hard task)",
962
+ interactive=False, wrap=True,
963
+ )
964
+
965
+ # --- Final Answer ---
966
+ gr.Markdown("#### Final Answer")
967
+ final_answer_out = gr.Markdown(
968
+ value="_Final answer appears here after all 3 tasks complete._"
969
+ )
970
+
971
+ # --- Business query result table ---
972
+ gr.Markdown("#### Query Results")
973
+ query_result_tbl = gr.Dataframe(
974
+ label="SQL query output (on cleaned data)",
975
+ interactive=False, wrap=True,
976
+ )
977
+
978
+ def _run(f1, f2, f3, hf, intent, fmt, req, sql):
979
+ for log, summary, dl, bdf, adf, schema, qdf, answer in run_agent_streaming(
980
+ f1, f2, f3, hf, intent, fmt, req, sql
981
  ):
982
+ yield log, summary, dl, bdf, adf, schema, qdf, answer
983
 
984
  run_btn.click(
985
  fn=_run,
986
+ inputs=[agent_file1, agent_file2, agent_file3,
987
+ agent_hf, agent_intent, agent_format,
988
+ requirement_input, biz_sql_input],
989
+ outputs=[agent_log, agent_results, download_out,
990
+ before_tbl, after_tbl, schema_out,
991
+ query_result_tbl, final_answer_out],
992
  show_progress="minimal",
993
  )
994
 
 
997
  # ==================================================================
998
  with gr.Tab("About"):
999
  gr.Markdown("""
1000
+ ## How It Works
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1001
 
1002
+ 1. **Describe your goal** — dashboard, model, or business question
1003
+ 2. **Agent cleans Easy → Medium → Hard** for the selected intent
1004
+ 3. **Final answer** — cleaning summary + SQL results + narrative
1005
 
1006
+ ### Scoring (Easy → Medium → Hard cascade)
 
 
 
1007
 
1008
+ | Task | Formula | Key weight |
1009
+ |------|---------|------------|
1010
+ | **Easy** | `resolution × 0.80 + validation × 0.20` | Nulls/types/whitespace/categories |
1011
+ | **Medium** | `easy × 0.70 + advanced × 0.30` | Easy = 70% of medium |
1012
+ | **Hard** | `medium × 0.70 + FK × 0.30` | Medium = 70% of hard |
1013
 
1014
+ ### Multiple Tables
 
 
 
1015
 
1016
+ Upload **2–3 files** — each becomes a table (sheet in a merged XLSX).
1017
+ The agent will `select_tables`, inspect schemas, and attempt `join_tables`.
1018
 
1019
+ ### Business Query Answering
 
1020
 
1021
+ - Enter a **plain English question** in the requirement box
1022
+ - The agent auto-generates SQL from keywords (group-by, average, top-N, etc.)
1023
+ - Or provide exact SQL in the Advanced SQL field
1024
+ - Results appear in the **Query Results** table with a narrative
1025
 
1026
+ ### Intent-Specific Cleaning Focus
1027
 
1028
+ | Intent | Primary Issues | Output |
1029
+ |--------|---------------|--------|
1030
+ | **Visualization** | Nulls, whitespace, categories | Chart-ready columns list |
1031
+ | **ML Training** | Nulls, type errors, outliers, constraints | Feature/target column list |
1032
+ | **Business Analytics** | All types + FK + duplicates | SQL answer + narrative |
 
 
 
 
1033
  """)
1034
 
1035
  return demo
tests/conftest.py CHANGED
@@ -127,10 +127,10 @@ def clean_profile():
127
 
128
  @pytest.fixture
129
  def db_task1():
130
- """DatabaseEngine for task1 loaded from raw CSV text."""
131
  from server.database import DatabaseEngine
132
  db = DatabaseEngine(
133
- task_id="task1_null_and_types",
134
  seed=42,
135
  dataset_source=RAW_CSV_TEXT,
136
  max_rows=50,
@@ -140,10 +140,10 @@ def db_task1():
140
 
141
  @pytest.fixture
142
  def db_task2():
143
- """DatabaseEngine for task2 loaded from raw CSV text."""
144
  from server.database import DatabaseEngine
145
  db = DatabaseEngine(
146
- task_id="task2_constraints_and_fk",
147
  seed=42,
148
  dataset_source=RAW_CSV_TEXT,
149
  max_rows=50,
@@ -153,10 +153,10 @@ def db_task2():
153
 
154
  @pytest.fixture
155
  def db_task3():
156
- """DatabaseEngine for task3 loaded from raw CSV text."""
157
  from server.database import DatabaseEngine
158
  db = DatabaseEngine(
159
- task_id="task3_full_audit_with_trap",
160
  seed=42,
161
  dataset_source=RAW_CSV_TEXT,
162
  max_rows=50,
@@ -170,7 +170,7 @@ def db_task3():
170
 
171
  @pytest.fixture
172
  def task1_issues(dirty_conn, dirty_profile):
173
- """Issues detected for task1 on the dirty dataset."""
174
  from server.issue_detector import detect_issues
175
  import copy
176
  records = copy.deepcopy(DIRTY_RECORDS)
@@ -178,14 +178,14 @@ def task1_issues(dirty_conn, dirty_profile):
178
  conn=dirty_conn,
179
  profile=dirty_profile,
180
  records=records,
181
- task_id="task1_null_and_types",
182
  seed=42,
183
  )
184
 
185
 
186
  @pytest.fixture
187
  def task3_issues(dirty_conn, dirty_profile):
188
- """Issues detected for task3 on the dirty dataset."""
189
  from server.issue_detector import detect_issues
190
  import copy
191
  records = copy.deepcopy(DIRTY_RECORDS)
@@ -193,6 +193,54 @@ def task3_issues(dirty_conn, dirty_profile):
193
  conn=dirty_conn,
194
  profile=dirty_profile,
195
  records=records,
196
- task_id="task3_full_audit_with_trap",
197
  seed=42,
198
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
  @pytest.fixture
129
  def db_task1():
130
+ """DatabaseEngine for easy task (viz_easy) loaded from raw CSV text."""
131
  from server.database import DatabaseEngine
132
  db = DatabaseEngine(
133
+ task_id="viz_easy",
134
  seed=42,
135
  dataset_source=RAW_CSV_TEXT,
136
  max_rows=50,
 
140
 
141
  @pytest.fixture
142
  def db_task2():
143
+ """DatabaseEngine for medium task (ml_medium) loaded from raw CSV text."""
144
  from server.database import DatabaseEngine
145
  db = DatabaseEngine(
146
+ task_id="ml_medium",
147
  seed=42,
148
  dataset_source=RAW_CSV_TEXT,
149
  max_rows=50,
 
153
 
154
  @pytest.fixture
155
  def db_task3():
156
+ """DatabaseEngine for hard task (bq_hard) loaded from raw CSV text."""
157
  from server.database import DatabaseEngine
158
  db = DatabaseEngine(
159
+ task_id="bq_hard",
160
  seed=42,
161
  dataset_source=RAW_CSV_TEXT,
162
  max_rows=50,
 
170
 
171
  @pytest.fixture
172
  def task1_issues(dirty_conn, dirty_profile):
173
+ """Issues detected for viz_easy task on the dirty dataset."""
174
  from server.issue_detector import detect_issues
175
  import copy
176
  records = copy.deepcopy(DIRTY_RECORDS)
 
178
  conn=dirty_conn,
179
  profile=dirty_profile,
180
  records=records,
181
+ task_id="viz_easy",
182
  seed=42,
183
  )
184
 
185
 
186
  @pytest.fixture
187
  def task3_issues(dirty_conn, dirty_profile):
188
+ """Issues detected for bq_hard task on the dirty dataset."""
189
  from server.issue_detector import detect_issues
190
  import copy
191
  records = copy.deepcopy(DIRTY_RECORDS)
 
193
  conn=dirty_conn,
194
  profile=dirty_profile,
195
  records=records,
196
+ task_id="bq_hard",
197
  seed=42,
198
  )
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Multi-table XLSX fixture
203
+ # ---------------------------------------------------------------------------
204
+
205
+ @pytest.fixture
206
+ def multi_table_xlsx_path():
207
+ """Create a temp XLSX with two sheets for multi-table testing.
208
+
209
+ Sheet 1 — 'passengers': 10 rows with null/type/constraint issues.
210
+ Sheet 2 — 'classes': 3 rows (lookup table, clean).
211
+ Both sheets share the 'id' column so join_tables can be tested.
212
+ """
213
+ import tempfile
214
+ import pandas as pd
215
+
216
+ df_passengers = pd.DataFrame([
217
+ {"id": 1, "name": "Alice", "age": None, "fare": 10.50, "survived": 1},
218
+ {"id": 2, "name": "Bob", "age": 25, "fare": 7.25, "survived": 0},
219
+ {"id": 3, "name": "Carol", "age": "FORTY", "fare": 15.00, "survived": 1},
220
+ {"id": 4, "name": "Dave", "age": -5, "fare": 8.00, "survived": 0},
221
+ {"id": 5, "name": "Eve", "age": 28, "fare": 12.00, "survived": 1},
222
+ {"id": 6, "name": "Frank", "age": 45, "fare": 9.75, "survived": 0},
223
+ {"id": 7, "name": "Grace", "age": 33, "fare": 11.50, "survived": 1},
224
+ {"id": 8, "name": "Heidi", "age": 29, "fare": 6.50, "survived": 0},
225
+ {"id": 9, "name": "Ivan", "age": 38, "fare": 13.25, "survived": 1},
226
+ {"id": 10, "name": "Judy", "age": 22, "fare": 5.00, "survived": 0},
227
+ ])
228
+
229
+ df_classes = pd.DataFrame([
230
+ {"id": 1, "class_name": "First", "min_fare": 30.0},
231
+ {"id": 2, "class_name": "Second", "min_fare": 10.0},
232
+ {"id": 3, "class_name": "Third", "min_fare": 5.0},
233
+ {"id": 4, "class_name": "Fourth", "min_fare": 3.0},
234
+ {"id": 5, "class_name": "Fifth", "min_fare": 2.0},
235
+ ])
236
+
237
+ tmp = tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False)
238
+ tmp.close()
239
+
240
+ with pd.ExcelWriter(tmp.name, engine="openpyxl") as writer:
241
+ df_passengers.to_excel(writer, sheet_name="passengers", index=False)
242
+ df_classes.to_excel(writer, sheet_name="classes", index=False)
243
+
244
+ yield tmp.name
245
+
246
+ os.unlink(tmp.name)
tests/test_environment.py CHANGED
@@ -588,3 +588,201 @@ class TestJoinTables:
588
  ))
589
  state = env_task1.get_state()
590
  assert state.joins_performed == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  ))
589
  state = env_task1.get_state()
590
  assert state.joins_performed == 1
591
+
592
+
593
+ # ---------------------------------------------------------------------------
594
+ # Multi-table input (2-sheet XLSX)
595
+ # ---------------------------------------------------------------------------
596
+
597
+ class TestMultiTableInput:
598
+ """Tests for loading and operating on a 2-sheet XLSX dataset."""
599
+
600
+ @pytest.fixture
601
+ def env_multi(self, env, multi_table_xlsx_path):
602
+ """Reset env with the 2-sheet XLSX — both tables are available."""
603
+ env.reset(dataset=multi_table_xlsx_path, task_id="viz_easy")
604
+ return env
605
+
606
+ # ------------------------------------------------------------------
607
+ # Loading
608
+ # ------------------------------------------------------------------
609
+
610
+ def test_two_tables_loaded(self, env_multi):
611
+ """Both XLSX sheets must appear as separate SQLite tables."""
612
+ tables = list(env_multi._db.table_names())
613
+ assert len(tables) == 2, f"Expected 2 tables, got {tables}"
614
+
615
+ def test_passengers_table_present(self, env_multi):
616
+ tables = list(env_multi._db.table_names())
617
+ assert "passengers" in tables
618
+
619
+ def test_classes_table_present(self, env_multi):
620
+ tables = list(env_multi._db.table_names())
621
+ assert "classes" in tables
622
+
623
+ def test_passengers_row_count(self, env_multi):
624
+ rows = env_multi._db.rows("passengers")
625
+ assert len(rows) == 10
626
+
627
+ def test_classes_row_count(self, env_multi):
628
+ rows = env_multi._db.rows("classes")
629
+ assert len(rows) == 5
630
+
631
+ # ------------------------------------------------------------------
632
+ # select_tables — reward should be positive for ≥2 tables
633
+ # ------------------------------------------------------------------
634
+
635
+ def test_select_tables_rewards_when_multi_table(self, env_multi):
636
+ """select_tables earns +0.02 when the dataset has ≥2 tables."""
637
+ obs, reward, done, _ = _step(env_multi,
638
+ SQLSherlockAction(action_type="select_tables",
639
+ tables=["passengers", "classes"])
640
+ )
641
+ assert reward > 0, f"Expected positive reward, got {reward}"
642
+
643
+ def test_select_tables_updates_state_with_both_tables(self, env_multi):
644
+ env_multi.step(SQLSherlockAction(
645
+ action_type="select_tables",
646
+ tables=["passengers", "classes"],
647
+ ))
648
+ state = env_multi.get_state()
649
+ assert "passengers" in state.tables_selected
650
+ assert "classes" in state.tables_selected
651
+
652
+ # ------------------------------------------------------------------
653
+ # inspect second table
654
+ # ------------------------------------------------------------------
655
+
656
+ def test_inspect_classes_table(self, env_multi):
657
+ obs, reward, done, _ = _step(env_multi,
658
+ SQLSherlockAction(action_type="inspect", table="classes")
659
+ )
660
+ assert obs.query_result is not None
661
+ assert len(obs.query_result) == 5
662
+
663
+ def test_inspect_classes_has_class_name_column(self, env_multi):
664
+ obs, _, _, _ = _step(env_multi,
665
+ SQLSherlockAction(action_type="inspect", table="classes")
666
+ )
667
+ assert "class_name" in obs.query_result[0]
668
+
669
+ # ------------------------------------------------------------------
670
+ # profile_column on second table
671
+ # ------------------------------------------------------------------
672
+
673
+ def test_profile_column_on_classes_table(self, env_multi):
674
+ obs, reward, done, _ = _step(env_multi,
675
+ SQLSherlockAction(action_type="profile_column",
676
+ table="classes", column="min_fare")
677
+ )
678
+ assert obs.query_result is not None
679
+ profile = obs.query_result[0]
680
+ assert "mean" in profile
681
+ assert "median" in profile
682
+
683
+ def test_profile_column_classes_min_fare_mean(self, env_multi):
684
+ """min_fare values are 30.0, 10.0, 5.0, 3.0, 2.0 → mean = 10.0."""
685
+ obs, _, _, _ = _step(env_multi,
686
+ SQLSherlockAction(action_type="profile_column",
687
+ table="classes", column="min_fare")
688
+ )
689
+ profile = obs.query_result[0]
690
+ assert abs(profile["mean"] - 10.0) < 1.0
691
+
692
+ # ------------------------------------------------------------------
693
+ # join_tables between the two real tables
694
+ # ------------------------------------------------------------------
695
+
696
+ def test_join_tables_valid_gives_positive_reward(self, env_multi):
697
+ """join_tables on a shared key column earns +0.20."""
698
+ obs, reward, done, _ = _step(env_multi,
699
+ SQLSherlockAction(
700
+ action_type="join_tables",
701
+ table="passengers",
702
+ table2="classes",
703
+ key="id",
704
+ )
705
+ )
706
+ assert reward > 0, f"Expected positive reward for valid join, got {reward}"
707
+
708
+ def test_join_tables_valid_increments_joins_performed(self, env_multi):
709
+ env_multi.step(SQLSherlockAction(
710
+ action_type="join_tables",
711
+ table="passengers",
712
+ table2="classes",
713
+ key="id",
714
+ ))
715
+ state = env_multi.get_state()
716
+ assert state.joins_performed == 1
717
+
718
+ def test_join_tables_result_has_columns_from_both_tables(self, env_multi):
719
+ obs, _, _, _ = _step(env_multi,
720
+ SQLSherlockAction(
721
+ action_type="join_tables",
722
+ table="passengers",
723
+ table2="classes",
724
+ key="id",
725
+ )
726
+ )
727
+ # Join result should appear in query_result or last_feedback
728
+ # At minimum the step must not error
729
+ assert "error" not in (obs.last_feedback or "").lower() or obs.query_result is not None
730
+
731
+ # ------------------------------------------------------------------
732
+ # run_sql across two tables
733
+ # ------------------------------------------------------------------
734
+
735
+ def test_run_sql_on_second_table(self, env_multi):
736
+ obs, reward, done, _ = _step(env_multi,
737
+ SQLSherlockAction(
738
+ action_type="run_sql",
739
+ sql='SELECT class_name, min_fare FROM "classes" ORDER BY min_fare DESC',
740
+ )
741
+ )
742
+ assert obs.query_result is not None
743
+ assert len(obs.query_result) == 5
744
+ # Should be ordered descending — First class has highest fare
745
+ assert obs.query_result[0]["class_name"] == "First"
746
+
747
+ def test_run_sql_cross_table_join(self, env_multi):
748
+ obs, _, _, _ = _step(env_multi,
749
+ SQLSherlockAction(
750
+ action_type="run_sql",
751
+ sql=(
752
+ 'SELECT p.name, c.class_name '
753
+ 'FROM "passengers" p '
754
+ 'LEFT JOIN "classes" c ON p.id = c.id '
755
+ 'LIMIT 3'
756
+ ),
757
+ )
758
+ )
759
+ assert obs.query_result is not None
760
+ assert len(obs.query_result) == 3
761
+
762
+ # ------------------------------------------------------------------
763
+ # fix_column on second table
764
+ # ------------------------------------------------------------------
765
+
766
+ def test_fix_column_no_issues_on_classes(self, env_multi):
767
+ """Applying fix_column to a clean column gives a negative reward (false positive)."""
768
+ obs, reward, done, _ = _step(env_multi,
769
+ SQLSherlockAction(
770
+ action_type="fix_column",
771
+ table="classes",
772
+ column="min_fare",
773
+ value=10.0,
774
+ reason="test unnecessary fix",
775
+ )
776
+ )
777
+ # Clean column → negative reward (−0.10 for fix_column with no issues)
778
+ assert reward <= 0, f"Expected ≤ 0 reward for clean column fix, got {reward}"
779
+
780
+ # ------------------------------------------------------------------
781
+ # Multi-table episode reaches done correctly
782
+ # ------------------------------------------------------------------
783
+
784
+ def test_multi_table_submit_ends_episode(self, env_multi):
785
+ _, _, done, _ = _step(env_multi,
786
+ SQLSherlockAction(action_type="submit")
787
+ )
788
+ assert done is True
tests/test_graders.py CHANGED
@@ -111,7 +111,7 @@ class TestZeroChangeGuard:
111
  db=db_task1,
112
  cleaned_rows=dirty,
113
  removed_ids=[],
114
- task_id="task1_null_and_types",
115
  validation_was_called=False,
116
  )
117
  assert score == 0.0
@@ -136,7 +136,7 @@ class TestTask1Grader:
136
  db=db_task1,
137
  cleaned_rows=cleaned,
138
  removed_ids=removed,
139
- task_id="task1_null_and_types",
140
  validation_was_called=True,
141
  )
142
  assert score >= 0.60, f"Expected >= 0.60 after full fix, got {score}"
@@ -147,7 +147,7 @@ class TestTask1Grader:
147
  db=db_task1,
148
  cleaned_rows=dirty,
149
  removed_ids=[],
150
- task_id="task1_null_and_types",
151
  validation_was_called=False,
152
  )
153
  assert score == 0.0
@@ -158,15 +158,15 @@ class TestTask1Grader:
158
  db=db_task1,
159
  cleaned_rows=cleaned,
160
  removed_ids=[],
161
- task_id="task1_null_and_types",
162
  validation_was_called=True,
163
  )
164
  assert 0.0 <= score <= 1.0
165
 
166
  def test_no_validate_penalty(self, db_task1):
167
  cleaned = _apply_all_fixes(db_task1)
168
- score_with = graders.grade(db_task1, cleaned, [], "task1_null_and_types", True)
169
- score_without = graders.grade(db_task1, cleaned, [], "task1_null_and_types", False)
170
  assert score_with >= score_without
171
 
172
  def test_false_positive_reduces_score(self, db_task1):
@@ -177,8 +177,8 @@ class TestTask1Grader:
177
  if row.get("survived") is not None:
178
  row["survived"] = 99 # not an issue
179
  break
180
- score_fp = graders.grade(db_task1, clean_copy, [], "task1_null_and_types", True)
181
- score_ok = graders.grade(db_task1, cleaned, [], "task1_null_and_types", True)
182
  assert score_fp <= score_ok
183
 
184
 
@@ -197,7 +197,7 @@ class TestTask2Grader:
197
  db=db_task2,
198
  cleaned_rows=cleaned,
199
  removed_ids=removed,
200
- task_id="task2_constraints_and_fk",
201
  validation_was_called=True,
202
  )
203
  assert score >= 0.50, f"Expected >= 0.50 after full fix, got {score}"
@@ -208,7 +208,7 @@ class TestTask2Grader:
208
  db=db_task2,
209
  cleaned_rows=cleaned,
210
  removed_ids=[],
211
- task_id="task2_constraints_and_fk",
212
  validation_was_called=True,
213
  )
214
  assert 0.0 <= score <= 1.0
@@ -217,8 +217,8 @@ class TestTask2Grader:
217
  """task2 weight means full fix may score differently — both must be in range."""
218
  c1 = _apply_all_fixes(db_task1)
219
  c2 = _apply_all_fixes(db_task2)
220
- s1 = graders.grade(db_task1, c1, [], "task1_null_and_types", True)
221
- s2 = graders.grade(db_task2, c2, [], "task2_constraints_and_fk", True)
222
  assert 0.0 <= s1 <= 1.0
223
  assert 0.0 <= s2 <= 1.0
224
 
@@ -234,7 +234,7 @@ class TestTask3Grader:
234
  db=db_task3,
235
  cleaned_rows=cleaned,
236
  removed_ids=[],
237
- task_id="task3_full_audit_with_trap",
238
  validation_was_called=True,
239
  )
240
  assert 0.0 <= score <= 1.0
@@ -256,11 +256,11 @@ class TestTask3Grader:
256
 
257
  score_untouched = graders.grade(
258
  db_task3, cleaned_no_touch, [],
259
- "task3_full_audit_with_trap", True,
260
  )
261
  score_touched = graders.grade(
262
  db_task3, cleaned_touched, [],
263
- "task3_full_audit_with_trap", True,
264
  )
265
  assert score_touched < score_untouched or score_touched <= score_untouched
266
 
@@ -282,7 +282,7 @@ class TestTask3Grader:
282
  cleaned = _apply_all_fixes(db_task3)
283
  score_with_reason = graders.grade(
284
  db_task3, cleaned, [],
285
- "task3_full_audit_with_trap", True,
286
  )
287
  assert score_with_reason >= 0.0
288
 
@@ -351,4 +351,4 @@ class TestFalsePositivePenalty:
351
  penalty = _false_positive_penalty(
352
  db_task1, tampered, [], db_task1.pk_col, db_task1.primary_table
353
  )
354
- assert penalty <= 0.20
 
111
  db=db_task1,
112
  cleaned_rows=dirty,
113
  removed_ids=[],
114
+ task_id="viz_easy",
115
  validation_was_called=False,
116
  )
117
  assert score == 0.0
 
136
  db=db_task1,
137
  cleaned_rows=cleaned,
138
  removed_ids=removed,
139
+ task_id="viz_easy",
140
  validation_was_called=True,
141
  )
142
  assert score >= 0.60, f"Expected >= 0.60 after full fix, got {score}"
 
147
  db=db_task1,
148
  cleaned_rows=dirty,
149
  removed_ids=[],
150
+ task_id="viz_easy",
151
  validation_was_called=False,
152
  )
153
  assert score == 0.0
 
158
  db=db_task1,
159
  cleaned_rows=cleaned,
160
  removed_ids=[],
161
+ task_id="viz_easy",
162
  validation_was_called=True,
163
  )
164
  assert 0.0 <= score <= 1.0
165
 
166
  def test_no_validate_penalty(self, db_task1):
167
  cleaned = _apply_all_fixes(db_task1)
168
+ score_with = graders.grade(db_task1, cleaned, [], "viz_easy", True)
169
+ score_without = graders.grade(db_task1, cleaned, [], "viz_easy", False)
170
  assert score_with >= score_without
171
 
172
  def test_false_positive_reduces_score(self, db_task1):
 
177
  if row.get("survived") is not None:
178
  row["survived"] = 99 # not an issue
179
  break
180
+ score_fp = graders.grade(db_task1, clean_copy, [], "viz_easy", True)
181
+ score_ok = graders.grade(db_task1, cleaned, [], "viz_easy", True)
182
  assert score_fp <= score_ok
183
 
184
 
 
197
  db=db_task2,
198
  cleaned_rows=cleaned,
199
  removed_ids=removed,
200
+ task_id="ml_medium",
201
  validation_was_called=True,
202
  )
203
  assert score >= 0.50, f"Expected >= 0.50 after full fix, got {score}"
 
208
  db=db_task2,
209
  cleaned_rows=cleaned,
210
  removed_ids=[],
211
+ task_id="ml_medium",
212
  validation_was_called=True,
213
  )
214
  assert 0.0 <= score <= 1.0
 
217
  """task2 weight means full fix may score differently — both must be in range."""
218
  c1 = _apply_all_fixes(db_task1)
219
  c2 = _apply_all_fixes(db_task2)
220
+ s1 = graders.grade(db_task1, c1, [], "viz_easy", True)
221
+ s2 = graders.grade(db_task2, c2, [], "ml_medium", True)
222
  assert 0.0 <= s1 <= 1.0
223
  assert 0.0 <= s2 <= 1.0
224
 
 
234
  db=db_task3,
235
  cleaned_rows=cleaned,
236
  removed_ids=[],
237
+ task_id="bq_hard",
238
  validation_was_called=True,
239
  )
240
  assert 0.0 <= score <= 1.0
 
256
 
257
  score_untouched = graders.grade(
258
  db_task3, cleaned_no_touch, [],
259
+ "bq_hard", True,
260
  )
261
  score_touched = graders.grade(
262
  db_task3, cleaned_touched, [],
263
+ "bq_hard", True,
264
  )
265
  assert score_touched < score_untouched or score_touched <= score_untouched
266
 
 
282
  cleaned = _apply_all_fixes(db_task3)
283
  score_with_reason = graders.grade(
284
  db_task3, cleaned, [],
285
+ "bq_hard", True,
286
  )
287
  assert score_with_reason >= 0.0
288
 
 
351
  penalty = _false_positive_penalty(
352
  db_task1, tampered, [], db_task1.pk_col, db_task1.primary_table
353
  )
354
+ assert penalty <= 0.35
tests/test_issue_detector.py CHANGED
@@ -231,25 +231,25 @@ class TestDetectIssues:
231
  def test_task1_minimum_issues(self, dirty_conn, dirty_profile):
232
  records = copy.deepcopy(DIRTY_RECORDS)
233
  issues = detect_issues(dirty_conn, dirty_profile, records,
234
- task_id="task1_null_and_types", seed=42)
235
- assert len(issues) >= MINIMUM_ISSUES["task1_null_and_types"]
236
 
237
  def test_task2_minimum_issues(self, dirty_conn, dirty_profile):
238
  records = copy.deepcopy(DIRTY_RECORDS)
239
  issues = detect_issues(dirty_conn, dirty_profile, records,
240
- task_id="task2_constraints_and_fk", seed=42)
241
- assert len(issues) >= MINIMUM_ISSUES["task2_constraints_and_fk"]
242
 
243
  def test_task3_minimum_issues(self, dirty_conn, dirty_profile):
244
  records = copy.deepcopy(DIRTY_RECORDS)
245
  issues = detect_issues(dirty_conn, dirty_profile, records,
246
- task_id="task3_full_audit_with_trap", seed=42)
247
- assert len(issues) >= MINIMUM_ISSUES["task3_full_audit_with_trap"]
248
 
249
  def test_task1_only_null_and_type_issues(self, dirty_conn, dirty_profile):
250
  records = copy.deepcopy(DIRTY_RECORDS)
251
  issues = detect_issues(dirty_conn, dirty_profile, records,
252
- task_id="task1_null_and_types", seed=42)
253
  for iss in issues:
254
  assert iss.issue_type in ("null", "type_error"), (
255
  f"task1 should only detect null/type_error, got {iss.issue_type}"
@@ -258,14 +258,14 @@ class TestDetectIssues:
258
  def test_no_duplicate_issue_ids(self, dirty_conn, dirty_profile):
259
  records = copy.deepcopy(DIRTY_RECORDS)
260
  issues = detect_issues(dirty_conn, dirty_profile, records,
261
- task_id="task3_full_audit_with_trap", seed=42)
262
  ids = [i.issue_id for i in issues]
263
  assert len(ids) == len(set(ids)), "Duplicate issue_ids found"
264
 
265
  def test_confidence_in_range(self, dirty_conn, dirty_profile):
266
  records = copy.deepcopy(DIRTY_RECORDS)
267
  issues = detect_issues(dirty_conn, dirty_profile, records,
268
- task_id="task3_full_audit_with_trap", seed=42)
269
  for iss in issues:
270
  assert 0.0 <= iss.confidence <= 1.0, (
271
  f"Issue {iss.issue_id} has out-of-range confidence {iss.confidence}"
@@ -275,8 +275,8 @@ class TestDetectIssues:
275
  """Clean data triggers synthetic top-up to meet minimum."""
276
  records = copy.deepcopy(CLEAN_RECORDS)
277
  issues = detect_issues(clean_conn, clean_profile, records,
278
- task_id="task1_null_and_types", seed=42)
279
- assert len(issues) >= MINIMUM_ISSUES["task1_null_and_types"]
280
 
281
  def test_reproducible_with_same_seed(self, dirty_conn, dirty_profile):
282
  conn2 = _make_conn(DIRTY_RECORDS)
@@ -284,9 +284,9 @@ class TestDetectIssues:
284
  r1 = copy.deepcopy(DIRTY_RECORDS)
285
  r2 = copy.deepcopy(DIRTY_RECORDS)
286
  issues1 = detect_issues(dirty_conn, dirty_profile, r1,
287
- task_id="task1_null_and_types", seed=99)
288
  issues2 = detect_issues(conn2, profile2, r2,
289
- task_id="task1_null_and_types", seed=99)
290
  assert len(issues1) == len(issues2)
291
  conn2.close()
292
 
@@ -299,7 +299,7 @@ class TestDetectTrap:
299
  def test_trap_planted_for_task3(self, dirty_conn, dirty_profile):
300
  records = copy.deepcopy(DIRTY_RECORDS)
301
  issues = detect_issues(dirty_conn, dirty_profile, records,
302
- task_id="task3_full_audit_with_trap", seed=42)
303
  trap = detect_trap(dirty_conn, dirty_profile, records, issues, seed=42)
304
  assert trap is not None
305
  assert isinstance(trap, Trap)
@@ -307,7 +307,7 @@ class TestDetectTrap:
307
  def test_trap_not_in_issue_registry(self, dirty_conn, dirty_profile):
308
  records = copy.deepcopy(DIRTY_RECORDS)
309
  issues = detect_issues(dirty_conn, dirty_profile, records,
310
- task_id="task3_full_audit_with_trap", seed=42)
311
  trap = detect_trap(dirty_conn, dirty_profile, records, issues, seed=42)
312
  if trap is None:
313
  pytest.skip("No numeric column available for trap")
@@ -317,7 +317,7 @@ class TestDetectTrap:
317
  def test_trap_value_is_2x_original(self, dirty_conn, dirty_profile):
318
  records = copy.deepcopy(DIRTY_RECORDS)
319
  issues = detect_issues(dirty_conn, dirty_profile, records,
320
- task_id="task3_full_audit_with_trap", seed=42)
321
  trap = detect_trap(dirty_conn, dirty_profile, records, issues, seed=42)
322
  if trap is None:
323
  pytest.skip("No numeric column available for trap")
@@ -327,7 +327,7 @@ class TestDetectTrap:
327
  def test_trap_written_to_sqlite(self, dirty_conn, dirty_profile):
328
  records = copy.deepcopy(DIRTY_RECORDS)
329
  issues = detect_issues(dirty_conn, dirty_profile, records,
330
- task_id="task3_full_audit_with_trap", seed=42)
331
  trap = detect_trap(dirty_conn, dirty_profile, records, issues, seed=42)
332
  if trap is None:
333
  pytest.skip("No numeric column available for trap")
 
231
  def test_task1_minimum_issues(self, dirty_conn, dirty_profile):
232
  records = copy.deepcopy(DIRTY_RECORDS)
233
  issues = detect_issues(dirty_conn, dirty_profile, records,
234
+ task_id="viz_easy", seed=42)
235
+ assert len(issues) >= MINIMUM_ISSUES["viz_easy"]
236
 
237
  def test_task2_minimum_issues(self, dirty_conn, dirty_profile):
238
  records = copy.deepcopy(DIRTY_RECORDS)
239
  issues = detect_issues(dirty_conn, dirty_profile, records,
240
+ task_id="ml_medium", seed=42)
241
+ assert len(issues) >= MINIMUM_ISSUES["ml_medium"]
242
 
243
  def test_task3_minimum_issues(self, dirty_conn, dirty_profile):
244
  records = copy.deepcopy(DIRTY_RECORDS)
245
  issues = detect_issues(dirty_conn, dirty_profile, records,
246
+ task_id="bq_hard", seed=42)
247
+ assert len(issues) >= MINIMUM_ISSUES["bq_hard"]
248
 
249
  def test_task1_only_null_and_type_issues(self, dirty_conn, dirty_profile):
250
  records = copy.deepcopy(DIRTY_RECORDS)
251
  issues = detect_issues(dirty_conn, dirty_profile, records,
252
+ task_id="viz_easy", seed=42)
253
  for iss in issues:
254
  assert iss.issue_type in ("null", "type_error"), (
255
  f"task1 should only detect null/type_error, got {iss.issue_type}"
 
258
  def test_no_duplicate_issue_ids(self, dirty_conn, dirty_profile):
259
  records = copy.deepcopy(DIRTY_RECORDS)
260
  issues = detect_issues(dirty_conn, dirty_profile, records,
261
+ task_id="bq_hard", seed=42)
262
  ids = [i.issue_id for i in issues]
263
  assert len(ids) == len(set(ids)), "Duplicate issue_ids found"
264
 
265
  def test_confidence_in_range(self, dirty_conn, dirty_profile):
266
  records = copy.deepcopy(DIRTY_RECORDS)
267
  issues = detect_issues(dirty_conn, dirty_profile, records,
268
+ task_id="bq_hard", seed=42)
269
  for iss in issues:
270
  assert 0.0 <= iss.confidence <= 1.0, (
271
  f"Issue {iss.issue_id} has out-of-range confidence {iss.confidence}"
 
275
  """Clean data triggers synthetic top-up to meet minimum."""
276
  records = copy.deepcopy(CLEAN_RECORDS)
277
  issues = detect_issues(clean_conn, clean_profile, records,
278
+ task_id="viz_easy", seed=42)
279
+ assert len(issues) >= MINIMUM_ISSUES["viz_easy"]
280
 
281
  def test_reproducible_with_same_seed(self, dirty_conn, dirty_profile):
282
  conn2 = _make_conn(DIRTY_RECORDS)
 
284
  r1 = copy.deepcopy(DIRTY_RECORDS)
285
  r2 = copy.deepcopy(DIRTY_RECORDS)
286
  issues1 = detect_issues(dirty_conn, dirty_profile, r1,
287
+ task_id="viz_easy", seed=99)
288
  issues2 = detect_issues(conn2, profile2, r2,
289
+ task_id="viz_easy", seed=99)
290
  assert len(issues1) == len(issues2)
291
  conn2.close()
292
 
 
299
  def test_trap_planted_for_task3(self, dirty_conn, dirty_profile):
300
  records = copy.deepcopy(DIRTY_RECORDS)
301
  issues = detect_issues(dirty_conn, dirty_profile, records,
302
+ task_id="bq_hard", seed=42)
303
  trap = detect_trap(dirty_conn, dirty_profile, records, issues, seed=42)
304
  assert trap is not None
305
  assert isinstance(trap, Trap)
 
307
  def test_trap_not_in_issue_registry(self, dirty_conn, dirty_profile):
308
  records = copy.deepcopy(DIRTY_RECORDS)
309
  issues = detect_issues(dirty_conn, dirty_profile, records,
310
+ task_id="bq_hard", seed=42)
311
  trap = detect_trap(dirty_conn, dirty_profile, records, issues, seed=42)
312
  if trap is None:
313
  pytest.skip("No numeric column available for trap")
 
317
  def test_trap_value_is_2x_original(self, dirty_conn, dirty_profile):
318
  records = copy.deepcopy(DIRTY_RECORDS)
319
  issues = detect_issues(dirty_conn, dirty_profile, records,
320
+ task_id="bq_hard", seed=42)
321
  trap = detect_trap(dirty_conn, dirty_profile, records, issues, seed=42)
322
  if trap is None:
323
  pytest.skip("No numeric column available for trap")
 
327
  def test_trap_written_to_sqlite(self, dirty_conn, dirty_profile):
328
  records = copy.deepcopy(DIRTY_RECORDS)
329
  issues = detect_issues(dirty_conn, dirty_profile, records,
330
+ task_id="bq_hard", seed=42)
331
  trap = detect_trap(dirty_conn, dirty_profile, records, issues, seed=42)
332
  if trap is None:
333
  pytest.skip("No numeric column available for trap")