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

95
api/analyses/_narrow.py Normal file
View File

@@ -0,0 +1,95 @@
"""Candidate narrowing for the Pick LLM call.
The composer covers L1's schema-pollution problem — the LLM never sees raw
SQL. But the LLM still needs to choose from *some* candidate set when
picking a metric / dimension / filter column. Sending the full schema for
every question is wasteful (cost, latency) and noisier (more candidates
the model can pick wrong from).
v1: **structural neighborhood**. Given the candidate metrics, compute the
union of:
- columns of each candidate metric's `from_table`
- columns of every table reachable within `max_hops` via declared
relationships.
This covers every column that could legitimately group/filter that metric
without hand-curating a list. Cheap, deterministic, easy to reason about.
v2 (future): embedding-based retrieval against question + metric/column
descriptions, with usage-derived signal (pgvector). Out of scope here.
"""
from __future__ import annotations
from dataclasses import dataclass
from api.recon.types import Metric, Recon
@dataclass
class Candidates:
metrics: list[Metric]
# column_refs: each entry is either a bare name (when unambiguous) or
# `table.column` (when the name lives on more than one reachable table).
column_refs: list[str]
def neighborhood_tables(recon: Recon, seeds: list[str], max_hops: int = 2) -> set[str]:
"""BFS from `seeds` over recon.relationships, returning every table
reachable within `max_hops`. Edges are undirected (FKs go both ways)."""
adj: dict[str, set[str]] = {t: set() for t in recon.tables}
for r in recon.relationships:
adj.setdefault(r.from_table, set()).add(r.to_table)
adj.setdefault(r.to_table, set()).add(r.from_table)
visited: set[str] = set()
frontier: set[str] = {s for s in seeds if s in recon.tables}
for _ in range(max_hops + 1): # +1 to include the seed itself at hop 0
visited |= frontier
next_frontier: set[str] = set()
for t in frontier:
next_frontier |= adj.get(t, set())
frontier = next_frontier - visited
if not frontier:
break
return visited
def narrow_candidates(
recon: Recon,
*,
allowed_metrics: list[str] | None = None,
max_hops: int = 2,
) -> Candidates:
"""Return the Pick-candidate set for the given metrics.
`allowed_metrics`: if provided, restricts the candidate metrics to this
list (intersected with recon.metrics). If None, every metric is in scope.
`max_hops`: how far to walk from each candidate metric's from_table.
"""
metric_names = (
[m for m in allowed_metrics if m in recon.metrics]
if allowed_metrics is not None
else list(recon.metrics)
)
metrics = [recon.metrics[m] for m in metric_names]
seeds = [m.from_table for m in metrics]
tables = neighborhood_tables(recon, seeds, max_hops=max_hops)
# Build the column ref list. A column name appearing in more than one
# reachable table is exposed as `table.column` for each owner to keep
# the LLM's pick unambiguous.
appearances: dict[str, list[str]] = {}
for tname in tables:
for c in recon.tables[tname].columns:
appearances.setdefault(c.name, []).append(tname)
refs: list[str] = []
for col, owners in appearances.items():
if len(owners) == 1:
refs.append(col)
else:
for t in owners:
refs.append(f"{t}.{col}")
refs.sort()
return Candidates(metrics=metrics, column_refs=refs)