recon-driven sql composer; pick → compose → execute; llm out of structural sql

This commit is contained in:
2026-06-03 11:01:02 -03:00
parent 61494362a3
commit 29c620b2c2
27 changed files with 1516 additions and 249 deletions

View File

@@ -23,6 +23,10 @@ class Column:
sql_type: str
nullable: bool
description: str | None = None
# Domain-level type that the composer dispatches on for filter rendering.
# Examples: "date_yymmdd" (BIRD's int-encoded YYMMDD dates), "date" (real
# postgres date). None = treat literally with the column's sql_type.
semantic_type: str | None = None
@classmethod
def from_inspector(cls, info: dict[str, Any], description: str | None = None) -> "Column":
@@ -40,12 +44,16 @@ class Column:
"sql_type": self.sql_type,
"nullable": self.nullable,
"description": self.description,
"semantic_type": self.semantic_type,
}
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Column":
return cls(name=d["name"], sql_type=d["sql_type"],
nullable=d["nullable"], description=d.get("description"))
return cls(
name=d["name"], sql_type=d["sql_type"],
nullable=d["nullable"], description=d.get("description"),
semantic_type=d.get("semantic_type"),
)
@dataclass
@@ -144,7 +152,7 @@ class Recon:
column_to_tables: dict[str, list[str]] = field(default_factory=dict)
relationships: list[Relationship] = field(default_factory=list)
# ── Read-side helpers used by Analyses + text_to_sql ──
# ── Read-side helpers used by Analyses + the composer ──
def table_names(self) -> list[str]:
return sorted(self.tables)
@@ -156,6 +164,37 @@ class Recon:
"""Tables that have a column with this name (case-sensitive)."""
return self.column_to_tables.get(column, [])
def resolve_column(self, ref: str) -> tuple[str, "Column"]:
"""Resolve a `column` or `table.column` reference to its (table_name,
Column) pair. Raises ValueError if the column doesn't exist or is
ambiguous (lives on multiple tables) without an explicit `table.` prefix.
Used by the composer to bind every Pick.group_by entry to a single
owning table before SQL composition. Disambiguation is on the caller:
if you mean `account.district_id`, say so."""
if "." in ref:
table, col = ref.split(".", 1)
if table not in self.tables:
raise ValueError(f"unknown table {table!r} in column ref {ref!r}")
for c in self.tables[table].columns:
if c.name == col:
return table, c
raise ValueError(f"column {col!r} not found on table {table!r}")
owners = self.owning_tables(ref)
if not owners:
raise ValueError(f"no table has a column named {ref!r}")
if len(owners) > 1:
raise ValueError(
f"column {ref!r} is ambiguous (lives in: {', '.join(owners)}); "
f"qualify it as 'table.{ref}'"
)
table = owners[0]
for c in self.tables[table].columns:
if c.name == ref:
return table, c
# Index says it's there but the table doesn't — shouldn't happen.
raise ValueError(f"column {ref!r} indexed under {table!r} but not found")
def join_path(self, src: str, dst: str) -> list[str] | None:
"""Shortest sequence of tables from src to dst via declared relationships.
Returns None if no path exists. The path includes both endpoints."""
@@ -220,6 +259,29 @@ class Recon:
)
return "\n".join(lines)
def render_metrics_brief(self) -> str:
"""One line per metric — name, unit, description. No SQL, no table.
For the planner: it picks WHICH analyses to run; it doesn't need
implementation detail. Saves prompt tokens and cuts the noise the
model has to filter through."""
if not self.metrics:
return "(no metrics defined)"
return "\n".join(
f"- {m.name} [{m.unit or 'unitless'}]: {m.description}"
for m in self.metrics.values()
)
def render_tables_brief(self) -> str:
"""One line per table — name + description. No columns, no types."""
if not self.tables:
return "(no tables)"
lines: list[str] = []
for t in self.tables.values():
desc = t.description or "(no description)"
lines.append(f"- {t.name}: {desc}")
return "\n".join(lines)
def render_relationships(self) -> str:
if not self.relationships:
return "(no relationships declared)"