Deadman922 commited on
Commit
ae3a2dd
Β·
verified Β·
1 Parent(s): 58a873a

Final Review and testing

Browse files
Files changed (5) hide show
  1. inference.py +183 -109
  2. pyproject.toml +29 -0
  3. requirements.txt +2 -0
  4. server/main.py +165 -0
  5. static/index.html +638 -290
inference.py CHANGED
@@ -1,7 +1,15 @@
1
  """Inference module for SQL Quality Environment.
2
 
3
  This module provides an LLM-based AI agent interface for solving SQL data quality tasks.
4
- It uses OpenAI API to generate SQL queries and fixes dynamically.
 
 
 
 
 
 
 
 
5
  """
6
 
7
  import json
@@ -14,9 +22,10 @@ from openai import OpenAI
14
 
15
  # Configuration from environment
16
  MAX_STEPS = int(os.getenv("MAX_STEPS", "10"))
17
- API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
18
- MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini")
19
  HF_TOKEN = os.getenv("HF_TOKEN", "")
 
20
 
21
 
22
  class SQLQualityAgent:
@@ -29,11 +38,10 @@ class SQLQualityAgent:
29
  self.step_count = 0
30
  self.trajectory: List[Dict[str, Any]] = []
31
 
32
- # Initialize OpenAI client
33
- api_key = HF_TOKEN or os.getenv("OPENAI_API_KEY", "")
34
  self.client = OpenAI(
35
- api_key=api_key,
36
- base_url=API_BASE_URL if "openai" not in API_BASE_URL else None
37
  )
38
 
39
  def health_check(self) -> bool:
@@ -47,6 +55,8 @@ class SQLQualityAgent:
47
  def reset(self, task_id: str, seed: Optional[int] = None) -> Dict[str, Any]:
48
  """Initialize a new episode for the given task."""
49
  self.task_id = task_id
 
 
50
  payload = {"task_id": task_id}
51
  if seed is not None:
52
  payload["seed"] = seed
@@ -76,7 +86,9 @@ class SQLQualityAgent:
76
  timeout=30,
77
  )
78
  r.raise_for_status()
79
- return r.json()
 
 
80
 
81
  def profile(self, column: str) -> Dict[str, Any]:
82
  """Profile a column to get statistics."""
@@ -97,7 +109,9 @@ class SQLQualityAgent:
97
  timeout=30,
98
  )
99
  r.raise_for_status()
100
- return r.json()
 
 
101
 
102
  def fix(self, fix_sql: str) -> Dict[str, Any]:
103
  """Apply a fix SQL (UPDATE/INSERT/DELETE)."""
@@ -118,7 +132,9 @@ class SQLQualityAgent:
118
  timeout=30,
119
  )
120
  r.raise_for_status()
121
- return r.json()
 
 
122
 
123
  def submit(
124
  self,
@@ -148,11 +164,10 @@ class SQLQualityAgent:
148
  r.raise_for_status()
149
  return r.json()
150
 
151
-
152
- def llm_generate_sql(self, prompt: str, system_context: str = "") -> str:
153
- """Generate SQL using LLM."""
154
  messages = [
155
- {"role": "system", "content": system_context or "You are a SQL data quality expert. Generate valid SQLite SQL queries."},
156
  {"role": "user", "content": prompt}
157
  ]
158
 
@@ -163,20 +178,39 @@ class SQLQualityAgent:
163
  temperature=0.1,
164
  max_tokens=500
165
  )
166
- sql = response.choices[0].message.content.strip()
167
  # Extract SQL from markdown if present
168
- if "```sql" in sql:
169
- sql = sql.split("```sql")[1].split("```")[0].strip()
170
- elif "```" in sql:
171
- sql = sql.split("```")[1].split("```")[0].strip()
172
- return sql
173
  except Exception as e:
174
  print(f"LLM generation failed: {e}")
175
  return ""
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
  def run_agent(task_id: str, base_url: str = "http://localhost:7860") -> Dict[str, Any]:
179
- """Run LLM-based agent on a task."""
180
  agent = SQLQualityAgent(base_url)
181
 
182
  if not agent.health_check():
@@ -189,148 +223,189 @@ def run_agent(task_id: str, base_url: str = "http://localhost:7860") -> Dict[str
189
  print(f"Episode ID: {agent.episode_id}")
190
 
191
  # Get schema and task info
192
- schema = obs.get("observation", {}).get("schema_info", [])
193
- task_desc = obs.get("observation", {}).get("task_description", "")
194
 
195
  print(f"Task: {task_desc}")
196
  print(f"Schema: {json.dumps(schema, indent=2)}")
197
 
198
- # Build context for LLM
199
  schema_text = json.dumps(schema, indent=2)
200
 
201
  try:
202
  if task_id == "null_audit":
203
- # Use LLM to find null percentages
204
- prompt = f"""Given this schema: {schema_text}
205
-
206
- Write a SQL query to find which column has the highest percentage of NULL values.
 
 
207
  Return only the SQL query."""
208
-
209
- sql = agent.llm_generate_sql(prompt, "You are a data quality expert.")
210
  if sql:
211
  result = agent.query(sql)
212
- print(f"Query result: {result}")
 
 
 
 
 
213
 
214
- # Ask LLM to identify the answer
215
- answer_prompt = f"""Based on the query results above, which column name has null rate > 50%?
216
- Return ONLY the column name (e.g., 'city'), nothing else."""
 
 
217
 
218
- answer = agent.llm_generate_sql(answer_prompt, "You identify the problematic column.")
 
 
 
 
 
 
 
219
  if not answer:
220
  answer = "city" # fallback
221
 
 
222
  submit_result = agent.submit(answer=answer.strip())
223
  return submit_result
224
 
225
  elif task_id == "type_repair":
226
- # Check for type issues
227
- prompt = f"""Given schema: {schema_text}
228
-
229
- Task: {task_desc}
230
- Write a SQL query to inspect the transactions table and identify type mismatches in the amount column.
231
- Return only the SQL."""
232
 
233
- sql = agent.llm_generate_sql(prompt)
234
- if sql:
235
- result = agent.query(sql)
236
- print(f"Inspection result: {result}")
237
-
238
- # Generate fix
239
- fix_prompt = f"""Given the type mismatch in the amount column (stored as TEXT with '$' prefix),
240
- write a SQLite UPDATE statement to convert it to REAL numeric values.
241
- Return only the SQL UPDATE statement."""
 
 
242
 
243
- fix_sql = agent.llm_generate_sql(fix_prompt, "You write data repair SQL.")
244
  if fix_sql:
 
245
  fix_result = agent.fix(fix_sql)
246
- print(f"Fix result: {fix_result}")
 
 
 
 
 
247
  submit_result = agent.submit(fix_query=fix_sql)
248
  else:
249
- submit_result = agent.submit()
 
 
 
 
 
 
 
 
 
250
  return submit_result
251
 
252
  elif task_id == "dedup_bug":
253
- # Find duplicates
254
- prompt = f"""Given schema: {schema_text}
255
-
256
- Write a SQL query to find duplicate event_id values in the events table.
257
- Return only the SQL."""
258
-
259
- sql = agent.llm_generate_sql(prompt)
260
- if sql:
261
- result = agent.query(sql)
262
- print(f"Duplicates found: {result}")
263
 
264
- # Generate dedup fix
265
- fix_prompt = f"""Write a SQLite DELETE statement to remove duplicate rows from the events table,
266
- keeping only the first occurrence (minimum rowid) for each event_id.
 
 
267
  Return only the SQL DELETE statement."""
 
268
 
269
- fix_sql = agent.llm_generate_sql(fix_prompt)
270
  if fix_sql:
271
  fix_result = agent.fix(fix_sql)
272
- print(f"Fix result: {fix_result}")
 
 
 
 
273
  submit_result = agent.submit(
274
  fix_query=fix_sql,
275
- root_cause_table="events",
276
- offending_key="event_id"
277
  )
278
  else:
279
- submit_result = agent.submit()
 
 
 
 
 
 
280
  return submit_result
281
 
282
  elif task_id == "fk_integrity":
283
- # Find orphans
284
- prompt = f"""Given schema with tables: invoices and invoice_items
285
- Write a SQL query to count orphan invoice_items (items with invoice_id not in invoices table).
286
- Return only the SQL."""
287
-
288
- sql = agent.llm_generate_sql(prompt)
289
- if sql:
290
- result = agent.query(sql)
291
- print(f"Orphan count: {result}")
292
 
293
- # Generate orphan removal
294
- fix_prompt = f"""Write a SQLite DELETE statement to remove orphan records from invoice_items table
295
- where invoice_id does not exist in the invoices table.
 
296
  Return only the SQL DELETE statement."""
 
297
 
298
- fix_sql = agent.llm_generate_sql(fix_prompt)
299
  if fix_sql:
300
- fix_result = agent.fix(fix_sql)
301
- print(f"Fix result: {fix_result}")
302
  submit_result = agent.submit(fix_query=fix_sql)
303
  else:
304
- submit_result = agent.submit()
 
 
305
  return submit_result
306
 
307
  elif task_id == "schema_drift":
308
- # Check schema
309
- prompt = f"""Write a SQL query: PRAGMA table_info(raw_events)
310
- Return only the SQL."""
311
 
312
- sql = agent.llm_generate_sql(prompt)
313
- if sql:
314
- result = agent.query(sql)
315
- print(f"Schema: {result}")
316
-
317
- # Generate normalization
318
- fix_prompt = f"""Given the raw_events table with columns: event_id, timestamp, user_id, value, event_type
319
- Write a SQLite CREATE TABLE AS statement to create analytics_events with:
320
- - event_id (preserved)
321
- - timestamp cast to INTEGER
322
- - user_id (preserved)
323
- - value cast to REAL
324
- - event_type (preserved)
325
- Return only the SQL CREATE TABLE AS statement."""
326
 
327
- fix_sql = agent.llm_generate_sql(fix_prompt)
328
  if fix_sql:
329
- fix_result = agent.fix(fix_sql)
330
- print(f"Fix result: {fix_result}")
331
  submit_result = agent.submit(fix_query=fix_sql)
332
  else:
333
- submit_result = agent.submit()
 
 
 
 
 
 
 
 
 
334
  return submit_result
335
 
336
  else:
@@ -338,7 +413,6 @@ Return only the SQL CREATE TABLE AS statement."""
338
 
339
  except RuntimeError as e:
340
  print(f"Agent failed: {e}")
341
- # Submit empty answer to end episode
342
  return agent.submit()
343
 
344
 
 
1
  """Inference module for SQL Quality Environment.
2
 
3
  This module provides an LLM-based AI agent interface for solving SQL data quality tasks.
4
+ It uses OpenAI-compatible API (Groq/HuggingFace) to generate SQL queries and fixes dynamically.
5
+
6
+ MANDATORY:
7
+ - Before submitting, ensure the following variables are defined in your environment configuration:
8
+ API_BASE_URL The API endpoint for the LLM.
9
+ MODEL_NAME The model identifier to use for inference.
10
+ HF_TOKEN Your Hugging Face / API key.
11
+ - The inference script must be named `inference.py` and placed in the root directory of the project
12
+ - Participants must use OpenAI Client for all LLM calls using above variables
13
  """
14
 
15
  import json
 
22
 
23
  # Configuration from environment
24
  MAX_STEPS = int(os.getenv("MAX_STEPS", "10"))
25
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://api.groq.com/openai/v1")
26
+ MODEL_NAME = os.getenv("MODEL_NAME", "llama-3.3-70b-versatile")
27
  HF_TOKEN = os.getenv("HF_TOKEN", "")
28
+ API_KEY = os.getenv("GROQ_API_KEY", "") or HF_TOKEN or os.getenv("OPENAI_API_KEY", "")
29
 
30
 
31
  class SQLQualityAgent:
 
38
  self.step_count = 0
39
  self.trajectory: List[Dict[str, Any]] = []
40
 
41
+ # Initialize OpenAI-compatible client (works with Groq, HF, OpenAI)
 
42
  self.client = OpenAI(
43
+ api_key=API_KEY,
44
+ base_url=API_BASE_URL
45
  )
46
 
47
  def health_check(self) -> bool:
 
55
  def reset(self, task_id: str, seed: Optional[int] = None) -> Dict[str, Any]:
56
  """Initialize a new episode for the given task."""
57
  self.task_id = task_id
58
+ self.step_count = 0
59
+ self.trajectory = []
60
  payload = {"task_id": task_id}
61
  if seed is not None:
62
  payload["seed"] = seed
 
86
  timeout=30,
87
  )
88
  r.raise_for_status()
89
+ result = r.json()
90
+ self.trajectory.append({"type": "query", "sql": sql, "result": result})
91
+ return result
92
 
93
  def profile(self, column: str) -> Dict[str, Any]:
94
  """Profile a column to get statistics."""
 
109
  timeout=30,
110
  )
111
  r.raise_for_status()
112
+ result = r.json()
113
+ self.trajectory.append({"type": "profile", "column": column, "result": result})
114
+ return result
115
 
116
  def fix(self, fix_sql: str) -> Dict[str, Any]:
117
  """Apply a fix SQL (UPDATE/INSERT/DELETE)."""
 
132
  timeout=30,
133
  )
134
  r.raise_for_status()
135
+ result = r.json()
136
+ self.trajectory.append({"type": "fix", "sql": fix_sql, "result": result})
137
+ return result
138
 
139
  def submit(
140
  self,
 
164
  r.raise_for_status()
165
  return r.json()
166
 
167
+ def llm_generate(self, prompt: str, system_context: str = "") -> str:
168
+ """Generate text using LLM via OpenAI-compatible API."""
 
169
  messages = [
170
+ {"role": "system", "content": system_context or "You are a SQL data quality expert. Generate valid SQLite SQL queries. Reply with ONLY the SQL, no explanations."},
171
  {"role": "user", "content": prompt}
172
  ]
173
 
 
178
  temperature=0.1,
179
  max_tokens=500
180
  )
181
+ text = response.choices[0].message.content.strip()
182
  # Extract SQL from markdown if present
183
+ if "```sql" in text:
184
+ text = text.split("```sql")[1].split("```")[0].strip()
185
+ elif "```" in text:
186
+ text = text.split("```")[1].split("```")[0].strip()
187
+ return text
188
  except Exception as e:
189
  print(f"LLM generation failed: {e}")
190
  return ""
191
 
192
+ def llm_answer(self, prompt: str, system_context: str = "") -> str:
193
+ """Generate a short answer using LLM."""
194
+ messages = [
195
+ {"role": "system", "content": system_context or "You are a data quality expert. Reply with ONLY the answer, no explanations."},
196
+ {"role": "user", "content": prompt}
197
+ ]
198
+
199
+ try:
200
+ response = self.client.chat.completions.create(
201
+ model=MODEL_NAME,
202
+ messages=messages,
203
+ temperature=0.0,
204
+ max_tokens=50
205
+ )
206
+ return response.choices[0].message.content.strip().strip('"').strip("'")
207
+ except Exception as e:
208
+ print(f"LLM answer failed: {e}")
209
+ return ""
210
+
211
 
212
  def run_agent(task_id: str, base_url: str = "http://localhost:7860") -> Dict[str, Any]:
213
+ """Run LLM-based agent on a task with multi-step investigation."""
214
  agent = SQLQualityAgent(base_url)
215
 
216
  if not agent.health_check():
 
223
  print(f"Episode ID: {agent.episode_id}")
224
 
225
  # Get schema and task info
226
+ schema = obs.get("schema_info", [])
227
+ task_desc = obs.get("task_description", "")
228
 
229
  print(f"Task: {task_desc}")
230
  print(f"Schema: {json.dumps(schema, indent=2)}")
231
 
 
232
  schema_text = json.dumps(schema, indent=2)
233
 
234
  try:
235
  if task_id == "null_audit":
236
+ # Step 1: Query null percentages for each column
237
+ sql = agent.llm_generate(
238
+ f"""Given this schema: {schema_text}
239
+
240
+ Write a SQL query that counts NULL values for each column in the 'users' table
241
+ and calculates the null percentage. Return column_name, null_count, and pct_null.
242
  Return only the SQL query."""
243
+ )
 
244
  if sql:
245
  result = agent.query(sql)
246
+ print(f"Null analysis: {json.dumps(result.get('reward', {}), indent=2)}")
247
+
248
+ # Step 2: Profile individual columns
249
+ for col in ["city", "email", "age", "signup_date"]:
250
+ profile_result = agent.profile(col)
251
+ print(f"Profile {col}: {profile_result.get('reward', {}).get('feedback', '')}")
252
 
253
+ # Step 3: Ask LLM to identify the answer based on profile results
254
+ trajectory_summary = "\n".join([
255
+ f"Step {i+1}: {t.get('type', 'unknown')} - {t.get('result', {}).get('reward', {}).get('feedback', '')[:100]}"
256
+ for i, t in enumerate(agent.trajectory)
257
+ ])
258
 
259
+ answer = agent.llm_answer(
260
+ f"""Based on these investigation results:
261
+ {trajectory_summary}
262
+
263
+ Which column name in the 'users' table has a null rate exceeding 50%?
264
+ Return ONLY the column name (e.g., 'city'), nothing else.""",
265
+ "You identify the problematic column. Return ONLY the column name."
266
+ )
267
  if not answer:
268
  answer = "city" # fallback
269
 
270
+ print(f"Identified column: {answer}")
271
  submit_result = agent.submit(answer=answer.strip())
272
  return submit_result
273
 
274
  elif task_id == "type_repair":
275
+ # Step 1: Inspect the table
276
+ result = agent.query("SELECT typeof(amount), amount FROM transactions LIMIT 10")
277
+ print(f"Type inspection: {result.get('reward', {}).get('feedback', '')[:100]}")
 
 
 
278
 
279
+ # Step 2: Generate fix using LLM
280
+ fix_sql = agent.llm_generate(
281
+ f"""The 'transactions' table has an 'amount' column stored as TEXT with numeric values like '12.50'.
282
+ Write a SQLite script to:
283
+ 1. Create a new table with amount as REAL
284
+ 2. Copy all data, casting amount to REAL
285
+ 3. Drop the old table
286
+ 4. Rename the new table
287
+
288
+ Return only the SQL statements."""
289
+ )
290
 
 
291
  if fix_sql:
292
+ print(f"Generated fix: {fix_sql[:200]}")
293
  fix_result = agent.fix(fix_sql)
294
+ print(f"Fix preview: {fix_result.get('reward', {}).get('feedback', '')}")
295
+
296
+ # Step 3: Verify the fix
297
+ verify = agent.query("SELECT typeof(amount), amount FROM transactions LIMIT 5")
298
+ print(f"Verification: {verify.get('reward', {}).get('feedback', '')[:100]}")
299
+
300
  submit_result = agent.submit(fix_query=fix_sql)
301
  else:
302
+ # Fallback fix
303
+ fallback = """
304
+ CREATE TABLE transactions_fixed AS
305
+ SELECT id, user_id, CAST(amount AS REAL) AS amount, description, category, date, transaction_date
306
+ FROM transactions;
307
+ DROP TABLE transactions;
308
+ ALTER TABLE transactions_fixed RENAME TO transactions;
309
+ """
310
+ agent.fix(fallback)
311
+ submit_result = agent.submit(fix_query=fallback)
312
  return submit_result
313
 
314
  elif task_id == "dedup_bug":
315
+ # Step 1: Count duplicates
316
+ result = agent.query(
317
+ "SELECT order_id, COUNT(*) as cnt FROM order_items GROUP BY order_id HAVING cnt > 1 LIMIT 10"
318
+ )
319
+ print(f"Duplicates: {result.get('reward', {}).get('feedback', '')[:100]}")
 
 
 
 
 
320
 
321
+ # Step 2: Generate dedup fix
322
+ fix_sql = agent.llm_generate(
323
+ f"""The 'order_items' table has duplicate rows for the same order_id.
324
+ Write a SQLite DELETE statement to remove duplicate rows, keeping only the row
325
+ with the smallest rowid for each order_id.
326
  Return only the SQL DELETE statement."""
327
+ )
328
 
 
329
  if fix_sql:
330
  fix_result = agent.fix(fix_sql)
331
+ print(f"Fix preview: {fix_result.get('reward', {}).get('feedback', '')}")
332
+
333
+ # Verify
334
+ agent.query("SELECT order_id, COUNT(*) as cnt FROM order_items GROUP BY order_id HAVING cnt > 1")
335
+
336
  submit_result = agent.submit(
337
  fix_query=fix_sql,
338
+ root_cause_table="order_items",
339
+ offending_key="order_id"
340
  )
341
  else:
342
+ fallback = "DELETE FROM order_items WHERE rowid NOT IN (SELECT MIN(rowid) FROM order_items GROUP BY order_id)"
343
+ agent.fix(fallback)
344
+ submit_result = agent.submit(
345
+ fix_query=fallback,
346
+ root_cause_table="order_items",
347
+ offending_key="order_id"
348
+ )
349
  return submit_result
350
 
351
  elif task_id == "fk_integrity":
352
+ # Step 1: Find orphans
353
+ result = agent.query(
354
+ "SELECT COUNT(*) FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE c.id IS NULL"
355
+ )
356
+ print(f"Orphans: {result.get('reward', {}).get('feedback', '')[:100]}")
 
 
 
 
357
 
358
+ # Step 2: Generate fix
359
+ fix_sql = agent.llm_generate(
360
+ f"""The 'invoices' table has orphan records where customer_id doesn't match any id in the 'customers' table.
361
+ Write a SQLite DELETE statement to remove these orphan invoices.
362
  Return only the SQL DELETE statement."""
363
+ )
364
 
 
365
  if fix_sql:
366
+ agent.fix(fix_sql)
367
+ agent.query("SELECT COUNT(*) FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE c.id IS NULL")
368
  submit_result = agent.submit(fix_query=fix_sql)
369
  else:
370
+ fallback = "DELETE FROM invoices WHERE customer_id NOT IN (SELECT id FROM customers)"
371
+ agent.fix(fallback)
372
+ submit_result = agent.submit(fix_query=fallback)
373
  return submit_result
374
 
375
  elif task_id == "schema_drift":
376
+ # Step 1: Inspect both tables
377
+ agent.query("PRAGMA table_info(events_v1)")
378
+ agent.query("PRAGMA table_info(events_v2)")
379
 
380
+ # Step 2: Generate normalization
381
+ fix_sql = agent.llm_generate(
382
+ f"""Two tables exist:
383
+ - events_v1: id, user_id, event_name, amount_text (TEXT), event_time
384
+ - events_v2: event_id, account_id, event_type, amount (REAL), event_ts, source
385
+
386
+ Create a canonical_events table with columns: event_id, user_id, event_type, amount (REAL), event_time
387
+ by merging both tables. Map:
388
+ events_v1: id→event_id, user_id→user_id, event_name→event_type, CAST(amount_text AS REAL)→amount, event_time→event_time
389
+ events_v2: event_id→event_id, account_id→user_id, event_type→event_type, amount→amount, event_ts→event_time
390
+
391
+ Return only the CREATE TABLE AS and INSERT statements."""
392
+ )
 
393
 
 
394
  if fix_sql:
395
+ agent.fix(fix_sql)
396
+ agent.query("SELECT COUNT(*) FROM canonical_events")
397
  submit_result = agent.submit(fix_query=fix_sql)
398
  else:
399
+ fallback = """
400
+ CREATE TABLE canonical_events AS
401
+ SELECT id AS event_id, user_id, event_name AS event_type, CAST(amount_text AS REAL) AS amount, event_time
402
+ FROM events_v1
403
+ UNION ALL
404
+ SELECT event_id, account_id AS user_id, event_type, amount, event_ts AS event_time
405
+ FROM events_v2;
406
+ """
407
+ agent.fix(fallback)
408
+ submit_result = agent.submit(fix_query=fallback)
409
  return submit_result
410
 
411
  else:
 
413
 
414
  except RuntimeError as e:
415
  print(f"Agent failed: {e}")
 
416
  return agent.submit()
417
 
418
 
pyproject.toml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sql-quality-env"
7
+ version = "0.1.0"
8
+ description = "An OpenEnv-compatible environment for training and evaluating SQL data quality agents"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "fastapi>=0.100.0",
12
+ "pydantic>=2.0.0",
13
+ "uvicorn[standard]>=0.23.0",
14
+ "openai>=1.0.0",
15
+ "requests>=2.31.0",
16
+ "python-dotenv>=1.0.0",
17
+ "python-multipart>=0.0.6",
18
+ "groq>=0.4.0",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest>=7.4.0",
24
+ ]
25
+
26
+ [tool.uv]
27
+ dev-dependencies = [
28
+ "pytest>=7.4.0",
29
+ ]
requirements.txt CHANGED
@@ -93,3 +93,5 @@ websockets==16.0
93
  # via uvicorn
94
  python-multipart>=0.0.6
95
  # for FastAPI file uploads (UploadFile)
 
 
 
93
  # via uvicorn
94
  python-multipart>=0.0.6
95
  # for FastAPI file uploads (UploadFile)
96
+ groq>=0.4.0
97
+ # for Groq LLM API (AI insights)
server/main.py CHANGED
@@ -1372,6 +1372,171 @@ async def custom_db_status() -> dict:
1372
  }
1373
 
1374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1375
  def run_server() -> None:
1376
  """
1377
  Entry point for 'uv run server' command.
 
1372
  }
1373
 
1374
 
1375
+ # ─── AI INSIGHTS ENDPOINT (Groq / OpenAI-compatible) ──────────────────────
1376
+
1377
+ def _get_ai_client():
1378
+ """Get an OpenAI-compatible client for AI insights.
1379
+
1380
+ Priority:
1381
+ 1. GROQ_API_KEY β†’ Groq API (fast, reliable)
1382
+ 2. HF_TOKEN + API_BASE_URL β†’ HuggingFace Inference
1383
+ 3. None β†’ fallback to local heuristics
1384
+ """
1385
+ groq_key = os.getenv("GROQ_API_KEY", "")
1386
+ if groq_key:
1387
+ try:
1388
+ from openai import OpenAI
1389
+ return OpenAI(
1390
+ api_key=groq_key,
1391
+ base_url="https://api.groq.com/openai/v1"
1392
+ ), os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
1393
+ except Exception:
1394
+ pass
1395
+
1396
+ hf_token = os.getenv("HF_TOKEN", "")
1397
+ api_base = os.getenv("API_BASE_URL", "")
1398
+ model_name = os.getenv("MODEL_NAME", "")
1399
+ if hf_token and api_base and model_name:
1400
+ try:
1401
+ from openai import OpenAI
1402
+ return OpenAI(api_key=hf_token, base_url=api_base), model_name
1403
+ except Exception:
1404
+ pass
1405
+
1406
+ return None, None
1407
+
1408
+
1409
+ def _generate_local_insights(sql: str, rows: list, columns: list, row_count: int) -> list[str]:
1410
+ """Generate insightful analysis without LLM (fast fallback)."""
1411
+ insights = []
1412
+
1413
+ if not rows:
1414
+ insights.append(f"Query returned 0 rows. The SQL may be too restrictive or target wrong table names.")
1415
+ return insights
1416
+
1417
+ insights.append(f"Query returned {row_count} rows across {len(columns)} columns")
1418
+
1419
+ # Null analysis
1420
+ null_stats = {}
1421
+ for col in columns:
1422
+ null_count = sum(1 for r in rows if r.get(col) is None or r.get(col) == '')
1423
+ if null_count > 0:
1424
+ pct = round(null_count / len(rows) * 100, 1)
1425
+ null_stats[col] = {'count': null_count, 'pct': pct}
1426
+
1427
+ if null_stats:
1428
+ worst = max(null_stats.items(), key=lambda x: x[1]['pct'])
1429
+ insights.append(f"Highest null rate: column '{worst[0]}' at {worst[1]['pct']}% ({worst[1]['count']} nulls)")
1430
+ if len(null_stats) > 1:
1431
+ insights.append(f"{len(null_stats)} columns have null/empty values β€” consider a null_audit task")
1432
+ else:
1433
+ insights.append("No NULL values detected in the returned rows")
1434
+
1435
+ # Numeric analysis
1436
+ for col in columns:
1437
+ values = [r.get(col) for r in rows if r.get(col) is not None]
1438
+ numeric = []
1439
+ for v in values:
1440
+ try:
1441
+ numeric.append(float(str(v).replace('$','').replace(',','').replace('%','')))
1442
+ except (ValueError, TypeError):
1443
+ pass
1444
+
1445
+ if len(numeric) > len(values) * 0.7 and len(numeric) > 3:
1446
+ avg = sum(numeric) / len(numeric)
1447
+ std = (sum((x - avg)**2 for x in numeric) / len(numeric)) ** 0.5
1448
+ outliers = sum(1 for x in numeric if abs(x - avg) > 2 * std)
1449
+ insights.append(f"Column '{col}': avg={avg:.2f}, std={std:.2f}, {outliers} outlier(s) (>2Οƒ)")
1450
+
1451
+ # Duplicate detection
1452
+ for col in columns:
1453
+ values = [str(r.get(col, '')) for r in rows if r.get(col) is not None]
1454
+ unique = set(values)
1455
+ if len(unique) < len(values) * 0.7 and len(values) > 5:
1456
+ dup_count = len(values) - len(unique)
1457
+ insights.append(f"Column '{col}' has {dup_count} duplicate values ({len(unique)}/{len(values)} unique) β€” check for dedup issues")
1458
+
1459
+ return insights[:8]
1460
+
1461
+
1462
+ @app.post("/ai-insights")
1463
+ async def ai_insights(body: dict) -> dict:
1464
+ """Generate AI-powered insights from query results.
1465
+
1466
+ Uses Groq API (or HF Inference) for intelligent analysis.
1467
+ Falls back to local heuristic analysis if no API key configured.
1468
+
1469
+ Args:
1470
+ body: JSON with {sql, rows, row_count, columns}
1471
+
1472
+ Returns:
1473
+ dict: {insights: [str, ...]}
1474
+ """
1475
+ sql = body.get("sql", "")
1476
+ rows = body.get("rows", [])
1477
+ row_count = body.get("row_count", len(rows))
1478
+ columns = body.get("columns", [])
1479
+
1480
+ # Try AI-powered insights first
1481
+ client, model = _get_ai_client()
1482
+
1483
+ if client and model:
1484
+ try:
1485
+ # Build a compact data summary for the LLM
1486
+ data_summary = f"SQL: {sql[:300]}\n"
1487
+ data_summary += f"Result: {row_count} rows, {len(columns)} columns\n"
1488
+ data_summary += f"Columns: {', '.join(columns[:15])}\n"
1489
+
1490
+ if rows:
1491
+ # Include a few sample rows
1492
+ sample = rows[:5]
1493
+ data_summary += f"Sample data (first {len(sample)} rows):\n"
1494
+ for r in sample:
1495
+ row_str = ', '.join(f"{k}={v}" for k, v in list(r.items())[:8])
1496
+ data_summary += f" {row_str}\n"
1497
+
1498
+ # Calculate null stats
1499
+ null_info = []
1500
+ for col in columns:
1501
+ null_count = sum(1 for r in rows if r.get(col) is None or r.get(col) == '')
1502
+ if null_count > 0:
1503
+ null_info.append(f"{col}: {null_count}/{len(rows)} nulls ({round(null_count/len(rows)*100)}%)")
1504
+ if null_info:
1505
+ data_summary += f"Null columns: {'; '.join(null_info)}\n"
1506
+
1507
+ response = client.chat.completions.create(
1508
+ model=model,
1509
+ messages=[
1510
+ {"role": "system", "content": (
1511
+ "You are a SQL data quality expert. Analyze the query results and provide "
1512
+ "3-5 actionable insights about data quality issues. Be specific and concise. "
1513
+ "Focus on: null rates, type mismatches, duplicates, outliers, referential issues. "
1514
+ "Each insight should be 1 sentence. Use emoji prefixes: πŸ“Š ⚠️ βœ… πŸ” πŸ“ˆ πŸ”„ πŸ”§"
1515
+ )},
1516
+ {"role": "user", "content": data_summary}
1517
+ ],
1518
+ temperature=0.3,
1519
+ max_tokens=400,
1520
+ stream=False
1521
+ )
1522
+
1523
+ text = response.choices[0].message.content or ""
1524
+ # Split into individual insights
1525
+ insights = [line.strip() for line in text.strip().split('\n') if line.strip() and len(line.strip()) > 5]
1526
+
1527
+ if insights:
1528
+ return {"insights": insights[:6], "source": "ai"}
1529
+
1530
+ except Exception as e:
1531
+ # Log but don't fail β€” fall back to local
1532
+ import traceback
1533
+ print(f"AI insights error: {e}\n{traceback.format_exc()}")
1534
+
1535
+ # Fallback: local heuristic insights
1536
+ insights = _generate_local_insights(sql, rows, columns, row_count)
1537
+ return {"insights": insights, "source": "local"}
1538
+
1539
+
1540
  def run_server() -> None:
1541
  """
1542
  Entry point for 'uv run server' command.
static/index.html CHANGED
@@ -4,21 +4,21 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>SQL Quality Environment</title>
 
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
- <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
10
  <style>
11
  * { box-sizing: border-box; margin: 0; padding: 0; }
12
  body {
13
  font-family: 'DM Sans', sans-serif;
14
- background: #FAFAFA;
15
  color: #18181B;
16
  min-height: 100vh;
17
  }
18
  .header {
19
- height: 52px;
20
- background: #FFFFFF;
21
- border-bottom: 1px solid #E4E4E7;
22
  display: flex;
23
  align-items: center;
24
  justify-content: space-between;
@@ -28,12 +28,13 @@
28
  left: 0;
29
  right: 0;
30
  z-index: 100;
 
31
  }
32
  .logo {
33
  font-family: 'DM Mono', monospace;
34
  font-weight: 500;
35
  font-size: 16px;
36
- color: #18181B;
37
  letter-spacing: -0.5px;
38
  }
39
  .header-center {
@@ -43,162 +44,172 @@
43
  }
44
  .task-select {
45
  font-family: 'DM Sans', sans-serif;
46
- padding: 6px 12px;
47
- border: 1px solid #E4E4E7;
48
- border-radius: 6px;
49
  font-size: 14px;
50
- background: #FFFFFF;
 
51
  cursor: pointer;
 
52
  }
 
 
53
  .btn {
54
  font-family: 'DM Sans', sans-serif;
55
- padding: 6px 16px;
56
  border: none;
57
- border-radius: 6px;
58
  font-size: 14px;
59
- font-weight: 500;
60
  cursor: pointer;
61
- transition: all 0.2s;
 
 
62
  }
63
  .btn-primary {
64
- background: #2D6A4F;
65
  color: #FFFFFF;
 
 
 
 
 
 
66
  }
67
- .btn-primary:hover { background: #40916C; }
68
  .btn-primary:disabled {
69
- background: #E4E4E7;
70
- color: #71717A;
71
  cursor: not-allowed;
 
 
72
  }
 
 
 
 
 
73
  .status-indicator {
74
  display: flex;
75
  align-items: center;
76
  gap: 6px;
77
  font-size: 13px;
78
- font-weight: 500;
 
79
  }
80
  .status-dot {
81
  width: 8px;
82
  height: 8px;
83
  border-radius: 50%;
 
84
  }
85
- .status-ready .status-dot { background: #16A34A; }
86
- .status-active .status-dot { background: #D97706; }
87
- .status-error .status-dot { background: #DC2626; }
88
- .status-ready { color: #16A34A; }
89
- .status-active { color: #D97706; }
90
- .status-error { color: #DC2626; }
 
91
  .main-container {
92
  display: grid;
93
  grid-template-columns: 1fr 1fr;
94
- gap: 24px;
95
- padding: 76px 24px 24px;
96
  max-width: 1600px;
97
  margin: 0 auto;
98
  }
99
  .panel {
100
  background: #FFFFFF;
101
- border: 1px solid #E4E4E7;
102
- border-radius: 8px;
103
  padding: 20px;
 
 
104
  }
 
105
  .panel-title {
106
- font-size: 14px;
107
- font-weight: 600;
108
- color: #18181B;
109
  margin-bottom: 16px;
110
  padding-bottom: 12px;
111
- border-bottom: 1px solid #E4E4E7;
112
  }
113
  .zone-label {
114
  font-size: 11px;
115
- font-weight: 600;
116
  text-transform: uppercase;
117
- letter-spacing: 0.5px;
118
- color: #71717A;
119
- margin-bottom: 8px;
120
- }
121
- .upload-section {
122
- margin-bottom: 20px;
123
  }
 
124
  .file-input-wrapper {
125
  display: flex;
126
  gap: 12px;
127
  align-items: center;
128
  }
129
- .file-input {
130
- display: none;
131
- }
132
  .file-input-label {
133
  font-family: 'DM Sans', sans-serif;
134
  padding: 8px 16px;
135
- border: 1px solid #E4E4E7;
136
- border-radius: 6px;
137
  font-size: 14px;
138
  cursor: pointer;
139
- background: #FFFFFF;
140
  display: inline-block;
 
141
  }
142
- .file-input-label:hover { background: #FAFAFA; }
143
- .file-name {
144
- font-size: 13px;
145
- color: #71717A;
146
- }
147
  .schema-panel {
148
- min-height: 120px;
149
  max-height: 300px;
150
  overflow-y: auto;
151
  }
152
- .schema-empty {
153
- font-size: 13px;
154
- color: #71717A;
155
- font-style: italic;
156
- }
157
  .schema-table-block {
158
- margin-bottom: 16px;
159
  padding: 12px;
160
- background: #FAFAFA;
161
- border-radius: 6px;
 
162
  }
163
  .schema-table-name {
164
  font-family: 'DM Mono', monospace;
165
  font-weight: 500;
166
  font-size: 13px;
167
- color: #18181B;
168
  margin-bottom: 4px;
169
  }
170
- .schema-table-meta {
171
- font-size: 12px;
172
- color: #71717A;
173
- margin-bottom: 8px;
174
- }
175
- .schema-columns {
176
- display: flex;
177
- flex-wrap: wrap;
178
- gap: 6px;
179
- }
180
  .schema-column {
181
  font-family: 'DM Mono', monospace;
182
  font-size: 11px;
183
- padding: 3px 8px;
184
  background: #FFFFFF;
185
- border: 1px solid #E4E4E7;
186
- border-radius: 4px;
187
  cursor: pointer;
188
  transition: all 0.2s;
 
 
189
  }
190
  .schema-column:hover {
191
- background: #2D6A4F;
192
  color: #FFFFFF;
193
  border-color: #2D6A4F;
 
 
194
  }
195
- .query-section {
196
- margin-top: 20px;
197
- }
198
  .section-label {
199
  font-size: 13px;
200
- font-weight: 500;
201
- color: #18181B;
202
  margin-bottom: 8px;
203
  }
204
  .sql-textarea {
@@ -206,25 +217,29 @@
206
  width: 100%;
207
  min-height: 80px;
208
  padding: 12px;
209
- border: 1px solid #E4E4E7;
210
- border-radius: 6px;
211
  font-size: 13px;
212
  resize: vertical;
 
 
213
  }
214
  .sql-textarea:focus {
215
  outline: none;
216
  border-color: #2D6A4F;
 
217
  }
218
  .query-controls {
219
  display: flex;
220
- gap: 12px;
221
  margin-top: 12px;
222
  }
223
  .result-panel {
224
  margin-top: 16px;
225
- min-height: 100px;
226
  max-height: 400px;
227
  overflow: auto;
 
228
  }
229
  .result-table {
230
  width: 100%;
@@ -235,133 +250,217 @@
235
  .result-table td {
236
  padding: 8px 12px;
237
  text-align: left;
238
- border-bottom: 1px solid #E4E4E7;
239
  font-family: 'DM Mono', monospace;
240
  }
241
  .result-table th {
242
- background: #FAFAFA;
243
- font-weight: 500;
244
- color: #18181B;
 
 
245
  }
246
  .result-table tr:nth-child(even) { background: #FAFAFA; }
247
- .result-table tr:hover { background: #F3F4F6; }
248
- .result-empty {
249
- font-size: 13px;
250
- color: #71717A;
251
- font-style: italic;
252
- padding: 20px;
253
- }
254
  .error-box {
255
- padding: 12px;
256
  background: #FEF2F2;
257
  border: 1px solid #FECACA;
258
- border-radius: 6px;
259
  color: #DC2626;
260
  font-size: 13px;
261
  margin-top: 12px;
262
  }
 
 
 
 
 
 
 
 
 
 
 
263
  .task-description {
264
  font-size: 13px;
265
- color: #71717A;
266
  margin: 12px 0;
267
- line-height: 1.5;
 
 
 
 
 
 
 
 
 
 
268
  }
269
  .fix-context-label {
270
  font-size: 12px;
271
- color: #71717A;
272
  font-style: italic;
273
  margin-bottom: 8px;
274
  }
275
  .insights-panel {
276
  margin-top: 16px;
277
- padding: 12px;
278
- background: #FAFAFA;
279
- border-radius: 6px;
280
  min-height: 80px;
 
281
  }
282
- .insights-empty {
283
- font-size: 13px;
284
- color: #71717A;
285
- font-style: italic;
286
- }
287
  .insight-item {
288
  font-size: 12px;
289
- color: #18181B;
290
  margin-bottom: 6px;
 
 
291
  }
292
- .suggestions-panel {
293
- margin-top: 16px;
 
 
 
 
 
 
 
 
 
 
 
294
  }
 
295
  .suggestion-item {
296
- padding: 10px 12px;
297
  background: #F0FDF4;
298
  border: 1px solid #BBF7D0;
299
- border-radius: 6px;
300
  margin-bottom: 8px;
301
  font-size: 12px;
302
  color: #166534;
 
 
303
  }
 
304
  .results-panel {
305
  margin-top: 16px;
306
- padding: 16px;
307
- background: #FAFAFA;
308
- border-radius: 6px;
 
309
  }
310
  .score-display {
311
- font-size: 32px;
312
- font-weight: 600;
313
- color: #2D6A4F;
 
 
 
314
  margin-bottom: 16px;
315
  }
316
- .dimension-bar {
317
- margin-bottom: 10px;
318
- }
319
  .dimension-label {
320
  display: flex;
321
  justify-content: space-between;
322
  font-size: 12px;
323
  margin-bottom: 4px;
 
324
  }
325
  .dimension-track {
326
  height: 8px;
327
- background: #E4E4E7;
328
  border-radius: 4px;
329
  overflow: hidden;
330
  }
331
  .dimension-fill {
332
  height: 100%;
333
- background: #2D6A4F;
334
  border-radius: 4px;
335
- transition: width 0.3s;
336
  }
337
  .feedback-text {
338
  font-size: 13px;
339
- color: #18181B;
340
- margin-top: 12px;
341
- line-height: 1.5;
342
- }
343
- .extra-fields {
344
- margin-top: 12px;
345
- display: flex;
346
- gap: 12px;
347
- }
348
- .extra-field {
349
- flex: 1;
350
- }
351
- .extra-field label {
352
- display: block;
353
- font-size: 12px;
354
- color: #71717A;
355
- margin-bottom: 4px;
356
  }
 
 
 
357
  .extra-field input {
358
  font-family: 'DM Mono', monospace;
359
  width: 100%;
360
  padding: 6px 10px;
361
- border: 1px solid #E4E4E7;
362
- border-radius: 6px;
363
  font-size: 13px;
 
364
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  @media (max-width: 1024px) {
366
  .main-container { grid-template-columns: 1fr; }
367
  }
@@ -370,7 +469,7 @@
370
  <body>
371
  <header class="header">
372
  <div class="logo">SQL QUALITY ENV</div>
373
- <div class="header-center" id="header-task-section" style="display: none;">
374
  <select class="task-select" id="task-select">
375
  <option value="">Select a task...</option>
376
  <option value="null_audit">null_audit</option>
@@ -394,7 +493,7 @@
394
 
395
  <div class="upload-section">
396
  <div class="file-input-wrapper">
397
- <input type="file" class="file-input" id="file-input" accept=".csv,.sqlite">
398
  <label class="file-input-label" for="file-input">Choose File</label>
399
  <span class="file-name" id="file-name">No file selected</span>
400
  <button class="btn btn-primary" id="upload-btn" onclick="uploadFile()">Upload</button>
@@ -409,7 +508,7 @@
409
  <div class="section-label">Run a SELECT query on your uploaded data</div>
410
  <textarea class="sql-textarea" id="query-sql" placeholder="SELECT * FROM your_table LIMIT 10"></textarea>
411
  <div class="query-controls">
412
- <button class="btn btn-primary" onclick="runQuery()">Run Query</button>
413
  </div>
414
  <div id="query-error"></div>
415
  <div class="result-panel" id="query-result">
@@ -417,8 +516,14 @@
417
  </div>
418
  </div>
419
 
 
 
 
 
 
 
420
  <div class="insights-panel" id="insights-panel">
421
- <div class="zone-label">AI Insights</div>
422
  <div class="insights-empty">Run a query to see insights</div>
423
  </div>
424
 
@@ -436,14 +541,14 @@
436
  Select a task from the dropdown above and click Initialize to start an evaluation episode.
437
  </div>
438
 
439
- <div id="rl-schema-panel" style="margin-bottom: 16px;">
440
  <div class="schema-empty">Task schema will appear after initialization</div>
441
  </div>
442
 
443
- <div style="margin-top: 20px;">
444
  <div class="section-label">Submit Your Answer</div>
445
  <div class="fix-context-label" id="fix-context">Select a task to see context</div>
446
- <div class="extra-fields" id="dedup-fields" style="display: none;">
447
  <div class="extra-field">
448
  <label>Root Cause Table</label>
449
  <input type="text" id="root-cause-table" placeholder="table_name">
@@ -455,13 +560,13 @@
455
  </div>
456
  <textarea class="sql-textarea" id="fix-sql" placeholder="Enter your fix query or answer here..."></textarea>
457
  <div class="query-controls">
458
- <button class="btn btn-primary" onclick="applyFix()">Apply Fix</button>
459
- <button class="btn btn-primary" onclick="submitFinal()">Submit Final</button>
460
  </div>
461
  <div id="fix-error"></div>
462
  </div>
463
 
464
- <div class="results-panel" id="results-panel" style="display: none;">
465
  <div class="score-display" id="final-score">0%</div>
466
  <div id="dimension-bars"></div>
467
  <div class="feedback-text" id="feedback-text"></div>
@@ -469,14 +574,21 @@
469
  </div>
470
  </div>
471
 
 
 
 
 
 
472
  <script>
473
  const BASE = window.location.origin;
474
 
475
  let uploadedFile = null;
476
  let uploadedSchema = [];
 
477
  let episodeId = null;
478
  let currentTask = null;
479
  let lastQueryResult = null;
 
480
  let mode = 'upload';
481
 
482
  const taskDescriptions = {
@@ -495,14 +607,38 @@
495
  schema_drift: 'Enter a SQL query that normalizes the schema'
496
  };
497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  async function checkHealth() {
499
  try {
500
  const r = await fetch(BASE + '/health');
501
  const indicator = document.getElementById('status-indicator');
502
  const text = document.getElementById('status-text');
503
-
504
  if (r.ok) {
505
- const data = await r.json();
506
  if (uploadedFile || episodeId) {
507
  indicator.className = 'status-indicator status-active';
508
  text.textContent = 'Active';
@@ -516,67 +652,87 @@
516
  }
517
  } catch (e) {
518
  document.getElementById('status-indicator').className = 'status-indicator status-error';
519
- document.getElementById('status-text').textContent = 'Error';
520
  }
521
  }
522
 
 
523
  document.getElementById('file-input').addEventListener('change', function() {
524
  const file = this.files[0];
525
  document.getElementById('file-name').textContent = file ? file.name : 'No file selected';
526
  });
527
 
 
528
  document.getElementById('task-select').addEventListener('change', function() {
529
  currentTask = this.value;
530
  document.getElementById('init-btn').disabled = !currentTask;
531
- document.getElementById('task-description').textContent = currentTask
532
- ? taskDescriptions[currentTask]
533
- : 'Select a task from the dropdown above and click Initialize to start an evaluation episode.';
534
- document.getElementById('fix-context').textContent = currentTask
535
- ? fixContexts[currentTask]
 
 
 
 
 
 
 
536
  : 'Select a task to see context';
537
- document.getElementById('dedup-fields').style.display = currentTask === 'dedup_bug' ? 'flex' : 'none';
 
 
 
 
 
 
538
  });
539
 
 
540
  async function uploadFile() {
541
  const fileInput = document.getElementById('file-input');
542
  const file = fileInput.files[0];
543
-
544
  if (!file) {
545
  showError('query-error', 'Please select a file first.');
546
  return;
547
  }
548
 
 
549
  const formData = new FormData();
550
  formData.append('file', file);
551
 
552
  try {
553
- const r = await fetch(BASE + '/upload-db', {
554
- method: 'POST',
555
- body: formData
556
- });
557
-
558
  const data = await r.json();
559
-
560
  if (!r.ok) {
561
  showError('query-error', data.detail || 'Upload failed');
562
  return;
563
  }
564
-
565
  uploadedFile = file.name;
566
  uploadedSchema = data.tables || [];
 
567
  mode = 'upload';
568
-
569
- renderSchema(data.tables);
570
- document.getElementById('header-task-section').style.display = 'flex';
571
  document.getElementById('query-error').innerHTML = '';
 
 
 
 
 
 
 
572
  checkHealth();
573
  } catch (e) {
574
  showError('query-error', 'Upload error: ' + e.message);
 
 
575
  }
576
  }
577
 
578
- function renderSchema(tables) {
579
- const panel = document.getElementById('schema-panel');
 
580
  if (!tables || tables.length === 0) {
581
  panel.innerHTML = '<div class="schema-empty">No schema available</div>';
582
  return;
@@ -596,7 +752,7 @@
596
  for (const col of columns) {
597
  const colName = typeof col === 'string' ? col.split(' (')[0] : (col.name || col);
598
  const colType = typeof col === 'string' ? (col.match(/\(([^)]+)\)/)?.[1] || 'TEXT') : (col.type || 'TEXT');
599
- html += '<span class="schema-column" onclick="insertColumn(\'' + escapeHtml(colName) + '\')">' +
600
  escapeHtml(colName) + ' <small>(' + escapeHtml(colType) + ')</small></span>';
601
  }
602
 
@@ -605,42 +761,57 @@
605
  panel.innerHTML = html;
606
  }
607
 
608
- function insertColumn(columnName) {
609
- const textarea = document.getElementById('query-sql');
 
 
610
  const cursorPos = textarea.selectionStart;
611
  const textBefore = textarea.value.substring(0, cursorPos);
612
  const textAfter = textarea.value.substring(cursorPos);
613
  textarea.value = textBefore + '"' + columnName + '"' + textAfter;
614
  textarea.focus();
 
 
 
 
 
 
 
 
 
 
615
  }
616
 
 
617
  async function runQuery() {
618
  const sql = document.getElementById('query-sql').value.trim();
619
-
620
- if (!uploadedFile) {
621
- showError('query-error', 'Upload a file first.');
622
- return;
623
- }
624
-
625
- if (!sql) {
626
- showError('query-error', 'Enter a SQL query.');
627
- return;
628
- }
629
 
630
- if (!sql.toUpperCase().startsWith('SELECT')) {
631
- showError('query-error', 'Only SELECT queries allowed here.');
 
632
  return;
633
  }
634
 
 
635
  try {
636
  const r = await fetch(BASE + '/query-custom', {
637
  method: 'POST',
638
  headers: { 'Content-Type': 'application/json' },
639
  body: JSON.stringify({ sql })
640
  });
641
-
642
  const data = await r.json();
643
 
 
 
 
 
 
 
 
 
 
644
  if (!r.ok) {
645
  showError('query-error', data.detail || 'Query failed');
646
  return;
@@ -648,86 +819,157 @@
648
 
649
  document.getElementById('query-error').innerHTML = '';
650
  lastQueryResult = data.rows || [];
 
651
  renderQueryResults(lastQueryResult);
652
- computeInsights(lastQueryResult);
 
 
 
 
 
 
 
653
  generateSuggestions(lastQueryResult);
 
 
654
  } catch (e) {
655
  showError('query-error', 'Query error: ' + e.message);
 
 
656
  }
657
  }
658
 
 
659
  function renderQueryResults(rows) {
660
  const panel = document.getElementById('query-result');
661
-
662
  if (!rows || rows.length === 0) {
663
  panel.innerHTML = '<div class="result-empty">No results returned</div>';
664
  return;
665
  }
666
-
667
  const columns = Object.keys(rows[0]);
668
  let html = '<table class="result-table"><thead><tr>';
669
- for (const col of columns) {
670
- html += '<th>' + escapeHtml(col) + '</th>';
671
- }
672
  html += '</tr></thead><tbody>';
673
-
674
- for (const row of rows) {
675
  html += '<tr>';
676
  for (const col of columns) {
677
  const val = row[col];
678
- const display = val === null ? 'NULL' : String(val);
679
- html += '<td>' + escapeHtml(display) + '</td>';
680
  }
681
  html += '</tr>';
682
  }
683
-
684
  html += '</tbody></table>';
 
685
  panel.innerHTML = html;
686
  }
687
 
688
- function computeInsights(rows) {
 
689
  const panel = document.getElementById('insights-panel');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
 
 
 
 
 
 
 
 
691
  if (!rows || rows.length === 0) {
692
- panel.innerHTML = '<div class="zone-label">AI Insights</div><div class="insights-empty">Query returned no data. Try a broader WHERE clause.</div>';
693
  return;
694
  }
695
 
696
  const columns = Object.keys(rows[0]);
697
  const insights = [];
698
 
699
- insights.push('Row count returned: ' + rows.length);
700
 
 
 
701
  for (const col of columns) {
702
- const values = rows.map(r => r[col]).filter(v => v !== null && v !== undefined);
703
- const nullCount = rows.length - values.length;
704
-
705
  if (nullCount > 0) {
706
- insights.push('Column "' + col + '": ' + nullCount + ' NULLs (' + Math.round(nullCount/rows.length*100) + '%)');
 
707
  }
708
-
709
- const distinct = new Set(values.map(String)).size;
710
- insights.push('Column "' + col + '": ' + distinct + ' distinct values');
711
-
712
- const numeric = values.filter(v => !isNaN(parseFloat(v)) && isFinite(v));
713
- if (numeric.length > 0) {
 
 
 
 
 
 
 
 
 
 
 
714
  const nums = numeric.map(v => parseFloat(v));
715
- insights.push('Column "' + col + '": numeric range ' + Math.min(...nums) + ' to ' + Math.max(...nums));
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  }
717
  }
718
 
719
- let html = '<div class="zone-label">AI Insights</div>';
720
- for (const insight of insights.slice(0, 6)) {
721
  html += '<div class="insight-item">' + escapeHtml(insight) + '</div>';
722
  }
723
  panel.innerHTML = html;
724
  }
725
 
 
726
  function generateSuggestions(rows) {
727
  const panel = document.getElementById('suggestions-panel');
728
-
729
  if (!rows || rows.length === 0) {
730
- panel.innerHTML = '<div class="zone-label">Smart Task Suggestions</div><div class="insights-empty">Upload data and run a query to see suggestions</div>';
731
  return;
732
  }
733
 
@@ -737,105 +979,129 @@
737
  for (const col of columns) {
738
  const values = rows.map(r => r[col]);
739
  const nullCount = values.filter(v => v === null || v === undefined).length;
740
-
741
  if (nullCount > 0) {
742
  const pct = Math.round(nullCount / rows.length * 100);
743
  if (pct > 10) {
744
- suggestions.push('Consider running null_audit task β€” column "' + col + '" has ' + pct + '% nulls');
 
 
 
745
  }
746
  }
747
-
748
- const nonNull = values.filter(v => v !== null && v !== undefined);
749
- const looksNumeric = nonNull.length > 0 && nonNull.every(v => {
750
- const s = String(v).trim();
751
- return /^-?\d+\.?\d*$/.test(s.replace(/[$,%]/g, ''));
752
- });
753
-
754
- if (looksNumeric) {
755
- const sample = nonNull.slice(0, 3).map(String).join(', ');
756
- suggestions.push('Consider running type_repair task β€” column "' + col + '" contains numeric-looking strings: ' + sample);
757
- }
758
  }
759
 
 
760
  const allValues = rows.flatMap(r => Object.values(r)).filter(v => v !== null);
761
  const duplicates = allValues.filter((v, i, a) => a.indexOf(v) !== i);
762
- if (duplicates.length > 0) {
763
- suggestions.push('Consider running dedup_bug task β€” duplicate values detected');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
764
  }
765
 
766
  if (suggestions.length === 0) {
767
- suggestions.push('Data looks clean. Try running an RL task to verify.');
768
  }
769
 
770
  let html = '<div class="zone-label">Smart Task Suggestions</div>';
771
- for (const suggestion of suggestions.slice(0, 3)) {
772
- html += '<div class="suggestion-item">' + escapeHtml(suggestion) + '</div>';
 
 
 
773
  }
774
  panel.innerHTML = html;
775
  }
776
 
777
- async function initializeEpisode() {
778
- if (!currentTask) {
779
- alert('Select a task first');
780
- return;
 
781
  }
 
 
 
 
 
 
 
 
 
782
 
783
  try {
784
- const r = await fetch(BASE + '/reset', {
 
785
  method: 'POST',
786
  headers: { 'Content-Type': 'application/json' },
787
  body: JSON.stringify({ task_id: currentTask })
788
  });
789
-
790
  const data = await r.json();
791
 
792
  if (!r.ok) {
793
- alert('Initialization failed: ' + (data.detail || 'Unknown error'));
794
  return;
795
  }
796
 
797
  episodeId = data.episode_id;
798
  mode = 'task';
799
-
800
- const schemaPanel = document.getElementById('rl-schema-panel');
801
- if (data.schema_info && data.schema_info.length > 0) {
802
- let html = '';
803
- for (const tbl of data.schema_info) {
804
- html += '<div class="schema-table-block">';
805
- html += '<div class="schema-table-name">' + escapeHtml(tbl.table_name) + '</div>';
806
- html += '<div class="schema-table-meta">' + (tbl.row_count || '?') + ' rows</div>';
807
- html += '<div class="schema-columns">';
808
- for (const col of tbl.columns) {
809
- const colName = typeof col === 'string' ? col.split(' (')[0] : col.name;
810
- html += '<span class="schema-column">' + escapeHtml(colName) + '</span>';
811
- }
812
- html += '</div></div>';
813
- }
814
- schemaPanel.innerHTML = html;
815
  }
816
 
 
 
 
 
 
 
 
 
 
 
 
817
  checkHealth();
818
  } catch (e) {
819
- alert('Initialization error: ' + e.message);
 
 
820
  }
821
  }
822
 
 
823
  async function applyFix() {
824
- if (!episodeId) {
825
- showError('fix-error', 'Initialize an episode first.');
826
- return;
827
- }
828
 
829
  const sql = document.getElementById('fix-sql').value.trim();
830
- if (!sql) {
831
- showError('fix-error', 'Enter a fix query.');
832
- return;
833
- }
834
 
 
 
 
835
  const body = {
836
  task_id: currentTask,
837
  action_type: 'fix',
838
- sql: sql
839
  };
840
 
841
  if (currentTask === 'dedup_bug') {
@@ -849,7 +1115,6 @@
849
  headers: { 'Content-Type': 'application/json' },
850
  body: JSON.stringify(body)
851
  });
852
-
853
  const data = await r.json();
854
 
855
  if (!r.ok) {
@@ -857,18 +1122,26 @@
857
  return;
858
  }
859
 
860
- document.getElementById('fix-error').innerHTML = '';
 
 
 
 
 
 
 
861
  } catch (e) {
862
  showError('fix-error', 'Fix error: ' + e.message);
 
 
863
  }
864
  }
865
 
 
866
  async function submitFinal() {
867
- if (!episodeId) {
868
- showError('fix-error', 'Initialize an episode first.');
869
- return;
870
- }
871
 
 
872
  const sql = document.getElementById('fix-sql').value.trim();
873
 
874
  const body = {
@@ -876,8 +1149,12 @@
876
  action_type: 'submit'
877
  };
878
 
879
- if (sql) {
880
- body.sql = sql;
 
 
 
 
881
  }
882
 
883
  if (currentTask === 'dedup_bug') {
@@ -891,7 +1168,6 @@
891
  headers: { 'Content-Type': 'application/json' },
892
  body: JSON.stringify(body)
893
  });
894
-
895
  const data = await r.json();
896
 
897
  if (!r.ok) {
@@ -901,14 +1177,18 @@
901
 
902
  document.getElementById('fix-error').innerHTML = '';
903
  renderResults(data.reward);
 
904
  } catch (e) {
905
  showError('fix-error', 'Submit error: ' + e.message);
 
 
906
  }
907
  }
908
 
 
909
  function renderResults(reward) {
910
  const panel = document.getElementById('results-panel');
911
- panel.style.display = 'block';
912
 
913
  const score = reward?.score ?? 0;
914
  document.getElementById('final-score').textContent = Math.round(score * 100) + '%';
@@ -917,25 +1197,92 @@
917
  let barsHtml = '';
918
 
919
  const dimNames = {
920
- d1_diagnostic_efficiency: 'D1 - Diagnostic Efficiency',
921
- d2_hypothesis_quality: 'D2 - Hypothesis Quality',
922
- d3_fix_correctness: 'D3 - Fix Correctness',
923
- d4_self_verification: 'D4 - Self Verification',
924
- d5_generalization: 'D5 - Generalization'
925
  };
926
 
927
  for (const [key, label] of Object.entries(dimNames)) {
928
  const val = dims[key] ?? 0;
 
 
929
  barsHtml += '<div class="dimension-bar">';
930
- barsHtml += '<div class="dimension-label"><span>' + label + '</span><span>' + Math.round(val * 100) + '%</span></div>';
931
- barsHtml += '<div class="dimension-track"><div class="dimension-fill" style="width: ' + (val * 100) + '%"></div></div>';
932
  barsHtml += '</div>';
933
  }
934
 
935
  document.getElementById('dimension-bars').innerHTML = barsHtml;
936
- document.getElementById('feedback-text').textContent = reward?.feedback || '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
937
  }
938
 
 
939
  function showError(elementId, message) {
940
  document.getElementById(elementId).innerHTML = '<div class="error-box">' + escapeHtml(message) + '</div>';
941
  }
@@ -949,6 +1296,7 @@
949
  .replace(/"/g, '&quot;');
950
  }
951
 
 
952
  checkHealth();
953
  setInterval(checkHealth, 30000);
954
  </script>
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>SQL Quality Environment</title>
7
+ <meta name="description" content="OpenEnv-compatible SQL data quality audit environment for AI agent training and evaluation">
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
11
  <style>
12
  * { box-sizing: border-box; margin: 0; padding: 0; }
13
  body {
14
  font-family: 'DM Sans', sans-serif;
15
+ background: #F8F9FA;
16
  color: #18181B;
17
  min-height: 100vh;
18
  }
19
  .header {
20
+ height: 56px;
21
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
 
22
  display: flex;
23
  align-items: center;
24
  justify-content: space-between;
 
28
  left: 0;
29
  right: 0;
30
  z-index: 100;
31
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
32
  }
33
  .logo {
34
  font-family: 'DM Mono', monospace;
35
  font-weight: 500;
36
  font-size: 16px;
37
+ color: #E0E7FF;
38
  letter-spacing: -0.5px;
39
  }
40
  .header-center {
 
44
  }
45
  .task-select {
46
  font-family: 'DM Sans', sans-serif;
47
+ padding: 6px 14px;
48
+ border: 1px solid rgba(255,255,255,0.2);
49
+ border-radius: 8px;
50
  font-size: 14px;
51
+ background: rgba(255,255,255,0.1);
52
+ color: #E0E7FF;
53
  cursor: pointer;
54
+ transition: all 0.2s;
55
  }
56
+ .task-select:hover { background: rgba(255,255,255,0.15); }
57
+ .task-select option { background: #1a1a2e; color: #E0E7FF; }
58
  .btn {
59
  font-family: 'DM Sans', sans-serif;
60
+ padding: 7px 18px;
61
  border: none;
62
+ border-radius: 8px;
63
  font-size: 14px;
64
+ font-weight: 600;
65
  cursor: pointer;
66
+ transition: all 0.25s ease;
67
+ position: relative;
68
+ overflow: hidden;
69
  }
70
  .btn-primary {
71
+ background: linear-gradient(135deg, #2D6A4F 0%, #40916C 100%);
72
  color: #FFFFFF;
73
+ box-shadow: 0 2px 6px rgba(45,106,79,0.3);
74
+ }
75
+ .btn-primary:hover {
76
+ background: linear-gradient(135deg, #40916C 0%, #52B788 100%);
77
+ box-shadow: 0 4px 12px rgba(45,106,79,0.4);
78
+ transform: translateY(-1px);
79
  }
80
+ .btn-primary:active { transform: translateY(0); }
81
  .btn-primary:disabled {
82
+ background: #D1D5DB;
83
+ color: #9CA3AF;
84
  cursor: not-allowed;
85
+ box-shadow: none;
86
+ transform: none;
87
  }
88
+ .btn-danger {
89
+ background: linear-gradient(135deg, #DC2626 0%, #EF4444 100%);
90
+ color: #FFFFFF;
91
+ }
92
+ .btn-danger:hover { background: linear-gradient(135deg, #EF4444 0%, #F87171 100%); }
93
  .status-indicator {
94
  display: flex;
95
  align-items: center;
96
  gap: 6px;
97
  font-size: 13px;
98
+ font-weight: 600;
99
+ color: #E0E7FF;
100
  }
101
  .status-dot {
102
  width: 8px;
103
  height: 8px;
104
  border-radius: 50%;
105
+ animation: pulse 2s infinite;
106
  }
107
+ @keyframes pulse {
108
+ 0%, 100% { opacity: 1; }
109
+ 50% { opacity: 0.5; }
110
+ }
111
+ .status-ready .status-dot { background: #22C55E; }
112
+ .status-active .status-dot { background: #F59E0B; animation: pulse 1s infinite; }
113
+ .status-error .status-dot { background: #EF4444; }
114
  .main-container {
115
  display: grid;
116
  grid-template-columns: 1fr 1fr;
117
+ gap: 20px;
118
+ padding: 76px 24px 80px;
119
  max-width: 1600px;
120
  margin: 0 auto;
121
  }
122
  .panel {
123
  background: #FFFFFF;
124
+ border: 1px solid #E5E7EB;
125
+ border-radius: 12px;
126
  padding: 20px;
127
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
128
+ transition: box-shadow 0.3s;
129
  }
130
+ .panel:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
131
  .panel-title {
132
+ font-size: 15px;
133
+ font-weight: 700;
134
+ color: #111827;
135
  margin-bottom: 16px;
136
  padding-bottom: 12px;
137
+ border-bottom: 2px solid #E5E7EB;
138
  }
139
  .zone-label {
140
  font-size: 11px;
141
+ font-weight: 700;
142
  text-transform: uppercase;
143
+ letter-spacing: 1px;
144
+ color: #6B7280;
145
+ margin-bottom: 6px;
 
 
 
146
  }
147
+ .upload-section { margin-bottom: 20px; }
148
  .file-input-wrapper {
149
  display: flex;
150
  gap: 12px;
151
  align-items: center;
152
  }
153
+ .file-input { display: none; }
 
 
154
  .file-input-label {
155
  font-family: 'DM Sans', sans-serif;
156
  padding: 8px 16px;
157
+ border: 1.5px dashed #D1D5DB;
158
+ border-radius: 8px;
159
  font-size: 14px;
160
  cursor: pointer;
161
+ background: #FAFAFA;
162
  display: inline-block;
163
+ transition: all 0.2s;
164
  }
165
+ .file-input-label:hover { border-color: #2D6A4F; background: #F0FDF4; color: #2D6A4F; }
166
+ .file-name { font-size: 13px; color: #6B7280; }
 
 
 
167
  .schema-panel {
168
+ min-height: 100px;
169
  max-height: 300px;
170
  overflow-y: auto;
171
  }
172
+ .schema-empty { font-size: 13px; color: #9CA3AF; font-style: italic; }
 
 
 
 
173
  .schema-table-block {
174
+ margin-bottom: 14px;
175
  padding: 12px;
176
+ background: #F9FAFB;
177
+ border-radius: 8px;
178
+ border: 1px solid #F3F4F6;
179
  }
180
  .schema-table-name {
181
  font-family: 'DM Mono', monospace;
182
  font-weight: 500;
183
  font-size: 13px;
184
+ color: #111827;
185
  margin-bottom: 4px;
186
  }
187
+ .schema-table-meta { font-size: 12px; color: #6B7280; margin-bottom: 8px; }
188
+ .schema-columns { display: flex; flex-wrap: wrap; gap: 6px; }
 
 
 
 
 
 
 
 
189
  .schema-column {
190
  font-family: 'DM Mono', monospace;
191
  font-size: 11px;
192
+ padding: 4px 10px;
193
  background: #FFFFFF;
194
+ border: 1px solid #E5E7EB;
195
+ border-radius: 6px;
196
  cursor: pointer;
197
  transition: all 0.2s;
198
+ -webkit-user-select: none;
199
+ user-select: none;
200
  }
201
  .schema-column:hover {
202
+ background: linear-gradient(135deg, #2D6A4F, #40916C);
203
  color: #FFFFFF;
204
  border-color: #2D6A4F;
205
+ transform: translateY(-1px);
206
+ box-shadow: 0 2px 6px rgba(45,106,79,0.3);
207
  }
208
+ .query-section { margin-top: 20px; }
 
 
209
  .section-label {
210
  font-size: 13px;
211
+ font-weight: 600;
212
+ color: #111827;
213
  margin-bottom: 8px;
214
  }
215
  .sql-textarea {
 
217
  width: 100%;
218
  min-height: 80px;
219
  padding: 12px;
220
+ border: 1.5px solid #E5E7EB;
221
+ border-radius: 8px;
222
  font-size: 13px;
223
  resize: vertical;
224
+ transition: border-color 0.2s, box-shadow 0.2s;
225
+ line-height: 1.5;
226
  }
227
  .sql-textarea:focus {
228
  outline: none;
229
  border-color: #2D6A4F;
230
+ box-shadow: 0 0 0 3px rgba(45,106,79,0.1);
231
  }
232
  .query-controls {
233
  display: flex;
234
+ gap: 10px;
235
  margin-top: 12px;
236
  }
237
  .result-panel {
238
  margin-top: 16px;
239
+ min-height: 80px;
240
  max-height: 400px;
241
  overflow: auto;
242
+ border-radius: 8px;
243
  }
244
  .result-table {
245
  width: 100%;
 
250
  .result-table td {
251
  padding: 8px 12px;
252
  text-align: left;
253
+ border-bottom: 1px solid #F3F4F6;
254
  font-family: 'DM Mono', monospace;
255
  }
256
  .result-table th {
257
+ background: #F9FAFB;
258
+ font-weight: 600;
259
+ color: #374151;
260
+ position: sticky;
261
+ top: 0;
262
  }
263
  .result-table tr:nth-child(even) { background: #FAFAFA; }
264
+ .result-table tr:hover { background: #F0FDF4; }
265
+ .result-empty { font-size: 13px; color: #9CA3AF; font-style: italic; padding: 20px; }
 
 
 
 
 
266
  .error-box {
267
+ padding: 12px 16px;
268
  background: #FEF2F2;
269
  border: 1px solid #FECACA;
270
+ border-radius: 8px;
271
  color: #DC2626;
272
  font-size: 13px;
273
  margin-top: 12px;
274
  }
275
+ .success-box {
276
+ padding: 12px 16px;
277
+ background: #F0FDF4;
278
+ border: 1px solid #BBF7D0;
279
+ border-radius: 8px;
280
+ color: #166534;
281
+ font-size: 13px;
282
+ margin-top: 12px;
283
+ animation: fadeIn 0.3s ease;
284
+ }
285
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
286
  .task-description {
287
  font-size: 13px;
288
+ color: #6B7280;
289
  margin: 12px 0;
290
+ line-height: 1.6;
291
+ font-weight: 500;
292
+ padding: 10px 14px;
293
+ background: #F9FAFB;
294
+ border-radius: 8px;
295
+ border-left: 3px solid #D1D5DB;
296
+ }
297
+ .task-description.active {
298
+ color: #F97316;
299
+ background: #FFF7ED;
300
+ border-left-color: #F97316;
301
  }
302
  .fix-context-label {
303
  font-size: 12px;
304
+ color: #6B7280;
305
  font-style: italic;
306
  margin-bottom: 8px;
307
  }
308
  .insights-panel {
309
  margin-top: 16px;
310
+ padding: 14px;
311
+ background: linear-gradient(135deg, #F8FAFC, #F1F5F9);
312
+ border-radius: 8px;
313
  min-height: 80px;
314
+ border: 1px solid #E2E8F0;
315
  }
316
+ .insights-empty { font-size: 13px; color: #94A3B8; font-style: italic; }
 
 
 
 
317
  .insight-item {
318
  font-size: 12px;
319
+ color: #1E293B;
320
  margin-bottom: 6px;
321
+ padding: 4px 0;
322
+ line-height: 1.5;
323
  }
324
+ .insight-item::before { content: 'β†’ '; color: #2D6A4F; font-weight: bold; }
325
+ .ai-badge {
326
+ display: inline-flex;
327
+ align-items: center;
328
+ gap: 4px;
329
+ font-size: 10px;
330
+ font-weight: 700;
331
+ color: #7C3AED;
332
+ background: #EDE9FE;
333
+ padding: 2px 8px;
334
+ border-radius: 10px;
335
+ margin-left: 8px;
336
+ letter-spacing: 0.5px;
337
  }
338
+ .suggestions-panel { margin-top: 16px; }
339
  .suggestion-item {
340
+ padding: 10px 14px;
341
  background: #F0FDF4;
342
  border: 1px solid #BBF7D0;
343
+ border-radius: 8px;
344
  margin-bottom: 8px;
345
  font-size: 12px;
346
  color: #166534;
347
+ cursor: pointer;
348
+ transition: all 0.2s;
349
  }
350
+ .suggestion-item:hover { background: #DCFCE7; transform: translateX(2px); }
351
  .results-panel {
352
  margin-top: 16px;
353
+ padding: 20px;
354
+ background: linear-gradient(135deg, #F8FAFC, #F0FDF4);
355
+ border-radius: 10px;
356
+ border: 1px solid #D1FAE5;
357
  }
358
  .score-display {
359
+ font-size: 42px;
360
+ font-weight: 700;
361
+ background: linear-gradient(135deg, #2D6A4F, #16A34A);
362
+ -webkit-background-clip: text;
363
+ -webkit-text-fill-color: transparent;
364
+ background-clip: text;
365
  margin-bottom: 16px;
366
  }
367
+ .dimension-bar { margin-bottom: 10px; }
 
 
368
  .dimension-label {
369
  display: flex;
370
  justify-content: space-between;
371
  font-size: 12px;
372
  margin-bottom: 4px;
373
+ font-weight: 500;
374
  }
375
  .dimension-track {
376
  height: 8px;
377
+ background: #E5E7EB;
378
  border-radius: 4px;
379
  overflow: hidden;
380
  }
381
  .dimension-fill {
382
  height: 100%;
383
+ background: linear-gradient(90deg, #2D6A4F, #40916C);
384
  border-radius: 4px;
385
+ transition: width 0.6s ease;
386
  }
387
  .feedback-text {
388
  font-size: 13px;
389
+ color: #374151;
390
+ margin-top: 14px;
391
+ line-height: 1.6;
392
+ padding: 10px;
393
+ background: #FFFFFF;
394
+ border-radius: 6px;
 
 
 
 
 
 
 
 
 
 
 
395
  }
396
+ .extra-fields { margin-top: 12px; display: flex; gap: 12px; }
397
+ .extra-field { flex: 1; }
398
+ .extra-field label { display: block; font-size: 12px; color: #6B7280; margin-bottom: 4px; font-weight: 500; }
399
  .extra-field input {
400
  font-family: 'DM Mono', monospace;
401
  width: 100%;
402
  padding: 6px 10px;
403
+ border: 1.5px solid #E5E7EB;
404
+ border-radius: 8px;
405
  font-size: 13px;
406
+ transition: border-color 0.2s;
407
  }
408
+ .extra-field input:focus { outline: none; border-color: #2D6A4F; }
409
+
410
+ /* Toast notification */
411
+ .toast-bar {
412
+ position: fixed;
413
+ bottom: 0;
414
+ left: 0;
415
+ right: 0;
416
+ height: 40px;
417
+ background: linear-gradient(135deg, #1a1a2e, #16213e);
418
+ color: #E0E7FF;
419
+ display: flex;
420
+ align-items: center;
421
+ justify-content: center;
422
+ font-size: 13px;
423
+ font-weight: 500;
424
+ z-index: 200;
425
+ transform: translateY(100%);
426
+ transition: transform 0.3s ease;
427
+ gap: 8px;
428
+ }
429
+ .toast-bar.visible { transform: translateY(0); }
430
+ .toast-bar.success { background: linear-gradient(135deg, #166534, #15803D); }
431
+ .toast-bar.error { background: linear-gradient(135deg, #991B1B, #DC2626); }
432
+ .toast-bar.info { background: linear-gradient(135deg, #1E40AF, #3B82F6); }
433
+
434
+ /* Loading spinner */
435
+ .spinner {
436
+ display: inline-block;
437
+ width: 14px;
438
+ height: 14px;
439
+ border: 2px solid rgba(255,255,255,0.3);
440
+ border-top-color: #fff;
441
+ border-radius: 50%;
442
+ animation: spin 0.6s linear infinite;
443
+ }
444
+ @keyframes spin { to { transform: rotate(360deg); } }
445
+ .btn-loading { pointer-events: none; opacity: 0.8; }
446
+ .btn-loading .spinner { margin-right: 6px; }
447
+
448
+ /* Chart container */
449
+ .chart-container {
450
+ margin-top: 16px;
451
+ padding: 16px;
452
+ background: #FFFFFF;
453
+ border: 1px solid #E5E7EB;
454
+ border-radius: 8px;
455
+ min-height: 120px;
456
+ }
457
+ .chart-container canvas { width: 100% !important; }
458
+
459
+ /* Utility classes to replace inline styles */
460
+ .hidden { display: none !important; }
461
+ .mt-20 { margin-top: 20px; }
462
+ .mb-16 { margin-bottom: 16px; }
463
+
464
  @media (max-width: 1024px) {
465
  .main-container { grid-template-columns: 1fr; }
466
  }
 
469
  <body>
470
  <header class="header">
471
  <div class="logo">SQL QUALITY ENV</div>
472
+ <div class="header-center hidden" id="header-task-section">
473
  <select class="task-select" id="task-select">
474
  <option value="">Select a task...</option>
475
  <option value="null_audit">null_audit</option>
 
493
 
494
  <div class="upload-section">
495
  <div class="file-input-wrapper">
496
+ <input type="file" class="file-input" id="file-input" accept=".csv,.sqlite,.db">
497
  <label class="file-input-label" for="file-input">Choose File</label>
498
  <span class="file-name" id="file-name">No file selected</span>
499
  <button class="btn btn-primary" id="upload-btn" onclick="uploadFile()">Upload</button>
 
508
  <div class="section-label">Run a SELECT query on your uploaded data</div>
509
  <textarea class="sql-textarea" id="query-sql" placeholder="SELECT * FROM your_table LIMIT 10"></textarea>
510
  <div class="query-controls">
511
+ <button class="btn btn-primary" id="run-query-btn" onclick="runQuery()">Run Query</button>
512
  </div>
513
  <div id="query-error"></div>
514
  <div class="result-panel" id="query-result">
 
516
  </div>
517
  </div>
518
 
519
+ <!-- Data Quality Chart -->
520
+ <div class="chart-container hidden" id="chart-container">
521
+ <div class="zone-label">Data Quality Overview</div>
522
+ <canvas id="quality-chart" height="140"></canvas>
523
+ </div>
524
+
525
  <div class="insights-panel" id="insights-panel">
526
+ <div class="zone-label">AI Insights <span class="ai-badge">✦ AI</span></div>
527
  <div class="insights-empty">Run a query to see insights</div>
528
  </div>
529
 
 
541
  Select a task from the dropdown above and click Initialize to start an evaluation episode.
542
  </div>
543
 
544
+ <div id="rl-schema-panel" class="mb-16">
545
  <div class="schema-empty">Task schema will appear after initialization</div>
546
  </div>
547
 
548
+ <div class="mt-20">
549
  <div class="section-label">Submit Your Answer</div>
550
  <div class="fix-context-label" id="fix-context">Select a task to see context</div>
551
+ <div class="extra-fields hidden" id="dedup-fields">
552
  <div class="extra-field">
553
  <label>Root Cause Table</label>
554
  <input type="text" id="root-cause-table" placeholder="table_name">
 
560
  </div>
561
  <textarea class="sql-textarea" id="fix-sql" placeholder="Enter your fix query or answer here..."></textarea>
562
  <div class="query-controls">
563
+ <button class="btn btn-danger" id="apply-fix-btn" onclick="applyFix()">Apply Fix</button>
564
+ <button class="btn btn-primary" id="submit-final-btn" onclick="submitFinal()">Submit Final</button>
565
  </div>
566
  <div id="fix-error"></div>
567
  </div>
568
 
569
+ <div class="results-panel hidden" id="results-panel">
570
  <div class="score-display" id="final-score">0%</div>
571
  <div id="dimension-bars"></div>
572
  <div class="feedback-text" id="feedback-text"></div>
 
574
  </div>
575
  </div>
576
 
577
+ <!-- Toast bar for status messages -->
578
+ <div class="toast-bar" id="toast-bar">
579
+ <span id="toast-text"></span>
580
+ </div>
581
+
582
  <script>
583
  const BASE = window.location.origin;
584
 
585
  let uploadedFile = null;
586
  let uploadedSchema = [];
587
+ let uploadedTableName = null;
588
  let episodeId = null;
589
  let currentTask = null;
590
  let lastQueryResult = null;
591
+ let lastQuerySQL = null;
592
  let mode = 'upload';
593
 
594
  const taskDescriptions = {
 
607
  schema_drift: 'Enter a SQL query that normalizes the schema'
608
  };
609
 
610
+ // ── Toast notification system ──
611
+ let toastTimer = null;
612
+ function showToast(message, type = 'info', duration = 4000) {
613
+ const bar = document.getElementById('toast-bar');
614
+ const text = document.getElementById('toast-text');
615
+ bar.className = 'toast-bar visible ' + type;
616
+ text.textContent = message;
617
+ if (toastTimer) clearTimeout(toastTimer);
618
+ toastTimer = setTimeout(() => { bar.classList.remove('visible'); }, duration);
619
+ }
620
+
621
+ // ── Loading state helpers ──
622
+ function setLoading(btnId, loading) {
623
+ const btn = document.getElementById(btnId);
624
+ if (!btn) return;
625
+ if (loading) {
626
+ btn._origText = btn.textContent;
627
+ btn.innerHTML = '<span class="spinner"></span>' + btn._origText + '...';
628
+ btn.classList.add('btn-loading');
629
+ } else {
630
+ btn.textContent = btn._origText || btn.textContent.replace('...', '');
631
+ btn.classList.remove('btn-loading');
632
+ }
633
+ }
634
+
635
+ // ── Health check ──
636
  async function checkHealth() {
637
  try {
638
  const r = await fetch(BASE + '/health');
639
  const indicator = document.getElementById('status-indicator');
640
  const text = document.getElementById('status-text');
 
641
  if (r.ok) {
 
642
  if (uploadedFile || episodeId) {
643
  indicator.className = 'status-indicator status-active';
644
  text.textContent = 'Active';
 
652
  }
653
  } catch (e) {
654
  document.getElementById('status-indicator').className = 'status-indicator status-error';
655
+ document.getElementById('status-text').textContent = 'Offline';
656
  }
657
  }
658
 
659
+ // ── File input handler ──
660
  document.getElementById('file-input').addEventListener('change', function() {
661
  const file = this.files[0];
662
  document.getElementById('file-name').textContent = file ? file.name : 'No file selected';
663
  });
664
 
665
+ // ── Task select handler ──
666
  document.getElementById('task-select').addEventListener('change', function() {
667
  currentTask = this.value;
668
  document.getElementById('init-btn').disabled = !currentTask;
669
+
670
+ const descEl = document.getElementById('task-description');
671
+ if (currentTask) {
672
+ descEl.textContent = taskDescriptions[currentTask];
673
+ descEl.classList.add('active');
674
+ } else {
675
+ descEl.textContent = 'Select a task from the dropdown above and click Initialize to start an evaluation episode.';
676
+ descEl.classList.remove('active');
677
+ }
678
+
679
+ document.getElementById('fix-context').textContent = currentTask
680
+ ? fixContexts[currentTask]
681
  : 'Select a task to see context';
682
+
683
+ const dedupFields = document.getElementById('dedup-fields');
684
+ if (currentTask === 'dedup_bug') {
685
+ dedupFields.classList.remove('hidden');
686
+ } else {
687
+ dedupFields.classList.add('hidden');
688
+ }
689
  });
690
 
691
+ // ── File upload ──
692
  async function uploadFile() {
693
  const fileInput = document.getElementById('file-input');
694
  const file = fileInput.files[0];
 
695
  if (!file) {
696
  showError('query-error', 'Please select a file first.');
697
  return;
698
  }
699
 
700
+ setLoading('upload-btn', true);
701
  const formData = new FormData();
702
  formData.append('file', file);
703
 
704
  try {
705
+ const r = await fetch(BASE + '/upload-db', { method: 'POST', body: formData });
 
 
 
 
706
  const data = await r.json();
 
707
  if (!r.ok) {
708
  showError('query-error', data.detail || 'Upload failed');
709
  return;
710
  }
 
711
  uploadedFile = file.name;
712
  uploadedSchema = data.tables || [];
713
+ uploadedTableName = uploadedSchema.length > 0 ? uploadedSchema[0].table_name : null;
714
  mode = 'upload';
715
+ renderSchema(data.tables, 'schema-panel', 'query-sql');
716
+ document.getElementById('header-task-section').classList.remove('hidden');
 
717
  document.getElementById('query-error').innerHTML = '';
718
+
719
+ // Auto-populate query with table name
720
+ if (uploadedTableName) {
721
+ document.getElementById('query-sql').value = 'SELECT * FROM ' + uploadedTableName + ' LIMIT 10';
722
+ }
723
+
724
+ showToast('βœ“ File uploaded: ' + file.name + ' (' + uploadedSchema.length + ' table(s))', 'success');
725
  checkHealth();
726
  } catch (e) {
727
  showError('query-error', 'Upload error: ' + e.message);
728
+ } finally {
729
+ setLoading('upload-btn', false);
730
  }
731
  }
732
 
733
+ // ── Schema renderer (reusable for Zone 1 and Zone 2) ──
734
+ function renderSchema(tables, panelId, targetTextareaId) {
735
+ const panel = document.getElementById(panelId);
736
  if (!tables || tables.length === 0) {
737
  panel.innerHTML = '<div class="schema-empty">No schema available</div>';
738
  return;
 
752
  for (const col of columns) {
753
  const colName = typeof col === 'string' ? col.split(' (')[0] : (col.name || col);
754
  const colType = typeof col === 'string' ? (col.match(/\(([^)]+)\)/)?.[1] || 'TEXT') : (col.type || 'TEXT');
755
+ html += '<span class="schema-column" onclick="insertColumnTo(\'' + escapeHtml(colName) + '\', \'' + targetTextareaId + '\')">' +
756
  escapeHtml(colName) + ' <small>(' + escapeHtml(colType) + ')</small></span>';
757
  }
758
 
 
761
  panel.innerHTML = html;
762
  }
763
 
764
+ // ── Insert column name into any textarea ──
765
+ function insertColumnTo(columnName, textareaId) {
766
+ const textarea = document.getElementById(textareaId);
767
+ if (!textarea) return;
768
  const cursorPos = textarea.selectionStart;
769
  const textBefore = textarea.value.substring(0, cursorPos);
770
  const textAfter = textarea.value.substring(cursorPos);
771
  textarea.value = textBefore + '"' + columnName + '"' + textAfter;
772
  textarea.focus();
773
+ showToast('Inserted column: ' + columnName, 'info', 1500);
774
+ }
775
+
776
+ // ── BUG FIX #1: Allow WITH/CTE queries ──
777
+ function isReadOnlySQL(sql) {
778
+ const trimmed = sql.trim().toUpperCase();
779
+ // Remove leading comments
780
+ const noComments = trimmed.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '').trim();
781
+ const firstWord = noComments.split(/\s+/)[0] || '';
782
+ return ['SELECT', 'WITH', 'EXPLAIN', 'PRAGMA'].includes(firstWord);
783
  }
784
 
785
+ // ── Run Query ──
786
  async function runQuery() {
787
  const sql = document.getElementById('query-sql').value.trim();
788
+ if (!uploadedFile) { showError('query-error', 'Upload a file first.'); return; }
789
+ if (!sql) { showError('query-error', 'Enter a SQL query.'); return; }
 
 
 
 
 
 
 
 
790
 
791
+ // BUG FIX #1: Use the proper check that allows WITH/CTE queries
792
+ if (!isReadOnlySQL(sql)) {
793
+ showError('query-error', 'Only SELECT queries allowed here. Supported prefixes: SELECT, WITH, EXPLAIN, PRAGMA.');
794
  return;
795
  }
796
 
797
+ setLoading('run-query-btn', true);
798
  try {
799
  const r = await fetch(BASE + '/query-custom', {
800
  method: 'POST',
801
  headers: { 'Content-Type': 'application/json' },
802
  body: JSON.stringify({ sql })
803
  });
 
804
  const data = await r.json();
805
 
806
+ if (data.error) {
807
+ showError('query-error', data.error + (data.message ? ' β€” ' + data.message : ''));
808
+ // Still show suggestions if available
809
+ if (data.suggestions) {
810
+ renderSuggestions(data.suggestions);
811
+ }
812
+ return;
813
+ }
814
+
815
  if (!r.ok) {
816
  showError('query-error', data.detail || 'Query failed');
817
  return;
 
819
 
820
  document.getElementById('query-error').innerHTML = '';
821
  lastQueryResult = data.rows || [];
822
+ lastQuerySQL = sql;
823
  renderQueryResults(lastQueryResult);
824
+
825
+ // Draw chart
826
+ drawQualityChart(lastQueryResult);
827
+
828
+ // Get AI insights (async - don't block)
829
+ fetchAIInsights(sql, lastQueryResult);
830
+
831
+ // Generate suggestions
832
  generateSuggestions(lastQueryResult);
833
+
834
+ showToast('Query returned ' + lastQueryResult.length + ' row(s)', 'success', 2000);
835
  } catch (e) {
836
  showError('query-error', 'Query error: ' + e.message);
837
+ } finally {
838
+ setLoading('run-query-btn', false);
839
  }
840
  }
841
 
842
+ // ── Render query results table ──
843
  function renderQueryResults(rows) {
844
  const panel = document.getElementById('query-result');
 
845
  if (!rows || rows.length === 0) {
846
  panel.innerHTML = '<div class="result-empty">No results returned</div>';
847
  return;
848
  }
 
849
  const columns = Object.keys(rows[0]);
850
  let html = '<table class="result-table"><thead><tr>';
851
+ for (const col of columns) { html += '<th>' + escapeHtml(col) + '</th>'; }
 
 
852
  html += '</tr></thead><tbody>';
853
+ for (const row of rows.slice(0, 100)) {
 
854
  html += '<tr>';
855
  for (const col of columns) {
856
  const val = row[col];
857
+ const display = val === null ? '<em style="color:#9CA3AF">NULL</em>' : escapeHtml(String(val));
858
+ html += '<td>' + display + '</td>';
859
  }
860
  html += '</tr>';
861
  }
 
862
  html += '</tbody></table>';
863
+ if (rows.length > 100) html += '<div style="padding:8px;font-size:12px;color:#6B7280;">Showing 100 of ' + rows.length + ' rows</div>';
864
  panel.innerHTML = html;
865
  }
866
 
867
+ // ── BUG FIX #2: AI Insights via Groq API endpoint ──
868
+ async function fetchAIInsights(sql, rows) {
869
  const panel = document.getElementById('insights-panel');
870
+ panel.innerHTML = '<div class="zone-label">AI Insights <span class="ai-badge">✦ AI</span></div><div class="insights-empty"><span class="spinner" style="border-color:rgba(0,0,0,0.1);border-top-color:#2D6A4F;width:12px;height:12px;"></span> Analyzing data...</div>';
871
+
872
+ try {
873
+ const r = await fetch(BASE + '/ai-insights', {
874
+ method: 'POST',
875
+ headers: { 'Content-Type': 'application/json' },
876
+ body: JSON.stringify({
877
+ sql: sql,
878
+ rows: rows.slice(0, 20),
879
+ row_count: rows.length,
880
+ columns: rows.length > 0 ? Object.keys(rows[0]) : []
881
+ })
882
+ });
883
+
884
+ if (r.ok) {
885
+ const data = await r.json();
886
+ const insights = data.insights || [];
887
+ if (insights.length > 0) {
888
+ let html = '<div class="zone-label">AI Insights <span class="ai-badge">✦ AI</span></div>';
889
+ for (const insight of insights) {
890
+ html += '<div class="insight-item">' + escapeHtml(insight) + '</div>';
891
+ }
892
+ panel.innerHTML = html;
893
+ return;
894
+ }
895
+ }
896
+ } catch (e) {
897
+ console.log('AI insights endpoint not available, using local analysis');
898
+ }
899
 
900
+ // Fallback: enhanced local insights
901
+ computeLocalInsights(rows);
902
+ }
903
+
904
+ // ── Enhanced local insights (fallback) ──
905
+ function computeLocalInsights(rows) {
906
+ const panel = document.getElementById('insights-panel');
907
  if (!rows || rows.length === 0) {
908
+ panel.innerHTML = '<div class="zone-label">AI Insights <span class="ai-badge">✦ AI</span></div><div class="insights-empty">Query returned no data. Try a broader query or check table name.</div>';
909
  return;
910
  }
911
 
912
  const columns = Object.keys(rows[0]);
913
  const insights = [];
914
 
915
+ insights.push('πŸ“Š Query returned ' + rows.length + ' rows across ' + columns.length + ' columns');
916
 
917
+ // Null analysis
918
+ const nullCols = [];
919
  for (const col of columns) {
920
+ const values = rows.map(r => r[col]);
921
+ const nullCount = values.filter(v => v === null || v === undefined || v === '').length;
 
922
  if (nullCount > 0) {
923
+ const pct = Math.round(nullCount / rows.length * 100);
924
+ nullCols.push({ col, pct, count: nullCount });
925
  }
926
+ }
927
+
928
+ if (nullCols.length > 0) {
929
+ nullCols.sort((a, b) => b.pct - a.pct);
930
+ const worst = nullCols[0];
931
+ insights.push('⚠️ Highest null rate: "' + worst.col + '" at ' + worst.pct + '% (' + worst.count + ' nulls)');
932
+ if (nullCols.length > 1) {
933
+ insights.push('πŸ“‹ ' + nullCols.length + ' columns have null values total');
934
+ }
935
+ } else {
936
+ insights.push('βœ… No NULL values detected in returned data');
937
+ }
938
+
939
+ // Numeric analysis
940
+ for (const col of columns) {
941
+ const numeric = rows.map(r => r[col]).filter(v => v !== null && !isNaN(parseFloat(v)) && isFinite(v));
942
+ if (numeric.length > rows.length * 0.5) {
943
  const nums = numeric.map(v => parseFloat(v));
944
+ const min = Math.min(...nums);
945
+ const max = Math.max(...nums);
946
+ const avg = (nums.reduce((a, b) => a + b, 0) / nums.length).toFixed(2);
947
+ insights.push('πŸ“ˆ "' + col + '": range [' + min + ' β€” ' + max + '], avg=' + avg);
948
+ }
949
+ }
950
+
951
+ // Duplicate detection
952
+ for (const col of columns) {
953
+ const values = rows.map(r => r[col]).filter(v => v !== null);
954
+ const unique = new Set(values.map(String));
955
+ if (unique.size < values.length * 0.8 && values.length > 5) {
956
+ const dupCount = values.length - unique.size;
957
+ insights.push('πŸ”„ "' + col + '": ' + dupCount + ' duplicate values detected (' + unique.size + ' unique / ' + values.length + ' total)');
958
  }
959
  }
960
 
961
+ let html = '<div class="zone-label">AI Insights <span class="ai-badge">✦ LOCAL</span></div>';
962
+ for (const insight of insights.slice(0, 8)) {
963
  html += '<div class="insight-item">' + escapeHtml(insight) + '</div>';
964
  }
965
  panel.innerHTML = html;
966
  }
967
 
968
+ // ── Smart suggestions ──
969
  function generateSuggestions(rows) {
970
  const panel = document.getElementById('suggestions-panel');
 
971
  if (!rows || rows.length === 0) {
972
+ panel.innerHTML = '<div class="zone-label">Smart Task Suggestions</div><div class="insights-empty">Query returned 0 rows β€” try broadening your query</div>';
973
  return;
974
  }
975
 
 
979
  for (const col of columns) {
980
  const values = rows.map(r => r[col]);
981
  const nullCount = values.filter(v => v === null || v === undefined).length;
 
982
  if (nullCount > 0) {
983
  const pct = Math.round(nullCount / rows.length * 100);
984
  if (pct > 10) {
985
+ suggestions.push({
986
+ text: 'πŸ” Consider running null_audit task β€” column "' + col + '" has ' + pct + '% nulls',
987
+ task: 'null_audit'
988
+ });
989
  }
990
  }
 
 
 
 
 
 
 
 
 
 
 
991
  }
992
 
993
+ // Check for duplicates
994
  const allValues = rows.flatMap(r => Object.values(r)).filter(v => v !== null);
995
  const duplicates = allValues.filter((v, i, a) => a.indexOf(v) !== i);
996
+ if (duplicates.length > rows.length * 0.1) {
997
+ suggestions.push({
998
+ text: 'πŸ”„ Consider running dedup_bug task β€” significant duplicate values detected',
999
+ task: 'dedup_bug'
1000
+ });
1001
+ }
1002
+
1003
+ // Check for type mismatches
1004
+ for (const col of columns) {
1005
+ const nonNull = rows.map(r => r[col]).filter(v => v !== null && v !== undefined);
1006
+ const allStrings = nonNull.every(v => typeof v === 'string');
1007
+ const allNumericLooking = nonNull.length > 0 && nonNull.every(v => /^-?\$?\d+\.?\d*%?$/.test(String(v).trim().replace(/[$,%]/g, '')));
1008
+ if (allStrings && allNumericLooking && nonNull.length > 3) {
1009
+ suggestions.push({
1010
+ text: 'πŸ”§ Consider running type_repair task β€” column "' + col + '" has numeric values stored as text',
1011
+ task: 'type_repair'
1012
+ });
1013
+ }
1014
  }
1015
 
1016
  if (suggestions.length === 0) {
1017
+ suggestions.push({ text: 'βœ… Data looks healthy. Try running an RL task to verify quality.', task: null });
1018
  }
1019
 
1020
  let html = '<div class="zone-label">Smart Task Suggestions</div>';
1021
+ for (const suggestion of suggestions.slice(0, 4)) {
1022
+ const onclick = suggestion.task
1023
+ ? ' onclick="document.getElementById(\'task-select\').value=\'' + suggestion.task + '\';document.getElementById(\'task-select\').dispatchEvent(new Event(\'change\'));"'
1024
+ : '';
1025
+ html += '<div class="suggestion-item"' + onclick + '>' + escapeHtml(suggestion.text) + '</div>';
1026
  }
1027
  panel.innerHTML = html;
1028
  }
1029
 
1030
+ function renderSuggestions(suggestionTexts) {
1031
+ const panel = document.getElementById('suggestions-panel');
1032
+ let html = '<div class="zone-label">Smart Task Suggestions</div>';
1033
+ for (const text of suggestionTexts) {
1034
+ html += '<div class="suggestion-item">' + escapeHtml(text) + '</div>';
1035
  }
1036
+ panel.innerHTML = html;
1037
+ }
1038
+
1039
+ // ── BUG FIX #7: Initialize Episode β€” use /initialize endpoint with correct response format ──
1040
+ async function initializeEpisode() {
1041
+ if (!currentTask) { alert('Select a task first'); return; }
1042
+
1043
+ setLoading('init-btn', true);
1044
+ showToast('⏳ Initializing ' + currentTask + ' episode...', 'info', 10000);
1045
 
1046
  try {
1047
+ // Use /initialize endpoint which returns { schema: [...] } format
1048
+ const r = await fetch(BASE + '/initialize', {
1049
  method: 'POST',
1050
  headers: { 'Content-Type': 'application/json' },
1051
  body: JSON.stringify({ task_id: currentTask })
1052
  });
 
1053
  const data = await r.json();
1054
 
1055
  if (!r.ok) {
1056
+ showToast('❌ Initialization failed: ' + (data.detail || 'Unknown error'), 'error');
1057
  return;
1058
  }
1059
 
1060
  episodeId = data.episode_id;
1061
  mode = 'task';
1062
+
1063
+ // BUG FIX #7: The /initialize endpoint returns { schema: [...] } not { schema_info: [...] }
1064
+ const schemaData = data.schema || [];
1065
+ if (schemaData.length > 0) {
1066
+ // Render schema with clickable columns pointing to fix-sql textarea (BUG FIX #5)
1067
+ renderSchema(schemaData, 'rl-schema-panel', 'fix-sql');
1068
+ } else {
1069
+ document.getElementById('rl-schema-panel').innerHTML = '<div class="schema-empty">No schema data available for this task</div>';
 
 
 
 
 
 
 
 
1070
  }
1071
 
1072
+ // Update task description with actual description from server
1073
+ if (data.task_description) {
1074
+ document.getElementById('task-description').textContent = data.task_description;
1075
+ }
1076
+
1077
+ // Show investigation hint
1078
+ if (data.investigation_hint) {
1079
+ showToast('πŸ’‘ Hint: ' + data.investigation_hint, 'info', 5000);
1080
+ }
1081
+
1082
+ showToast('βœ“ Episode initialized: ' + currentTask + ' (ID: ' + episodeId.slice(0, 8) + '...)', 'success');
1083
  checkHealth();
1084
  } catch (e) {
1085
+ showToast('❌ Initialization error: ' + e.message, 'error');
1086
+ } finally {
1087
+ setLoading('init-btn', false);
1088
  }
1089
  }
1090
 
1091
+ // ── BUG FIX #3: Apply Fix β€” send fix_query field + show feedback ──
1092
  async function applyFix() {
1093
+ if (!episodeId) { showError('fix-error', 'Initialize an episode first.'); return; }
 
 
 
1094
 
1095
  const sql = document.getElementById('fix-sql').value.trim();
1096
+ if (!sql) { showError('fix-error', 'Enter a fix query or answer.'); return; }
 
 
 
1097
 
1098
+ setLoading('apply-fix-btn', true);
1099
+
1100
+ // BUG FIX #3: Use fix_query field (not sql) for fix actions
1101
  const body = {
1102
  task_id: currentTask,
1103
  action_type: 'fix',
1104
+ fix_query: sql // FIXED: was 'sql', should be 'fix_query'
1105
  };
1106
 
1107
  if (currentTask === 'dedup_bug') {
 
1115
  headers: { 'Content-Type': 'application/json' },
1116
  body: JSON.stringify(body)
1117
  });
 
1118
  const data = await r.json();
1119
 
1120
  if (!r.ok) {
 
1122
  return;
1123
  }
1124
 
1125
+ // BUG FIX #3: Show success feedback
1126
+ const feedback = data.reward?.feedback || 'Fix applied successfully';
1127
+ const score = data.reward?.step_reward ?? 0;
1128
+ document.getElementById('fix-error').innerHTML =
1129
+ '<div class="success-box">βœ“ ' + escapeHtml(feedback) +
1130
+ ' (step reward: ' + (score * 100).toFixed(1) + '%)</div>';
1131
+
1132
+ showToast('βœ“ Fix applied β€” ' + feedback, 'success');
1133
  } catch (e) {
1134
  showError('fix-error', 'Fix error: ' + e.message);
1135
+ } finally {
1136
+ setLoading('apply-fix-btn', false);
1137
  }
1138
  }
1139
 
1140
+ // ── BUG FIX #4: Submit Final β€” send answer for null_audit, fix_query for others ──
1141
  async function submitFinal() {
1142
+ if (!episodeId) { showError('fix-error', 'Initialize an episode first.'); return; }
 
 
 
1143
 
1144
+ setLoading('submit-final-btn', true);
1145
  const sql = document.getElementById('fix-sql').value.trim();
1146
 
1147
  const body = {
 
1149
  action_type: 'submit'
1150
  };
1151
 
1152
+ // BUG FIX #4: Route answer vs fix_query based on task type
1153
+ if (currentTask === 'null_audit') {
1154
+ // null_audit expects 'answer' field with the column name
1155
+ body.answer = sql;
1156
+ } else if (sql) {
1157
+ body.fix_query = sql;
1158
  }
1159
 
1160
  if (currentTask === 'dedup_bug') {
 
1168
  headers: { 'Content-Type': 'application/json' },
1169
  body: JSON.stringify(body)
1170
  });
 
1171
  const data = await r.json();
1172
 
1173
  if (!r.ok) {
 
1177
 
1178
  document.getElementById('fix-error').innerHTML = '';
1179
  renderResults(data.reward);
1180
+ showToast('βœ“ Final score: ' + Math.round((data.reward?.score ?? 0) * 100) + '%', 'success', 6000);
1181
  } catch (e) {
1182
  showError('fix-error', 'Submit error: ' + e.message);
1183
+ } finally {
1184
+ setLoading('submit-final-btn', false);
1185
  }
1186
  }
1187
 
1188
+ // ── Render scoring results ──
1189
  function renderResults(reward) {
1190
  const panel = document.getElementById('results-panel');
1191
+ panel.classList.remove('hidden');
1192
 
1193
  const score = reward?.score ?? 0;
1194
  document.getElementById('final-score').textContent = Math.round(score * 100) + '%';
 
1197
  let barsHtml = '';
1198
 
1199
  const dimNames = {
1200
+ d1_diagnostic_efficiency: 'D1 β€” Diagnostic Efficiency',
1201
+ d2_hypothesis_quality: 'D2 β€” Hypothesis Quality',
1202
+ d3_fix_correctness: 'D3 β€” Fix Correctness',
1203
+ d4_self_verification: 'D4 β€” Self Verification',
1204
+ d5_generalization: 'D5 β€” Generalization'
1205
  };
1206
 
1207
  for (const [key, label] of Object.entries(dimNames)) {
1208
  const val = dims[key] ?? 0;
1209
+ const pct = Math.round(val * 100);
1210
+ const color = pct >= 70 ? '#16A34A' : pct >= 40 ? '#F59E0B' : '#DC2626';
1211
  barsHtml += '<div class="dimension-bar">';
1212
+ barsHtml += '<div class="dimension-label"><span>' + label + '</span><span style="color:' + color + '">' + pct + '%</span></div>';
1213
+ barsHtml += '<div class="dimension-track"><div class="dimension-fill" style="width: ' + pct + '%; background: ' + color + '"></div></div>';
1214
  barsHtml += '</div>';
1215
  }
1216
 
1217
  document.getElementById('dimension-bars').innerHTML = barsHtml;
1218
+
1219
+ const feedback = reward?.feedback || '';
1220
+ const severity = dims?.d3_fix_severity || '';
1221
+ let feedbackHtml = escapeHtml(feedback);
1222
+ if (severity) {
1223
+ const severityColor = {PERFECT:'#16A34A',EXCELLENT:'#22C55E',ACCEPTABLE:'#84CC16',GOOD:'#65A30D',FAIR:'#F59E0B',PARTIAL:'#F97316',DEGRADED:'#FB923C',DANGEROUS:'#EF4444',CRITICAL:'#DC2626',CATASTROPHIC:'#991B1B'}[severity] || '#6B7280';
1224
+ feedbackHtml += ' <span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;color:white;background:' + severityColor + '">' + severity + '</span>';
1225
+ }
1226
+ document.getElementById('feedback-text').innerHTML = feedbackHtml;
1227
+ }
1228
+
1229
+ // ── Data Quality Chart (simple canvas bar chart) ──
1230
+ function drawQualityChart(rows) {
1231
+ const container = document.getElementById('chart-container');
1232
+ if (!rows || rows.length === 0) {
1233
+ container.classList.add('hidden');
1234
+ return;
1235
+ }
1236
+
1237
+ container.classList.remove('hidden');
1238
+ const canvas = document.getElementById('quality-chart');
1239
+ const ctx = canvas.getContext('2d');
1240
+
1241
+ // Calculate dimensions
1242
+ canvas.width = canvas.parentElement.clientWidth - 32;
1243
+ canvas.height = 140;
1244
+
1245
+ const columns = Object.keys(rows[0]);
1246
+ const barWidth = Math.min(60, (canvas.width - 40) / columns.length - 8);
1247
+ const maxHeight = 100;
1248
+
1249
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1250
+
1251
+ // Draw bars showing non-null percentage per column
1252
+ columns.forEach((col, i) => {
1253
+ const values = rows.map(r => r[col]);
1254
+ const nonNull = values.filter(v => v !== null && v !== undefined && v !== '').length;
1255
+ const pct = nonNull / values.length;
1256
+ const barHeight = pct * maxHeight;
1257
+ const x = 20 + i * (barWidth + 8);
1258
+ const y = maxHeight - barHeight + 10;
1259
+
1260
+ // Bar gradient
1261
+ const grad = ctx.createLinearGradient(x, y, x, y + barHeight);
1262
+ if (pct >= 0.9) { grad.addColorStop(0, '#22C55E'); grad.addColorStop(1, '#16A34A'); }
1263
+ else if (pct >= 0.7) { grad.addColorStop(0, '#F59E0B'); grad.addColorStop(1, '#D97706'); }
1264
+ else { grad.addColorStop(0, '#EF4444'); grad.addColorStop(1, '#DC2626'); }
1265
+
1266
+ ctx.fillStyle = grad;
1267
+ ctx.beginPath();
1268
+ ctx.roundRect(x, y, barWidth, barHeight, 3);
1269
+ ctx.fill();
1270
+
1271
+ // Label
1272
+ ctx.fillStyle = '#6B7280';
1273
+ ctx.font = '9px DM Mono';
1274
+ ctx.textAlign = 'center';
1275
+ const label = col.length > 8 ? col.slice(0, 7) + '…' : col;
1276
+ ctx.fillText(label, x + barWidth / 2, maxHeight + 24);
1277
+
1278
+ // Percentage
1279
+ ctx.fillStyle = '#374151';
1280
+ ctx.font = 'bold 10px DM Sans';
1281
+ ctx.fillText(Math.round(pct * 100) + '%', x + barWidth / 2, y - 4);
1282
+ });
1283
  }
1284
 
1285
+ // ── Helpers ──
1286
  function showError(elementId, message) {
1287
  document.getElementById(elementId).innerHTML = '<div class="error-box">' + escapeHtml(message) + '</div>';
1288
  }
 
1296
  .replace(/"/g, '&quot;');
1297
  }
1298
 
1299
+ // ── Init ──
1300
  checkHealth();
1301
  setInterval(checkHealth, 30000);
1302
  </script>