Spaces:
Sleeping
Sleeping
Download server/environment.py from Deadman922/sql-quality-env: direct link, hf CLI and curl.
- Browser
- Download file 77.4 kB
-
https://huggingface.co/spaces/Deadman922/sql-quality-env/resolve/main/server/environment.py
- Command line
-
hf download hf://spaces/Deadman922/sql-quality-env/server/environment.py
-
curl -L -o environment.py https://huggingface.co/spaces/Deadman922/sql-quality-env/resolve/main/server/environment.py
77.4 kB
"""SQL Quality Environment - Core environment manager."""
from __future__ import annotations
from datetime import datetime
from difflib import get_close_matches
from itertools import combinations
import random as _random
import re
import sqlite3
import threading
import uuid
from typing import Any, Dict, List, Tuple
from fastapi import HTTPException
from .models import (
ActionType,
SQLAction,
SQLObservation,
SQLReward,
SQLState,
StepResponse,
TrajectoryStep,
)
from .tasks import (
create_dedup_bug_task,
create_fk_integrity_task,
create_null_audit_task,
create_schema_drift_task,
create_type_repair_task,
)
TASK_CREATORS = {
"null_audit": create_null_audit_task,
"type_repair": create_type_repair_task,
"dedup_bug": create_dedup_bug_task,
"fk_integrity": create_fk_integrity_task,
"schema_drift": create_schema_drift_task,
}
NULLISH_TEXT_VALUES = {
"",
"n/a",
"na",
"nan",
"null",
"none",
"missing",
"unknown",
"-1",
}
ALLOWED_READ_KEYWORDS = {"SELECT", "WITH", "EXPLAIN", "PRAGMA", "SHOW"}
IDENTITY_NAME_TOKENS = {
"id",
"title",
"name",
"email",
"company",
"location",
"address",
"original",
"imdb",
}
BUSINESS_NUMERIC_TOKENS = {
"score",
"rating",
"votes",
"income",
"revenue",
"salary",
"amount",
"price",
"cost",
"duration",
"founded",
}
LOW_SIGNAL_DEDUP_TOKENS = {
"genre",
"industry",
"sector",
"country",
"release",
"year",
"type",
"category",
}
def strip_sql_comments(sql: str) -> str:
"""Remove SQL comments while preserving string literals."""
if not sql:
return ""
result = []
i = 0
n = len(sql)
while i < n:
char = sql[i]
# Handle string literals
if char in ("'", '"'):
quote_char = char
result.append(char)
i += 1
while i < n:
char = sql[i]
result.append(char)
if char == quote_char:
# Check for doubled quote escape
if i + 1 < n and sql[i + 1] == quote_char:
result.append(sql[i + 1]) # Add the escaped quote
i += 2
else:
i += 1
break
elif char == "\\" and i + 1 < n:
# Handle backslash escapes
result.append(sql[i + 1])
i += 2
else:
i += 1
elif char == "-" and i + 1 < n and sql[i + 1] == "-":
# Skip -- comments until end of line
i += 2
while i < n and sql[i] != "\n":
i += 1
elif char == "/" and i + 1 < n and sql[i + 1] == "*":
# Skip /* */ comments
i += 2
while i + 1 < n and not (sql[i] == "*" and sql[i + 1] == "/"):
i += 1
i += 2 # Skip the closing */
else:
result.append(char)
i += 1
return "".join(result).strip()
def split_sql_statements(sql: str) -> list[str]:
"""Split SQL into top-level statements without breaking string literals."""
if not sql:
return []
statements: list[str] = []
current = []
in_string = False
string_char = ""
i = 0
n = len(sql)
while i < n:
char = sql[i]
prev = sql[i - 1] if i > 0 else ""
next_char = sql[i + 1] if i < n - 1 else ""
# Always append quote characters to current buffer
if char in ("'", '"'):
current.append(char)
# Check for doubled-quote escape (SQL standard)
if next_char == char:
current.append(next_char) # Add the escaped quote
i += 1 # Skip the second quote
elif not in_string:
in_string = True
string_char = char
elif string_char == char:
in_string = False
string_char = ""
elif char == "\\" and in_string and next_char:
# Handle backslash escapes in strings
current.append(char)
current.append(next_char)
i += 1
elif char == ";" and not in_string:
# Statement delimiter outside of strings
statement = "".join(current).strip()
if statement:
statements.append(statement)
current = []
else:
current.append(char)
i += 1
# Add the final statement if non-empty
tail = "".join(current).strip()
if tail:
statements.append(tail)
return statements
def first_sql_keyword(sql: str) -> str:
"""Return the first SQL keyword after stripping comments and whitespace."""
cleaned = strip_sql_comments(sql)
if not cleaned:
return ""
return cleaned.split()[0].upper()
def is_readonly_statement(sql: str) -> bool:
"""Return True when a statement is allowed in Zone 1 read-only execution."""
# Remove comments and split into individual statements
statements = split_sql_statements(sql)
# Empty or whitespace-only SQL should return False
if not statements:
return False
# Check every statement's first keyword
for statement in statements:
statement = statement.strip()
if statement: # Skip empty statements
first_keyword = first_sql_keyword(statement)
if not first_keyword or first_keyword not in ALLOWED_READ_KEYWORDS:
return False
return True
def quote_identifier(name: str) -> str:
"""Quote SQLite identifiers safely."""
return '"' + str(name).replace('"', '""') + '"'
def collect_schema_identifiers(schema: Any) -> tuple[set[str], set[str], dict[str, str]]:
"""Collect table/column identifiers plus normalized lookup keys from schema-like objects."""
tables: set[str] = set()
columns: set[str] = set()
normalized_lookup: dict[str, str] = {}
for table_info in schema or []:
if isinstance(table_info, dict):
table_name = table_info.get("table_name", "")
if table_name:
tables.add(table_name)
normalized_lookup.setdefault(normalize_schema_label(table_name), table_name)
# Handle both string and dict column formats
for col in table_info.get("columns", []):
if isinstance(col, str):
column_name = col.split(" (")[0]
else:
column_name = col.get("name", str(col))
if column_name:
columns.add(column_name)
normalized_lookup.setdefault(normalize_schema_label(column_name), column_name)
return tables, columns, normalized_lookup
def rewrite_known_quoted_identifiers(sql: str, schema: Any) -> Tuple[str, List[Tuple[str, str]], List[Tuple[str, str]]]:
"""Map quoted original CSV headers to sanitized schema identifiers when the match is exact after normalization."""
sql_clean = strip_sql_comments(sql)
tables, columns, normalized_lookup = collect_schema_identifiers(schema)
alias_tokens = set(re.findall(r'(?i)\bAS\s+"([^"]+)"', sql_clean))
rewrites: list[tuple[str, str]] = []
unresolved: list[tuple[str, str]] = []
rewritten_sql = sql
for token in re.findall(r'"([^"]+)"', sql_clean):
if token in alias_tokens or token in tables or token in columns:
continue
normalized = normalize_schema_label(token)
if not normalized:
continue
replacement = normalized_lookup.get(normalized)
if replacement and replacement != token:
rewrites.append((token, replacement))
else:
close = get_close_matches(normalized, list(normalized_lookup.keys()), n=1, cutoff=0.75)
if close:
unresolved.append((token, normalized_lookup[close[0]]))
for source, target in rewrites:
# Use regex to match double-quoted identifiers only, not inside string literals
pattern = fr'"({re.escape(source)})"'
replacement = f'"{target}"'
rewritten_sql = re.sub(pattern, replacement, rewritten_sql)
# Deduplicate while preserving order.
dedup_rewrites = list(dict.fromkeys(rewrites))
dedup_unresolved = list(dict.fromkeys(unresolved))
return rewritten_sql, dedup_rewrites, dedup_unresolved
def normalize_text(value: Any) -> str:
"""Normalize values for placeholder, boolean, and date heuristics."""
if value is None:
return ""
return str(value).strip()
def normalize_schema_label(value: str) -> str:
"""Collapse punctuation and spacing for schema-aware comparisons."""
import re
return re.sub(r"[^a-z0-9]+", "", (value or "").lower())
def identifier_tokens(name: str) -> list[str]:
"""Split a column name into normalized semantic tokens."""
import re
return [token for token in re.split(r"[^a-z0-9]+", (name or "").lower()) if token]
def is_identifier_like_name(name: str) -> bool:
"""Return True for classic identifier columns such as *_id."""
tokens = identifier_tokens(name)
return bool(name and ((name or "").lower() == "id" or (tokens and tokens[-1] == "id")))
def is_identity_like_name(name: str) -> bool:
"""Return True for columns likely to participate in business keys."""
tokens = set(identifier_tokens(name))
return bool(tokens & IDENTITY_NAME_TOKENS)
def is_low_signal_dedup_name(name: str) -> bool:
"""Return True for categorical/date-ish names that rarely form a good dedup key alone."""
tokens = set(identifier_tokens(name))
return bool(tokens & LOW_SIGNAL_DEDUP_TOKENS)
def is_nullish_value(value: Any) -> bool:
"""Treat NULLs, empty strings, and common placeholders as missing."""
if value is None:
return True
text = normalize_text(value).lower()
return text in NULLISH_TEXT_VALUES
def clean_numeric_text(value: Any) -> str:
"""Remove common numeric noise such as currency and commas."""
text = normalize_text(value)
return re.sub(r"\s+", "", text.replace(",", "").replace("$", "").replace("%", ""))
def parse_numeric_scalar(value: Any) -> float | None:
"""Parse scalar numeric values while rejecting IDs and textual ranges."""
text = clean_numeric_text(value)
if not text:
return None
if re.fullmatch(r"[+-]?\d+(?:\.\d+)?", text):
try:
return float(text)
except ValueError:
return None
suffix_match = re.fullmatch(r"([+-]?\d+(?:\.\d+)?)([KMB])", text, flags=re.IGNORECASE)
if suffix_match:
magnitude = {"K": 1_000, "M": 1_000_000, "B": 1_000_000_000}[suffix_match.group(2).upper()]
try:
return float(suffix_match.group(1)) * magnitude
except ValueError:
return None
return None
def identifier_like_ratio(values: list[Any]) -> float:
"""Estimate how many values look like alphanumeric identifiers rather than measures."""
clean_values = [normalize_text(v) for v in values if not is_nullish_value(v)]
if not clean_values:
return 0.0
matches = 0
for value in clean_values:
compact = re.sub(r"[\s_-]+", "", value)
if re.fullmatch(r"[A-Za-z]{1,8}\d{3,}", compact):
matches += 1
return matches / max(1, len(clean_values))
def numeric_parse_ratio(values: list[Any]) -> float:
"""Estimate how many non-nullish values can be parsed as numbers."""
clean_values = [v for v in values if not is_nullish_value(v)]
if not clean_values:
return 0.0
parsed = 0
for value in clean_values:
if parse_numeric_scalar(value) is not None:
parsed += 1
return parsed / max(1, len(clean_values))
def boolean_parse_ratio(values: list[Any]) -> float:
"""Estimate how many non-nullish values look like booleans."""
clean_values = [normalize_text(v).lower() for v in values if not is_nullish_value(v)]
if not clean_values:
return 0.0
boolean_tokens = {"true", "false", "yes", "no", "y", "n", "0", "1"}
parsed = sum(1 for value in clean_values if value in boolean_tokens)
return parsed / max(1, len(clean_values))
def date_parse_ratio(values: list[Any]) -> float:
"""Estimate how many non-nullish values look like dates or timestamps."""
clean_values = [normalize_text(v) for v in values if not is_nullish_value(v)]
if not clean_values:
return 0.0
parsed = 0
patterns = [
("%Y-%m-%d",),
("%Y-%m-%d %H:%M:%S",),
("%Y-%m-%dT%H:%M:%S",),
("%m/%d/%Y",),
("%d/%m/%Y",),
]
for value in clean_values:
candidate = value.replace("Z", "")
matched = False
for (fmt,) in patterns:
try:
datetime.strptime(candidate, fmt)
parsed += 1
matched = True
break
except ValueError:
continue
if matched:
continue
try:
datetime.fromisoformat(candidate)
parsed += 1
except ValueError:
pass
return parsed / max(1, len(clean_values))
def normalize_base_table_name(name: str) -> str:
"""Normalize versioned table names to support schema-drift detection."""
text = name.lower()
text = re.sub(r"(?:_v\d+|_version\d+|_ver\d+)$", "", text)
text = re.sub(r"(?:\d{4}|\d+)$", "", text)
return text.rstrip("_")
def fetch_sample_values(conn: sqlite3.Connection, table_name: str, column_name: str, limit: int = 200) -> list[Any]:
"""Fetch a bounded sample for heuristic analysis."""
query = (
f"SELECT {quote_identifier(column_name)} "
f"FROM {quote_identifier(table_name)} "
f"LIMIT {limit}"
)
return [row[0] for row in conn.execute(query).fetchall()]
def nullish_count_sql(table_name: str, column_name: str) -> str:
"""Build a deterministic SQL predicate for nullish values."""
col = quote_identifier(column_name)
return (
f"SELECT COUNT(*) FROM {quote_identifier(table_name)} WHERE "
f"{col} IS NULL OR TRIM(CAST({col} AS TEXT)) = '' OR "
f"LOWER(TRIM(CAST({col} AS TEXT))) IN ('n/a','na','nan','null','none','missing','unknown','-1')"
)
def analyze_uploaded_database(conn: sqlite3.Connection, schema_info: dict[str, dict]) -> dict[str, Any]:
"""Profile uploaded data and derive supported task contexts deterministically."""
table_profiles: dict[str, dict[str, Any]] = {}
task_candidates: dict[str, dict[str, Any]] = {}
tables = list(schema_info.keys())
for table_name in tables:
table_schema = schema_info.get(table_name, {})
raw_columns = table_schema.get("columns", [])
columns = [
col.split(" (")[0] if isinstance(col, str) else col.get("name", "")
for col in raw_columns
]
row_count = int(table_schema.get("row_count", 0) or 0)
column_profiles: dict[str, dict[str, Any]] = {}
duplicate_candidates: list[dict[str, Any]] = []
for column_name in columns:
try:
nullish_count = conn.execute(nullish_count_sql(table_name, column_name)).fetchone()[0]
except sqlite3.Error:
nullish_count = 0
try:
header_like_count = conn.execute(
f"SELECT COUNT(*) FROM {quote_identifier(table_name)} "
f"WHERE LOWER(TRIM(CAST({quote_identifier(column_name)} AS TEXT))) = ?",
(column_name.lower(),),
).fetchone()[0]
except sqlite3.Error:
header_like_count = 0
try:
distinct_count = conn.execute(
f"SELECT COUNT(DISTINCT {quote_identifier(column_name)}) "
f"FROM {quote_identifier(table_name)}"
).fetchone()[0]
except sqlite3.Error:
distinct_count = 0
sample_values = fetch_sample_values(conn, table_name, column_name)
numeric_ratio = numeric_parse_ratio(sample_values)
boolean_ratio = boolean_parse_ratio(sample_values)
date_ratio = date_parse_ratio(sample_values)
id_like_ratio = identifier_like_ratio(sample_values)
numeric_samples = [parsed for parsed in (parse_numeric_scalar(v) for v in sample_values) if parsed is not None]
semantic_anomaly = ""
anomaly_ratio = 0.0
name_tokens = set(identifier_tokens(column_name))
if numeric_samples and name_tokens & {"score", "rating"}:
too_large = sum(1 for value in numeric_samples if value > 10)
in_range = sum(1 for value in numeric_samples if 0 <= value <= 10)
if too_large > 0 and in_range >= max(3, int(len(numeric_samples) * 0.6)):
semantic_anomaly = "score_scale"
anomaly_ratio = round(too_large / max(1, len(numeric_samples)), 4)
declared_type = ""
for raw in raw_columns:
if isinstance(raw, str) and raw.startswith(f"{column_name} "):
declared_type = raw.rsplit("(", 1)[-1].rstrip(")")
break
column_profiles[column_name] = {
"declared_type": declared_type.upper(),
"nullish_count": nullish_count,
"nullish_ratio": round(nullish_count / max(1, row_count), 4),
"distinct_count": distinct_count,
"distinct_ratio": round(distinct_count / max(1, row_count), 4),
"numeric_ratio": round(numeric_ratio, 4),
"boolean_ratio": round(boolean_ratio, 4),
"date_ratio": round(date_ratio, 4),
"identifier_like_ratio": round(id_like_ratio, 4),
"semantic_anomaly": semantic_anomaly,
"anomaly_ratio": anomaly_ratio,
"header_like_count": header_like_count,
}
try:
duplicate_groups = conn.execute(
f"SELECT COUNT(*) FROM ("
f"SELECT {quote_identifier(column_name)} "
f"FROM {quote_identifier(table_name)} "
f"WHERE {quote_identifier(column_name)} IS NOT NULL "
f"GROUP BY {quote_identifier(column_name)} "
f"HAVING COUNT(*) > 1)"
).fetchone()[0]
expected_clean = conn.execute(
f"SELECT COUNT(DISTINCT {quote_identifier(column_name)}) "
f"FROM {quote_identifier(table_name)} "
f"WHERE {quote_identifier(column_name)} IS NOT NULL"
).fetchone()[0]
except sqlite3.Error:
duplicate_groups = 0
expected_clean = row_count
is_key_like = is_identifier_like_name(column_name) or column_name.lower() in {
"id",
"order_id",
"invoice_id",
"customer_id",
"user_id",
"event_id",
"job_id",
}
distinct_ratio = column_profiles[column_name]["distinct_ratio"]
if duplicate_groups >= 2 and (
is_key_like or (is_identity_like_name(column_name) and distinct_ratio >= 0.7)
):
duplicate_candidates.append(
{
"table": table_name,
"key_columns": [column_name],
"duplicate_groups": duplicate_groups,
"expected_clean_count": expected_clean,
"score": (
distinct_ratio
+ (0.45 if is_key_like else 0.0)
+ (0.2 if is_identity_like_name(column_name) else 0.0)
- min(0.3, duplicate_groups / max(1, row_count))
),
}
)
if not duplicate_candidates and len(columns) >= 2:
composite_pool = [
column_name
for column_name in columns
if is_identity_like_name(column_name)
or column_profiles[column_name]["distinct_ratio"] >= 0.55
][:6]
for combo_size in (3, 2):
for combo in combinations(composite_pool, combo_size):
if not any(is_identity_like_name(column_name) for column_name in combo):
continue
if all(is_low_signal_dedup_name(column_name) for column_name in combo):
continue
try:
key_expr = ", ".join(quote_identifier(column_name) for column_name in combo)
duplicate_groups = conn.execute(
f"SELECT COUNT(*) FROM ("
f"SELECT {key_expr} "
f"FROM {quote_identifier(table_name)} "
f"GROUP BY {key_expr} "
f"HAVING COUNT(*) > 1)"
).fetchone()[0]
expected_clean = conn.execute(
f"SELECT COUNT(*) FROM ("
f"SELECT DISTINCT {key_expr} "
f"FROM {quote_identifier(table_name)})"
).fetchone()[0]
except sqlite3.Error:
duplicate_groups = 0
expected_clean = row_count
if duplicate_groups <= 1:
continue
combined_distinct_ratio = expected_clean / max(1, row_count)
if combined_distinct_ratio < 0.6:
continue
semantic_bonus = 0.12 * sum(
1 for column_name in combo if is_identity_like_name(column_name)
)
duplicate_candidates.append(
{
"table": table_name,
"key_columns": list(combo),
"duplicate_groups": duplicate_groups,
"expected_clean_count": expected_clean,
"score": combined_distinct_ratio + semantic_bonus,
}
)
if duplicate_candidates:
break
table_profiles[table_name] = {
"table_name": table_name,
"row_count": row_count,
"columns": columns,
"column_profiles": column_profiles,
"duplicate_candidates": sorted(
duplicate_candidates,
key=lambda item: item["score"],
reverse=True,
),
}
# null_audit candidate
null_candidate = None
for table_profile in table_profiles.values():
for column_name, profile in table_profile["column_profiles"].items():
if profile["nullish_ratio"] < 0.05:
continue
candidate = {
"task_id": "null_audit",
"supported": True,
"confidence": min(0.99, profile["nullish_ratio"] + 0.2),
"table": table_profile["table_name"],
"related_tables": [table_profile["table_name"]],
"column": column_name,
"description": f"Identify the highest nullish-rate column in {table_profile['table_name']}.",
"input_mode": "answer",
"input_label": "Column name",
"input_placeholder": column_name,
"action_guidance": "Enter only the single column name with the highest nullish rate.",
"context_hint": (
f"Target table: {table_profile['table_name']}; focus on column {column_name}. "
f"Nullish ratio: {round(profile['nullish_ratio'] * 100, 1)}%."
),
"verification_sql": (
f"SELECT COUNT(*) AS nullish_count FROM {quote_identifier(table_profile['table_name'])} "
f"WHERE {quote_identifier(column_name)} IS NULL "
f"OR TRIM(CAST({quote_identifier(column_name)} AS TEXT)) = ''"
),
"ground_truth": {
"task_type": "null_audit",
"table": table_profile["table_name"],
"column": column_name,
"null_column": column_name,
"null_rate": profile["nullish_ratio"],
"original_row_count": table_profile["row_count"],
"all_columns": table_profile["columns"],
"uploaded_mode": True,
},
}
if null_candidate is None or candidate["ground_truth"]["null_rate"] > null_candidate["ground_truth"]["null_rate"]:
null_candidate = candidate
task_candidates["null_audit"] = null_candidate or {
"task_id": "null_audit",
"supported": False,
"reason": "No column with meaningful nullish values was detected in the uploaded data.",
}
# type_repair candidate
type_candidate = None
for table_profile in table_profiles.values():
for column_name, profile in table_profile["column_profiles"].items():
declared = profile["declared_type"]
family = None
ratio = 0.0
name_tokens = set(identifier_tokens(column_name))
if is_identifier_like_name(column_name) or profile.get("identifier_like_ratio", 0.0) >= 0.5:
continue
anomaly_type = profile.get("semantic_anomaly", "")
if anomaly_type == "score_scale":
family = "score_scale"
ratio = max(0.8, 1.0 - profile.get("anomaly_ratio", 0.0))
elif declared not in {"INTEGER", "REAL", "NUMERIC", "FLOAT", "DOUBLE"} and profile["numeric_ratio"] >= 0.75:
family = "numeric"
ratio = profile["numeric_ratio"]
elif declared not in {"BOOLEAN", "INTEGER"} and profile["boolean_ratio"] >= 0.85:
family = "boolean"
ratio = profile["boolean_ratio"]
elif declared not in {"DATE", "DATETIME", "TIMESTAMP"} and profile["date_ratio"] >= 0.75:
family = "date"
ratio = profile["date_ratio"]
if not family:
continue
if family == "date" and ratio >= 0.98:
# Fully parseable date-like text does not need repair unless another anomaly exists.
continue
score = ratio
if family in {"numeric", "score_scale"}:
score += 0.15
if name_tokens & BUSINESS_NUMERIC_TOKENS:
score += 0.2
if name_tokens & {"score", "rating"} and family == "score_scale":
score += 0.25
if declared in {"", "TEXT"}:
score += 0.05
candidate = {
"task_id": "type_repair",
"supported": True,
"confidence": min(0.99, score),
"ranking_score": score,
"table": table_profile["table_name"],
"related_tables": [table_profile["table_name"]],
"column": column_name,
"description": (
f"Repair numeric anomalies in {table_profile['table_name']}.{column_name}."
if family == "score_scale"
else f"Repair typed values stored inconsistently in {table_profile['table_name']}.{column_name}."
),
"input_mode": "fix_sql",
"input_label": "Fix SQL",
"input_placeholder": f'UPDATE {quote_identifier(table_profile["table_name"])} SET {quote_identifier(column_name)} = ...',
"action_guidance": "Submit SQL that repairs the affected values on a cloned database.",
"context_hint": (
f"Target table: {table_profile['table_name']}; column {column_name} contains score/rating values above 10 that likely need rescaling."
if family == "score_scale"
else f"Target table: {table_profile['table_name']}; column {column_name} looks {family}-like "
f"but is declared as {declared or 'TEXT'}."
),
"verification_sql": (
f"SELECT {quote_identifier(column_name)}, typeof({quote_identifier(column_name)}) "
f"FROM {quote_identifier(table_profile['table_name'])} "
f"WHERE {quote_identifier(column_name)} IS NOT NULL LIMIT 10"
),
"ground_truth": {
"task_type": "type_repair",
"table": table_profile["table_name"],
"column": column_name,
"target_column": column_name,
"issue_type": family,
"baseline_parse_ratio": ratio,
"baseline_anomaly_ratio": profile.get("anomaly_ratio", 0.0),
"declared_type": declared or "TEXT",
"original_row_count": table_profile["row_count"],
"all_columns": table_profile["columns"],
"uploaded_mode": True,
},
}
if type_candidate is None or candidate["ranking_score"] > type_candidate.get("ranking_score", type_candidate["confidence"]):
type_candidate = candidate
task_candidates["type_repair"] = type_candidate or {
"task_id": "type_repair",
"supported": False,
"reason": "No strongly typed-as-text column was detected in the uploaded data.",
}
# dedup_bug candidate
dedup_candidate = None
for table_profile in table_profiles.values():
if not table_profile["duplicate_candidates"]:
continue
best = table_profile["duplicate_candidates"][0]
key_columns = best["key_columns"]
candidate = {
"task_id": "dedup_bug",
"supported": True,
"confidence": 0.85,
"table": table_profile["table_name"],
"related_tables": [table_profile["table_name"]],
"column": key_columns[0],
"key_columns": key_columns,
"description": f"Repair duplicate rows in {table_profile['table_name']}.",
"input_mode": "fix_sql",
"input_label": "Fix SQL",
"input_placeholder": f'DELETE FROM {quote_identifier(table_profile["table_name"])} WHERE rowid NOT IN (...)',
"action_guidance": "Submit SQL that removes duplicate rows while preserving one valid row per duplicate key.",
"context_hint": f"Target table: {table_profile['table_name']}; duplicate key candidate: {', '.join(best.get('key_columns', ['unknown']))}",
"ranking_score": best["score"],
"ground_truth": {
"task_type": "dedup_bug",
"table": table_profile["table_name"],
"key_columns": key_columns,
"column": key_columns[0] if key_columns else None,
"expected_clean_count": table_profile["row_count"] - best.get("duplicate_count", 0),
"original_row_count": table_profile["row_count"],
"uploaded_mode": True,
}
}
if dedup_candidate is None or best["score"] > dedup_candidate.get("ranking_score", 0):
dedup_candidate = candidate
task_candidates["dedup_bug"] = dedup_candidate or {
"task_id": "dedup_bug",
"supported": False,
"reason": "No meaningful duplicate-key pattern was detected in the uploaded data.",
}
# fk_integrity candidate
fk_candidate = None
for child_table, child_profile in table_profiles.items():
for column_name in child_profile["columns"]:
if not column_name.lower().endswith("_id") or column_name.lower() == "id":
continue
stem = column_name[:-3].lower()
for parent_table, parent_profile in table_profiles.items():
if parent_table == child_table:
continue
parent_keys = [col for col in parent_profile["columns"] if col.lower() == "id" or col.lower() == stem or col.lower() == f"{stem}_id"]
if not parent_keys:
continue
parent_key = parent_keys[0]
try:
orphan_count = conn.execute(
f"SELECT COUNT(*) FROM {quote_identifier(child_table)} c "
f"LEFT JOIN {quote_identifier(parent_table)} p "
f"ON c.{quote_identifier(column_name)} = p.{quote_identifier(parent_key)} "
f"WHERE c.{quote_identifier(column_name)} IS NOT NULL "
f"AND p.{quote_identifier(parent_key)} IS NULL"
).fetchone()[0]
valid_count = conn.execute(
f"SELECT COUNT(*) FROM {quote_identifier(child_table)} c "
f"JOIN {quote_identifier(parent_table)} p "
f"ON c.{quote_identifier(column_name)} = p.{quote_identifier(parent_key)}"
).fetchone()[0]
except sqlite3.Error:
orphan_count = 0
valid_count = 0
if orphan_count <= 0 or valid_count <= 0:
continue
candidate = {
"task_id": "fk_integrity",
"supported": True,
"confidence": min(0.95, 0.5 + orphan_count / max(1, child_profile["row_count"])),
"table": child_table,
"related_tables": [child_table, parent_table],
"column": column_name,
"description": f"Resolve orphaned rows in {child_table}.{column_name}.",
"input_mode": "fix_sql",
"input_label": "Fix SQL",
"input_placeholder": f'UPDATE {quote_identifier(child_table)} SET {quote_identifier(column_name)} = ... WHERE ...',
"action_guidance": "Submit SQL that resolves orphan child rows without damaging valid relationships.",
"context_hint": (
f"Child table: {child_table}; parent table: {parent_table}; "
f"foreign key candidate: {column_name} -> {parent_key}."
),
"verification_sql": (
f"SELECT COUNT(*) AS orphan_count FROM {quote_identifier(child_table)} c "
f"LEFT JOIN {quote_identifier(parent_table)} p "
f"ON c.{quote_identifier(column_name)} = p.{quote_identifier(parent_key)} "
f"WHERE c.{quote_identifier(column_name)} IS NOT NULL "
f"AND p.{quote_identifier(parent_key)} IS NULL"
),
"ground_truth": {
"task_type": "fk_integrity",
"table": child_table,
"column": column_name,
"parent_table": parent_table,
"parent_key": parent_key,
"expected_clean_count": valid_count,
"orphan_count": orphan_count,
"original_row_count": child_profile["row_count"],
"all_columns": child_profile["columns"],
"uploaded_mode": True,
},
}
if fk_candidate is None or candidate["ground_truth"]["orphan_count"] > fk_candidate["ground_truth"]["orphan_count"]:
fk_candidate = candidate
task_candidates["fk_integrity"] = fk_candidate or {
"task_id": "fk_integrity",
"supported": False,
"reason": "No real parent-child orphan pattern was detected across the uploaded tables.",
}
# schema_drift candidate
drift_candidate = None
for left_table, right_table in combinations(tables, 2):
left_base = normalize_base_table_name(left_table)
right_base = normalize_base_table_name(right_table)
left_cols = set(table_profiles[left_table]["columns"])
right_cols = set(table_profiles[right_table]["columns"])
overlap = len(left_cols & right_cols)
if left_base != right_base and overlap < 2:
continue
if overlap == 0:
continue
canonical_name = "canonical_events" if "event" in left_base or "event" in right_base else f"canonical_{left_base or right_base}"
expected_columns = sorted(left_cols | right_cols)
candidate = {
"task_id": "schema_drift",
"supported": True,
"confidence": min(0.9, overlap / max(1, min(len(left_cols), len(right_cols))) + 0.2),
"table": canonical_name,
"related_tables": [left_table, right_table],
"column": expected_columns[0] if expected_columns else "",
"description": f"Create a canonical table from {left_table} and {right_table}.",
"input_mode": "fix_sql",
"input_label": "Fix SQL",
"input_placeholder": f'CREATE TABLE {quote_identifier(canonical_name)} AS SELECT ...',
"action_guidance": "Submit SQL that creates a canonical table covering both source tables.",
"context_hint": (
f"Comparable source tables detected: {left_table} and {right_table}. "
f"Canonical output should be {canonical_name}."
),
"verification_sql": f"SELECT COUNT(*) FROM {quote_identifier(canonical_name)}",
"ground_truth": {
"task_type": "schema_drift",
"table": canonical_name,
"canonical_table": canonical_name,
"source_tables": [left_table, right_table],
"expected_columns": expected_columns,
"expected_row_count": table_profiles[left_table]["row_count"] + table_profiles[right_table]["row_count"],
"original_row_count": table_profiles[left_table]["row_count"] + table_profiles[right_table]["row_count"],
"uploaded_mode": True,
},
}
drift_candidate = candidate
break
task_candidates["schema_drift"] = drift_candidate or {
"task_id": "schema_drift",
"supported": False,
"reason": "No comparable versioned tables were detected in the uploaded database.",
}
return {
"table_profiles": table_profiles,
"task_candidates": task_candidates,
}
class SQLQualityEnvironment:
"""Main environment class for SQL Quality tasks."""
def __init__(self) -> None:
"""Initialize environment."""
self._current_task_id: str | None = None
self._episode_id: str | None = None
self._attempt_number: int = 1
self._step_count: int = (
0 # increments on every action (QUERY, PROFILE, FIX, SUBMIT)
)
self._max_steps_per_episode: int = 20 # prevent infinite loops
self._cumulative_reward: float = 0.0
self._done: bool = False
self._ground_truth: Dict[str, Any] = {}
self._cached_observation: SQLObservation | None = None
self._lock = threading.Lock()
# NEW: Multi-step trajectory support
self._trajectory: list = []
self._original_snapshot: dict = {}
self._original_conn: sqlite3.Connection | None = None
self._uploaded_analysis: dict[str, Any] = {}
self._task_context: dict[str, Any] = {}
self._active: bool = False
self._last_feedback: str | None = None
def reset(self, task_id: str, seed: int | None = None) -> SQLObservation:
"""Reset environment for a new episode.
Args:
task_id: One of the registered task IDs
seed: Optional deterministic episode seed
Returns:
SQLObservation for the new episode
Raises:
ValueError: If task_id is not valid
"""
with self._lock:
# ── NEW: close any live connection from the previous episode ──────
if hasattr(self, "_original_conn") and self._original_conn is not None:
try:
self._original_conn.close()
except Exception:
pass # Already closed or never opened — safe to ignore
self._original_conn = None
# Validate task_id
if task_id not in TASK_CREATORS:
raise ValueError(f"Invalid task_id: {task_id}")
# Generate new episode
self._episode_id = str(uuid.uuid4())
self._attempt_number = 1
self._step_count = (
0 # increments on every action (QUERY, PROFILE, FIX, SUBMIT)
)
self._cumulative_reward = 0.0
self._done = False
self._current_task_id = task_id
self._trajectory = []
self._original_snapshot = {}
self._ground_truth = {}
self._task_context = {}
self._cached_observation = None
self._last_feedback = None
# Create task-specific database to extract observation data
conn = None
try:
# Generate random episode seed for diversity
episode_seed = seed if seed is not None else _random.randint(0, 2**31 - 1)
creator = TASK_CREATORS[task_id]
conn, observation, self._ground_truth = creator(episode_seed)
# Store original connection for graders (read-only use)
self._original_conn = conn
# Snapshot the target table for data preservation grading
target_table = self._ground_truth.get("table", "")
if target_table:
from .graders import _snapshot_table
self._original_snapshot = _snapshot_table(conn, target_table)
# Mark episode as active
self._active = True
# Cache observation for reuse in step()
self._cached_observation = observation.model_copy(
update={"episode_id": self._episode_id}
)
# Store schema info for dynamic updates
self._schema_info = observation.schema_info
# Keep connection open for multi-step operations
# Connection will be closed when episode ends or environment is reset
except Exception as e:
if conn is not None:
conn.close()
raise e
return self._cached_observation
def reset_with_connection(
self,
task_id: str,
conn: sqlite3.Connection,
schema_info: dict,
uploaded_analysis: dict[str, Any] | None = None,
) -> SQLObservation:
"""Reset environment using an uploaded database connection.
This allows RL tasks to run on user-uploaded databases instead of
synthetic data.
Args:
task_id: One of the registered task IDs
conn: SQLite connection to the uploaded database
schema_info: Dictionary mapping table names to schema info
Returns:
SQLObservation for the new episode
Raises:
ValueError: If task_id is not valid
"""
with self._lock:
# ── close any live connection from the previous episode ──────
if hasattr(self, "_original_conn") and self._original_conn is not None:
try:
self._original_conn.close()
except Exception:
pass
self._original_conn = None
# Validate task_id
if task_id not in TASK_CREATORS:
raise ValueError(f"Invalid task_id: {task_id}")
# Generate new episode
self._episode_id = str(uuid.uuid4())
self._attempt_number = 1
self._step_count = 0
self._cumulative_reward = 0.0
self._done = False
self._current_task_id = task_id
self._trajectory = []
self._original_snapshot = {}
self._ground_truth = {}
self._task_context = {}
self._cached_observation = None
self._last_feedback = None
try:
# Use the uploaded connection
self._original_conn = conn
self._uploaded_analysis = uploaded_analysis or analyze_uploaded_database(conn, schema_info)
task_candidates = self._uploaded_analysis.get("task_candidates", {})
task_candidate = task_candidates.get(task_id, {})
if not task_candidate.get("supported"):
reason = task_candidate.get(
"reason",
f"Task '{task_id}' is not supported for the uploaded database.",
)
raise ValueError(reason)
self._ground_truth = task_candidate.get("ground_truth", {}).copy()
self._task_context = {
"task_id": task_id,
"target_table": task_candidate.get("table"),
"target_column": task_candidate.get("column"),
"related_tables": task_candidate.get("related_tables", []),
"key_columns": task_candidate.get("key_columns", []),
"verification_sql": task_candidate.get("verification_sql"),
"context_hint": task_candidate.get("context_hint"),
"confidence": task_candidate.get("confidence"),
"input_mode": task_candidate.get("input_mode", "fix_sql"),
"input_label": task_candidate.get("input_label", "Fix SQL"),
"input_placeholder": task_candidate.get("input_placeholder"),
"action_guidance": task_candidate.get("action_guidance"),
"description": task_candidate.get("description", f"Investigate task {task_id} on uploaded data."),
"max_attempts": task_candidate.get("max_attempts", 1),
"investigation_hint": task_candidate.get("context_hint", f"Using uploaded database."),
}
target_table = self._ground_truth.get("table", "")
# Snapshot the target table for data preservation grading
if target_table:
from .graders import _snapshot_table
self._original_snapshot = _snapshot_table(conn, target_table)
# Build observation from uploaded schema
from .models import SchemaInfo, SQLObservation
schema_list = []
sample_rows = {}
related_tables = task_candidate.get("related_tables") or ([target_table] if target_table else [])
for related_table in related_tables:
if related_table not in schema_info:
continue
table_data = schema_info[related_table]
schema_list.append(SchemaInfo(
table_name=related_table,
columns=table_data.get('columns', []),
row_count=table_data.get('row_count', 0)
))
try:
cursor = conn.execute(f'SELECT * FROM "{related_table}" LIMIT 3')
cols = [d[0] for d in cursor.description]
rows = cursor.fetchall()
sample_rows[related_table] = [dict(zip(cols, row)) for row in rows]
except Exception:
sample_rows[related_table] = []
observation = SQLObservation(
task_id=task_id,
task_description=task_candidate.get("description", f"Investigate task {task_id} on uploaded data."),
schema_info=schema_list,
sample_rows=sample_rows,
attempt_number=1,
episode_id=self._episode_id,
trajectory_history=[],
investigation_hint=task_candidate.get("context_hint", f"Using uploaded database: {target_table} table"),
max_attempts=1,
)
# Mark episode as active
self._active = True
self._cached_observation = observation
self._schema_info = schema_list
except Exception as e:
raise e
return self._cached_observation
def task_context(self) -> dict[str, Any]:
"""Expose the current uploaded-data task context for UI helpers."""
return self._task_context.copy()
def _analyze_data_for_ground_truth(
self,
conn: sqlite3.Connection,
task_id: str,
target_table: str
) -> dict:
"""Analyze uploaded database to determine ground truth for scoring.
Args:
conn: SQLite connection
task_id: The task type
target_table: The primary table for this task
Returns:
Dictionary with ground truth information
"""
ground_truth = {"task_type": task_id, "table": target_table}
try:
if task_id == "null_audit" and target_table:
# Find column with highest null rate
cursor = conn.execute(f'PRAGMA table_info("{target_table}")')
columns = [row[1] for row in cursor.fetchall()]
total_rows = conn.execute(
f'SELECT COUNT(*) FROM "{target_table}"'
).fetchone()[0]
if total_rows > 0:
max_null_rate = 0
max_null_col = None
for col in columns:
try:
null_count = conn.execute(
f'SELECT COUNT(*) FROM "{target_table}" WHERE "{col}" IS NULL'
).fetchone()[0]
null_rate = null_count / total_rows
if null_rate > max_null_rate:
max_null_rate = null_rate
max_null_col = col
except Exception:
continue
ground_truth["column"] = max_null_col
ground_truth["null_rate"] = max_null_rate
ground_truth["null_column"] = max_null_col
elif task_id == "dedup_bug" and target_table:
# Find duplicate keys
cursor = conn.execute(f'PRAGMA table_info("{target_table}")')
columns = [row[1] for row in cursor.fetchall()]
ground_truth["all_columns"] = columns
if columns:
# Try to find duplicates on first column
key_col = columns[0]
ground_truth["column"] = key_col
ground_truth["offending_key"] = key_col
# Count total duplicates
cursor = conn.execute(f'''
SELECT COUNT(*) FROM (
SELECT "{key_col}"
FROM "{target_table}"
GROUP BY "{key_col}"
HAVING COUNT(*) > 1
)
''')
dup_groups = cursor.fetchone()[0]
# Count expected rows after dedup (unique keys)
cursor = conn.execute(f'''
SELECT COUNT(DISTINCT "{key_col}") FROM "{target_table}"
''')
expected_clean = cursor.fetchone()[0]
ground_truth["expected_clean_count"] = expected_clean
# Get first duplicate example
cursor = conn.execute(f'''
SELECT "{key_col}", COUNT(*) as cnt
FROM "{target_table}"
GROUP BY "{key_col}"
HAVING cnt > 1
LIMIT 1
''')
result = cursor.fetchone()
if result:
ground_truth["duplicate_key"] = result[0]
ground_truth["duplicate_count"] = result[1]
ground_truth["duplicate_groups"] = dup_groups
elif task_id == "type_repair" and target_table:
# Find columns that might have type issues (TEXT containing numbers)
cursor = conn.execute(f'PRAGMA table_info("{target_table}")')
columns = [row[1] for row in cursor.fetchall()]
ground_truth["all_columns"] = columns
# Find columns with numeric-like text values
for col in columns:
try:
# Check if column contains numeric-looking values stored as TEXT
cursor = conn.execute(f'''
SELECT "{col}" FROM "{target_table}"
WHERE "{col}" IS NOT NULL
LIMIT 100
''')
values = [row[0] for row in cursor.fetchall()]
# Check if majority look like numbers but stored as text
numeric_like = 0
for v in values:
if v and str(v).strip():
try:
float(str(v).replace(',', '').replace('$', ''))
numeric_like += 1
except (ValueError, TypeError):
pass
if numeric_like > len(values) * 0.5:
ground_truth["column"] = col
ground_truth["target_column"] = col
ground_truth["issue_type"] = "TEXT_STORED_NUMERIC"
break
except Exception:
continue
# If no type issue found, default to first column
if "column" not in ground_truth and columns:
ground_truth["column"] = columns[0]
ground_truth["target_column"] = columns[0]
elif task_id == "fk_integrity" and target_table:
# For FK integrity, we need to simulate orphan records
# Since uploaded data may not have parent/child tables,
# we'll create a virtual parent table based on existing data
cursor = conn.execute(f'PRAGMA table_info("{target_table}")')
columns = [row[1] for row in cursor.fetchall()]
ground_truth["all_columns"] = columns
# Look for columns that might be foreign keys (ending in _id)
fk_candidates = [c for c in columns if c.lower().endswith('_id') and c.lower() != 'id']
if fk_candidates:
fk_col = fk_candidates[0]
ground_truth["column"] = fk_col
# Find values that would be orphans (using modulo to simulate missing parents)
cursor = conn.execute(f'''
SELECT DISTINCT "{fk_col}" FROM "{target_table}"
WHERE "{fk_col}" IS NOT NULL
''')
all_ids = [row[0] for row in cursor.fetchall()]
if all_ids:
# Mark some IDs as invalid to simulate orphans
rng = _random.Random(42) # Deterministic for testing
valid_ids = rng.sample(all_ids, max(1, len(all_ids) * 2 // 3))
valid_ids_set = set(valid_ids)
invalid_ids = [v for v in all_ids if v not in valid_ids_set]
ground_truth["parent_table"] = f"{target_table}_parent"
ground_truth["parent_key"] = fk_col.replace('_id', '')
ground_truth["valid_invoice_ids"] = valid_ids
ground_truth["expected_clean_count"] = len([v for v in all_ids if v in valid_ids])
ground_truth["orphan_count"] = len(invalid_ids)
elif task_id == "schema_drift" and target_table:
# Schema drift compares two versions of same table
# For uploaded data, we'll treat it as canonical and create expected schema
cursor = conn.execute(f'PRAGMA table_info("{target_table}")')
columns_info = cursor.fetchall()
columns = [row[1] for row in columns_info]
ground_truth["table"] = target_table
ground_truth["canonical_table"] = target_table
ground_truth["expected_columns"] = columns
# Get row count
cursor = conn.execute(f'SELECT COUNT(*) FROM "{target_table}"')
ground_truth["expected_row_count"] = cursor.fetchone()[0]
# Get distinct IDs if available
if 'id' in [c.lower() for c in columns]:
id_col = next(c for c in columns if c.lower() == 'id')
cursor = conn.execute(f'SELECT COUNT(DISTINCT "{id_col}") FROM "{target_table}"')
ground_truth["expected_distinct_ids"] = cursor.fetchone()[0]
except Exception as e:
# Ground truth analysis failed, but we can still run the task
ground_truth["analysis_error"] = str(e)
return ground_truth
def step(self, action: SQLAction) -> StepResponse:
"""
Execute one agent action and return the resulting observation and reward.
Routes by action_type:
QUERY - execute SELECT on episode conn, return result in observation
PROFILE - compute column statistics, return in observation
FIX - execute on fresh_test_db(), score partially, continue episode
SUBMIT - call compute_episode_score(), end episode
"""
with self._lock:
if not self._active:
raise HTTPException(
status_code=422, detail="No active episode. Call reset() first."
)
if self._done:
raise HTTPException(
status_code=422,
detail="Episode is already done. Call reset() to start a new episode.",
)
# Check max steps guard
if self._step_count >= self._max_steps_per_episode:
raise HTTPException(
status_code=422,
detail="Max investigation steps reached. Call SUBMIT or reset().",
)
action = self._normalize_action(action)
action_type = action.action_type
# --- QUERY action ---
if action_type == ActionType.QUERY:
return self._handle_query(action)
# --- PROFILE action ---
elif action_type == ActionType.PROFILE:
return self._handle_profile(action)
# --- FIX action ---
elif action_type == ActionType.FIX:
return self._handle_fix(action)
# --- SUBMIT action (or legacy single-step) ---
else:
return self._handle_submit(action)
def _normalize_action(self, action: SQLAction) -> SQLAction:
"""Infer v2 action_type for backward-compatible payloads without action_type."""
if "action_type" in action.model_fields_set:
return action
inferred = ActionType.SUBMIT
if action.column_name:
inferred = ActionType.PROFILE
elif action.fix_query:
inferred = ActionType.FIX
elif action.sql:
inferred = ActionType.QUERY if is_readonly_statement(action.sql) else ActionType.FIX
elif action.answer or action.root_cause_table or action.offending_key:
inferred = ActionType.SUBMIT
return action.model_copy(update={"action_type": inferred})
def _handle_query(self, action: SQLAction) -> StepResponse:
"""
Handle a QUERY action.
Executes the SQL on the episode connection (supports multi-statement).
Returns a result preview in the observation. Does NOT end the episode.
Awards small step_reward (0.05) for queries that target the right area.
"""
# Minimal implementation to isolate the issue
sql = (action.sql or "").strip()
# Execute the query directly
try:
cursor = self._original_conn.execute(sql)
if cursor.description:
rows = cursor.fetchmany(5)
cols = [d[0] for d in cursor.description]
result_preview = {
"type": "table",
"columns": cols,
"rows": [dict(zip(cols, row)) for row in rows],
"total_rows": len(rows),
}
else:
result_preview = f"Query executed successfully. Rows affected: {cursor.rowcount}"
except Exception as e:
result_preview = f"SQL Error: {e}"
# Add to trajectory
self._trajectory.append(
TrajectoryStep(
step_number=len(self._trajectory) + 1,
action_type=ActionType.QUERY,
sql_submitted=sql,
result_preview=str(result_preview)[:400],
step_reward=0.01,
)
)
# Build minimal observation
from .models import SQLObservation
obs = SQLObservation(
task_id=getattr(self, '_current_task_id', 'unknown'),
episode_id=getattr(self, '_episode_id', 'unknown'),
task_description="Test task",
schema_info=getattr(self, '_schema_info', []),
sample_rows={},
attempt_number=getattr(self, '_attempt_number', 1),
max_attempts=1,
previous_feedback=None,
trajectory_history=[],
available_actions=["query", "profile", "fix", "submit"],
investigation_hint=None,
)
return StepResponse(
observation=obs,
reward=SQLReward(
score=self._cumulative_reward,
step_reward=0.01,
feedback=str(result_preview)[:400],
dimensions={},
),
done=False,
info={"action_type": "query", "step": len(self._trajectory)},
)
def _handle_profile(self, action: SQLAction) -> StepResponse:
"""
Handle a PROFILE action — statistical summary of a named column.
Computes: null rate, distinct count, data type distribution, min/max.
Does NOT end the episode. Awards step_reward based on column relevance.
"""
col = (action.column_name or "").strip()
result_preview = ""
if not col:
result_preview = "Error: column_name required for PROFILE action."
else:
# Try to find which table has this column
try:
tables = self._original_conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
profile_results = []
for (tbl,) in tables:
try:
cols = [
row[1]
for row in self._original_conn.execute(
f"PRAGMA table_info({tbl})"
).fetchall()
]
if col in cols:
total = self._original_conn.execute(
f"SELECT COUNT(*) FROM {quote_identifier(tbl)}"
).fetchone()[0]
nulls = self._original_conn.execute(
nullish_count_sql(tbl, col)
).fetchone()[0]
distinct = self._original_conn.execute(
f"SELECT COUNT(DISTINCT {quote_identifier(col)}) FROM {quote_identifier(tbl)}"
).fetchone()[0]
profile_results.append(
f"{tbl}.{col}: total={total}, nulls={nulls} ({100*nulls//total if total else 0}%), distinct={distinct}"
)
except sqlite3.Error:
pass
result_preview = (
" | ".join(profile_results)
if profile_results
else f"Column '{col}' not found."
)
except sqlite3.Error as e:
result_preview = f"Profile error: {e}"
# Determine step_reward based on relevance to ground truth
ground_table = self._ground_truth.get("table", "").lower()
ground_col = self._ground_truth.get(
"column", self._ground_truth.get("null_column", "")
).lower()
col_lower = col.lower()
if col_lower == ground_col:
step_reward = 0.08 # On-target: profiling exact relevant column
elif ground_table and ground_table in result_preview.lower():
step_reward = 0.05 # Near-target: profiled something from right table
elif col_lower in result_preview.lower() and len(col_lower) > 2:
step_reward = 0.01 # Off-target: profiled something real but irrelevant
else:
step_reward = 0.01 # Minimal: invalid column or junk probe
self._trajectory.append(
TrajectoryStep(
step_number=len(self._trajectory) + 1,
action_type=ActionType.PROFILE,
sql_submitted=f"PROFILE({col})",
result_preview=str(result_preview)[:200] if result_preview else "",
step_reward=step_reward,
)
)
self._cumulative_reward = min(
1.0, round(self._cumulative_reward + step_reward, 4)
)
self._last_feedback = str(result_preview)[:200] if result_preview else ""
self._step_count += 1
obs = self._build_observation(result_preview=result_preview)
return StepResponse(
observation=obs,
reward=SQLReward(
score=self._cumulative_reward,
step_reward=step_reward,
feedback=str(result_preview)[:200] if result_preview else "",
),
done=False,
info={"action_type": "profile", "column_profiled": col},
)
def _handle_fix(self, action: SQLAction) -> StepResponse:
"""
Handle a FIX action — execute write query on isolated copy for preview scoring.
Executes on a _fresh_test_db() copy. Does NOT mutate episode state.
Returns intermediate feedback on whether fix appears to work.
Does NOT end episode — agent can follow up with verification query or SUBMIT.
Awards intermediate reward based on quick fix assessment.
"""
fix_sql = action.effective_sql or ""
step_reward = 0.0
feedback = ""
rewrite_note = ""
if fix_sql:
if getattr(self, "_schema_info", None):
# fix_sql, rewrites, unresolved = rewrite_known_quoted_identifiers(fix_sql, self._schema_info)
fix_sql, rewrites, unresolved = fix_sql, [], []
if unresolved:
feedback = (
"Fix preview blocked: unknown quoted identifier(s). Closest schema matches: "
+ ", ".join(f'"{src}" -> "{dst}"' for src, dst in unresolved[:3])
)
fix_sql = ""
if rewrites:
rewrite_note = " Auto-mapped: " + ", ".join(
f'"{src}" -> "{dst}"' for src, dst in rewrites[:3]
) + "."
from .graders import grade_fix_correctness
if fix_sql:
quick_score, severity = grade_fix_correctness(
fix_sql,
self._original_conn,
self._original_snapshot,
self._ground_truth,
)
step_reward = quick_score * 0.3 # Partial credit for fix attempt
preview_label = "Answer preview" if self._ground_truth.get("task_type") == "null_audit" else "Fix preview"
feedback = f"{preview_label}: {severity} ({quick_score:.2f}). Submit to finalize score.{rewrite_note}"
else:
feedback = "No SQL provided in fix action."
self._trajectory.append(
TrajectoryStep(
step_number=len(self._trajectory) + 1,
action_type=ActionType.FIX,
sql_submitted=fix_sql,
result_preview=feedback[:200],
step_reward=step_reward,
)
)
self._cumulative_reward = min(
1.0, round(self._cumulative_reward + step_reward, 4)
)
self._last_feedback = feedback
self._step_count += 1
obs = self._build_observation(result_preview=feedback)
return StepResponse(
observation=obs,
reward=SQLReward(
score=self._cumulative_reward,
step_reward=step_reward,
feedback=feedback,
),
done=False,
info={
"action_type": "fix",
"severity_preview": severity if fix_sql else "N/A",
},
)
def _handle_submit(self, action: SQLAction) -> StepResponse:
"""
Handle a SUBMIT action — compute final composite score and end episode.
Calls compute_episode_score() with the full trajectory.
Sets done=True. This is the only action that ends the episode.
"""
from .graders import compute_episode_score
reward = compute_episode_score(
trajectory=self._trajectory,
ground_truth=self._ground_truth,
original_conn=self._original_conn,
original_snapshot=self._original_snapshot,
submit_action=action,
)
self._done = True
self._active = False
self._cumulative_reward = min(
1.0, round(self._cumulative_reward + reward.score, 4)
)
self._last_feedback = reward.feedback
obs = self._build_observation(result_preview="")
# Close connection when episode ends
if self._original_conn:
self._original_conn.close()
self._original_conn = None
return StepResponse(
observation=obs,
reward=reward,
done=True,
info={
"action_type": "submit",
"final_score": reward.score,
"dimensions": reward.dimensions,
}
)
if fix_sql:
# quick_score, severity = grade_fix_correctness(
# fix_sql,
# self._original_conn,
# self._original_snapshot,
# self._ground_truth,
# )
quick_score, severity = 0.5, "UNKNOWN"
step_reward = quick_score * 0.3 # Partial credit for fix attempt
preview_label = "Answer preview" if self._ground_truth.get("task_type") == "null_audit" else "Fix preview"
feedback = f"{preview_label}: {severity} ({quick_score:.2f}). Submit to finalize score.{rewrite_note}"
else:
feedback = "No SQL provided in fix action."
self._trajectory.append(
TrajectoryStep(
step_number=len(self._trajectory) + 1,
action_type=ActionType.FIX,
sql_submitted=fix_sql,
result_preview=feedback[:200],
step_reward=step_reward,
)
)
self._cumulative_reward = min(
1.0, round(self._cumulative_reward + step_reward, 4)
)
self._last_feedback = feedback[:200] if feedback else ""
self._step_count += 1
# Build observation
obs = self._build_observation(result_preview=feedback)
return StepResponse(
observation=obs,
reward=SQLReward(
score=self._cumulative_reward,
step_reward=step_reward,
feedback=feedback[:200] if feedback else "",
dimensions={},
),
done=False,
info={"action_type": "fix", "step": len(self._trajectory)},
)
def _build_observation(self, result_preview: str = "") -> SQLObservation:
"""Build observation with current state and optional result preview."""
# Format trajectory history for frontend
trajectory_history = []
for step in self._trajectory:
trajectory_history.append({
"step_number": step.step_number,
"action_type": step.action_type.value,
"sql_submitted": step.sql_submitted,
"result_preview": step.result_preview,
"step_reward": step.step_reward,
})
# Fallback to _cached_observation for synthetic tasks where _task_context may be empty
task_description = self._task_context.get("description") or (self._cached_observation.task_description if self._cached_observation else "")
max_attempts = self._task_context.get("max_attempts") or (self._cached_observation.max_attempts if self._cached_observation else 1)
investigation_hint = self._task_context.get("investigation_hint") or (self._cached_observation.investigation_hint if self._cached_observation else None)
# Check if current task has a grader (all tasks currently have graders)
# This makes available_actions dynamic based on task grader status
tasks_with_graders = {"null_audit", "type_repair", "dedup_bug", "fk_integrity", "schema_drift"}
has_grader = self._current_task_id in tasks_with_graders
available_actions = ["query", "profile", "fix"]
if has_grader:
available_actions.append("submit")
return SQLObservation(
task_id=self._current_task_id,
episode_id=self._episode_id,
task_description=task_description or "",
schema_info=self._schema_info,
sample_rows=self._cached_observation.sample_rows if self._cached_observation else {},
attempt_number=self._attempt_number,
max_attempts=max_attempts or 1,
previous_feedback=self._last_feedback,
trajectory_history=trajectory_history,
available_actions=available_actions,
investigation_hint=investigation_hint,
)
def state(self) -> SQLState:
"""Get current environment state.
Returns:
SQLState with current episode information
Raises:
ValueError: If no episode has been started
"""
if self._episode_id is None:
raise ValueError("No episode started. Call reset() first.")
return SQLState(
task_id=self._current_task_id,
episode_id=self._episode_id,
attempt_number=self._attempt_number,
cumulative_reward=self._cumulative_reward,
done=self._done,
)
"""SQL Quality Environment - Core environment manager."""
from __future__ import annotations
from datetime import datetime
from difflib import get_close_matches
from itertools import combinations
import random as _random
import re
import sqlite3
import threading
import uuid
from typing import Any, Dict, List, Tuple
from fastapi import HTTPException
from .models import (
ActionType,
SQLAction,
SQLObservation,
SQLReward,
SQLState,
StepResponse,
TrajectoryStep,
)
from .tasks import (
create_dedup_bug_task,
create_fk_integrity_task,
create_null_audit_task,
create_schema_drift_task,
create_type_repair_task,
)
TASK_CREATORS = {
"null_audit": create_null_audit_task,
"type_repair": create_type_repair_task,
"dedup_bug": create_dedup_bug_task,
"fk_integrity": create_fk_integrity_task,
"schema_drift": create_schema_drift_task,
}
NULLISH_TEXT_VALUES = {
"",
"n/a",
"na",
"nan",
"null",
"none",
"missing",
"unknown",
"-1",
}
ALLOWED_READ_KEYWORDS = {"SELECT", "WITH", "EXPLAIN", "PRAGMA", "SHOW"}
IDENTITY_NAME_TOKENS = {
"id",
"title",
"name",
"email",
"company",
"location",
"address",
"original",
"imdb",
}
BUSINESS_NUMERIC_TOKENS = {
"score",
"rating",
"votes",
"income",
"revenue",
"salary",
"amount",
"price",
"cost",
"duration",
"founded",
}
LOW_SIGNAL_DEDUP_TOKENS = {
"genre",
"industry",
"sector",
"country",
"release",
"year",
"type",
"category",
}
def strip_sql_comments(sql: str) -> str:
"""Remove SQL comments while preserving string literals."""
if not sql:
return ""
result = []
i = 0
n = len(sql)
while i < n:
char = sql[i]