recon + sqlglot validator + drill_down package; guard ReAct dimension picks against candidate list

This commit is contained in:
2026-06-03 07:15:02 -03:00
parent e124a8a7d9
commit 2dad62f7e7
38 changed files with 1954 additions and 596 deletions

76
api/recon/validate.py Normal file
View File

@@ -0,0 +1,76 @@
"""SQL validation against the recon schema.
`validate_sql` parses the SQL, runs sqlglot's `qualify` optimizer pass with
the dataset's column→type schema, and raises with a clear message when the
LLM references a column that doesn't exist on the table it's bound to.
This is the deterministic complement to the LLM prompt: prompt hints help
the LLM get it right; the validator GUARANTEES we don't ship a schema-
wrong query to Postgres. If validation fails, the caller can either bubble
the error up (no silent retry) or do one *guided* re-prompt that includes
the validator's message — which is a correction with concrete facts, not
a blind retry.
"""
from __future__ import annotations
import sqlglot
from sqlglot.errors import OptimizeError
from sqlglot.optimizer.qualify import qualify
from api.recon import load_recon
from api.recon.types import Recon
class ReconValidationError(ValueError):
"""The SQL references a table/column combination that doesn't exist
in the recon. The original sqlglot error is in `__cause__`."""
def _build_sqlglot_schema(recon: Recon) -> dict[str, dict[str, str]]:
"""Convert recon → sqlglot's expected schema shape: {table: {col: type}}."""
return {
name: {col.name: col.sql_type for col in t.columns}
for name, t in recon.tables.items()
}
def validate_sql(sql: str, recon: Recon | None = None) -> None:
"""Raise `ReconValidationError` if any column reference in `sql` doesn't
exist on the table it's bound to. Returns None on success."""
recon = recon or load_recon()
schema = _build_sqlglot_schema(recon)
try:
parsed = sqlglot.parse_one(sql, dialect="postgres")
qualify(parsed, schema=schema, dialect="postgres")
except OptimizeError as e:
# Try to enrich the message with hints from the recon.
msg = str(e)
hint = _column_hint(msg, recon)
full = f"{msg}{hint}" if hint else msg
raise ReconValidationError(full) from e
_COL_PATTERNS = [
r"Column '([^']+)'", # "Column 'X' could not be resolved."
r"Unknown column:\s*\"?([^\"\s,]+)\"?", # "Unknown column: X"
r"column \"([^\"]+)\"", # 'column "X" does not exist'
]
def _column_hint(msg: str, recon: Recon) -> str:
"""If the error names a specific column, append a hint about which
table(s) actually own it. Tries the wordings sqlglot and psycopg use."""
import re
col_name: str | None = None
for pat in _COL_PATTERNS:
m = re.search(pat, msg)
if m:
col_name = m.group(1)
break
if col_name is None:
return ""
owners = recon.owning_tables(col_name)
if owners:
return f" (Column '{col_name}' actually lives in: {', '.join(owners)}.)"
return f" (No table in the {recon.schema!r} schema has a column named '{col_name}'.)"