Spaces:
Sleeping
Sleeping
Commit ·
9620e6a
1
Parent(s): 8da763b
Changes for the intent
Browse files- README.md +39 -7
- inference.py +11 -9
- sqlsherlock_env/server/graders/task2.py +2 -1
- sqlsherlock_env/server/graders/task3.py +5 -5
- sqlsherlock_env/server/requirements.txt +1 -0
- sqlsherlock_env/server/ui.py +669 -203
- tests/conftest.py +58 -10
- tests/test_environment.py +198 -0
- tests/test_graders.py +17 -17
- tests/test_issue_detector.py +17 -17
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.
|
| 102 |
-
+ validation × 0.
|
| 103 |
− fp_penalty
|
| 104 |
```
|
| 105 |
|
| 106 |
-
### Medium
|
| 107 |
```
|
| 108 |
-
score = easy_score × 0.
|
| 109 |
-
+ avg(constraint_resolved + outlier_resolved) × 0.
|
| 110 |
− fp_penalty
|
| 111 |
```
|
| 112 |
|
| 113 |
-
### Hard
|
| 114 |
```
|
| 115 |
# With FK violations:
|
| 116 |
-
score = medium_score × 0.
|
| 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={
|
| 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 |
-
|
| 199 |
score = 0.0
|
| 200 |
success = False
|
| 201 |
-
env = None
|
| 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
|
| 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
|
| 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(
|
| 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=
|
| 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 |
-
|
|
|
|
| 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 |
-
#
|
|
|
|
| 86 |
fk_score, _ = _resolution_score(fk_issues, cleaned_rows, removed_ids, pk_col, db)
|
| 87 |
raw = (
|
| 88 |
-
t2 * 0.
|
| 89 |
-
+ fk_score * 0.
|
| 90 |
+ r_bonus
|
| 91 |
- trap_pen
|
| 92 |
)
|
| 93 |
else:
|
| 94 |
# Single-table dataset — no FK issues to resolve.
|
| 95 |
-
# Hard difficulty
|
| 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
|
| 6 |
-
2. Select ONE cleaning intent
|
| 7 |
-
3.
|
| 8 |
-
4.
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
"""
|
| 13 |
|
| 14 |
-
import os
|
| 15 |
import re
|
| 16 |
import tempfile
|
| 17 |
-
from
|
|
|
|
| 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 |
-
|
| 54 |
-
["phihung/titanic"],
|
| 55 |
-
["scikit-learn/iris"],
|
| 56 |
-
]
|
| 57 |
|
| 58 |
_AGENT_EXAMPLES = [
|
| 59 |
-
["phihung/titanic", "visualization",
|
| 60 |
-
|
| 61 |
-
["phihung/titanic", "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
]
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
# ---------------------------------------------------------------------------
|
| 66 |
# Helpers
|
| 67 |
# ---------------------------------------------------------------------------
|
| 68 |
|
| 69 |
-
def _resolve_source(
|
| 70 |
-
|
| 71 |
-
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
# ---------------------------------------------------------------------------
|
| 94 |
# Tab 1: Data Quality Scanner
|
| 95 |
# ---------------------------------------------------------------------------
|
| 96 |
|
| 97 |
-
def scan_dataset(file_path: str | None, hf_name: str)
|
| 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,
|
|
|
|
| 106 |
except Exception as exc:
|
| 107 |
return f"**Error:** {_friendly_error(exc, source)}", [], [], [], []
|
| 108 |
|
| 109 |
-
table
|
| 110 |
-
issues
|
| 111 |
-
profiles
|
| 112 |
-
total_rows
|
| 113 |
-
total_cols
|
| 114 |
-
|
| 115 |
-
cells
|
| 116 |
-
quality_pct
|
| 117 |
-
grade
|
| 118 |
-
|
| 119 |
-
|
| 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 |
-
|
| 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** | {
|
| 138 |
| **Quality score** | **{quality_pct:.1f}% — {grade}** |
|
| 139 |
|
| 140 |
### Issue Breakdown
|
| 141 |
|
| 142 |
| Issue Type | Count |
|
| 143 |
|------------|-------|
|
| 144 |
-
{
|
| 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", "?"),
|
|
|
|
| 156 |
|
| 157 |
-
issue_rows
|
| 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
|
| 173 |
# ---------------------------------------------------------------------------
|
| 174 |
|
| 175 |
def run_agent_streaming(
|
| 176 |
-
|
|
|
|
|
|
|
| 177 |
hf_name: str,
|
| 178 |
intent: str,
|
| 179 |
output_format: str,
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
from server.environment import SQLSherlockEnvironment
|
| 186 |
from models import SQLSherlockAction
|
| 187 |
|
| 188 |
-
|
| 189 |
-
if
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 197 |
-
all_scores: list[tuple[str, float]] = []
|
| 198 |
download_path: str | None = None
|
| 199 |
-
summary_md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
def _log(*parts: str) -> None:
|
| 202 |
-
log_lines.append(" ".join(parts))
|
| 203 |
|
| 204 |
-
def
|
| 205 |
-
return "\n".join(log_lines)
|
|
|
|
| 206 |
|
|
|
|
| 207 |
_log(f"Intent : {intent_label}")
|
|
|
|
| 208 |
_log(f"Format : {output_format}")
|
| 209 |
-
|
|
|
|
| 210 |
_log("")
|
| 211 |
-
yield
|
| 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
|
| 219 |
|
| 220 |
-
env
|
| 221 |
-
score
|
| 222 |
-
step
|
| 223 |
-
done
|
| 224 |
-
obs
|
| 225 |
rewards: list[float] = []
|
| 226 |
-
|
| 227 |
|
| 228 |
def _do(action_dict: dict) -> float:
|
| 229 |
nonlocal step, done, obs
|
| 230 |
if done:
|
| 231 |
return 0.0
|
| 232 |
step += 1
|
| 233 |
-
|
| 234 |
-
|
| 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 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
# Phase 1: Inspect
|
| 262 |
r = _do({"action_type": "inspect", "table": table})
|
| 263 |
-
_log(f" [
|
| 264 |
-
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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" [
|
| 276 |
-
yield
|
| 277 |
-
|
| 278 |
if nc > 0 and not done:
|
| 279 |
-
fill = (p.get("median") or p.get("mean") or 0) if dt in ("int",
|
| 280 |
-
|
|
|
|
| 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" [
|
| 287 |
if r2 > 0:
|
| 288 |
-
|
| 289 |
-
yield
|
| 290 |
else:
|
| 291 |
-
_log(f" [
|
| 292 |
-
yield
|
| 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" [
|
| 299 |
-
yield
|
| 300 |
-
|
| 301 |
-
# Phase 4:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
if not done:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
r = _do({"action_type": "export"})
|
| 304 |
-
fb = (obs.last_feedback if obs else "")[:
|
| 305 |
-
_log(f" [
|
| 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 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 325 |
-
f"{'SUCCESS' if success else 'PARTIAL'} Steps: {step}")
|
| 326 |
_log("")
|
| 327 |
all_scores.append((task_id, score))
|
| 328 |
-
yield
|
| 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 | `{
|
| 347 |
-
|
|
| 348 |
| Tasks run | {len(all_scores)} (easy → medium → hard) |
|
| 349 |
-
{"|
|
|
|
|
| 350 |
"""
|
| 351 |
-
yield
|
| 352 |
|
| 353 |
|
| 354 |
# ---------------------------------------------------------------------------
|
| 355 |
-
# Build
|
| 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 |
-
"
|
| 364 |
-
"across **3
|
| 365 |
)
|
| 366 |
|
| 367 |
# ==================================================================
|
|
@@ -369,25 +782,23 @@ def create_ui() -> gr.Blocks:
|
|
| 369 |
# ==================================================================
|
| 370 |
with gr.Tab("Data Quality Scanner"):
|
| 371 |
gr.Markdown(
|
| 372 |
-
"
|
| 373 |
-
"
|
| 374 |
)
|
| 375 |
with gr.Row():
|
| 376 |
with gr.Column(scale=1):
|
| 377 |
-
gr.Markdown("####
|
| 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("####
|
| 384 |
scan_hf = gr.Textbox(
|
| 385 |
-
label="Dataset name",
|
| 386 |
placeholder="e.g. phihung/titanic",
|
| 387 |
-
value="phihung/titanic",
|
| 388 |
)
|
| 389 |
-
gr.Examples(examples=
|
| 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
|
| 422 |
# ==================================================================
|
| 423 |
with gr.Tab("Agent Demo"):
|
| 424 |
gr.Markdown(
|
| 425 |
-
"**
|
| 426 |
-
"
|
| 427 |
-
"The cleaned file from the Hard task is available for download."
|
| 428 |
)
|
| 429 |
|
| 430 |
with gr.Row():
|
| 431 |
-
# --- Dataset
|
| 432 |
with gr.Column(scale=2):
|
| 433 |
-
gr.Markdown("#### Dataset")
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
)
|
| 439 |
agent_hf = gr.Textbox(
|
| 440 |
-
label="
|
| 441 |
value="phihung/titanic",
|
| 442 |
placeholder="phihung/titanic",
|
| 443 |
)
|
| 444 |
|
| 445 |
-
# ---
|
| 446 |
-
with gr.Column(scale=
|
| 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 |
-
">
|
| 462 |
-
">
|
| 463 |
-
"
|
| 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=
|
| 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
|
| 490 |
interactive=False,
|
| 491 |
)
|
| 492 |
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
):
|
| 497 |
-
yield log,
|
| 498 |
|
| 499 |
run_btn.click(
|
| 500 |
fn=_run,
|
| 501 |
-
inputs=[
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
##
|
| 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 |
-
|
|
|
|
|
|
|
| 533 |
|
| 534 |
-
|
| 535 |
-
```
|
| 536 |
-
score = resolution(null+type+whitespace+category) × 0.70 + validation × 0.30
|
| 537 |
-
```
|
| 538 |
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
``
|
|
|
|
| 543 |
|
| 544 |
-
|
| 545 |
-
```
|
| 546 |
-
score = medium_score × 0.50 + fk_resolved × 0.50 + reasoning_bonus − trap_penalty
|
| 547 |
-
```
|
| 548 |
|
| 549 |
-
|
|
|
|
| 550 |
|
| 551 |
-
|
| 552 |
-
Touching it costs **−0.40**. Always verify z-scores before fixing numeric values.
|
| 553 |
|
| 554 |
-
-
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
-
###
|
| 557 |
|
| 558 |
-
|
|
| 559 |
-
|--------|------
|
| 560 |
-
|
|
| 561 |
-
|
|
| 562 |
-
|
|
| 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
|
| 131 |
from server.database import DatabaseEngine
|
| 132 |
db = DatabaseEngine(
|
| 133 |
-
task_id="
|
| 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
|
| 144 |
from server.database import DatabaseEngine
|
| 145 |
db = DatabaseEngine(
|
| 146 |
-
task_id="
|
| 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
|
| 157 |
from server.database import DatabaseEngine
|
| 158 |
db = DatabaseEngine(
|
| 159 |
-
task_id="
|
| 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
|
| 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="
|
| 182 |
seed=42,
|
| 183 |
)
|
| 184 |
|
| 185 |
|
| 186 |
@pytest.fixture
|
| 187 |
def task3_issues(dirty_conn, dirty_profile):
|
| 188 |
-
"""Issues detected for
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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, [], "
|
| 169 |
-
score_without = graders.grade(db_task1, cleaned, [], "
|
| 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, [], "
|
| 181 |
-
score_ok = graders.grade(db_task1, cleaned, [], "
|
| 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="
|
| 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="
|
| 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, [], "
|
| 221 |
-
s2 = graders.grade(db_task2, c2, [], "
|
| 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="
|
| 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 |
-
"
|
| 260 |
)
|
| 261 |
score_touched = graders.grade(
|
| 262 |
db_task3, cleaned_touched, [],
|
| 263 |
-
"
|
| 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 |
-
"
|
| 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.
|
|
|
|
| 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="
|
| 235 |
-
assert len(issues) >= MINIMUM_ISSUES["
|
| 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="
|
| 241 |
-
assert len(issues) >= MINIMUM_ISSUES["
|
| 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="
|
| 247 |
-
assert len(issues) >= MINIMUM_ISSUES["
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 279 |
-
assert len(issues) >= MINIMUM_ISSUES["
|
| 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="
|
| 288 |
issues2 = detect_issues(conn2, profile2, r2,
|
| 289 |
-
task_id="
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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")
|