"""Pick / Filter / OrderBy — the typed shape the LLM emits and the composer
consumes.
Validation is strict: a Pick that references a metric or column not in recon
is rejected before composition. That's the whole point — the composer's
input must be expressible in terms of recon entities. There is no raw-SQL
escape hatch on a Filter; questions outside the supported shapes get a clean
"beyond what I'm built to answer" rather than a fabricated query.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
class PickValidationError(ValueError):
"""The Pick references a metric/column/filter shape that doesn't fit
recon. Surfaced cleanly to the caller (no retry — see feedback memory)."""
# ── Filter ──────────────────────────────────────────────────
@dataclass
class Filter:
"""One WHERE-clause fragment. Exactly one of the value fields must be set.
- column + equals: `
= `
- column + in_values: ` IN ()`
- column + between: ` BETWEEN AND ` (inclusive, both ends).
- column + date_range: ` BETWEEN '-01-01' AND '-12-31'`
for `"YYYY"`, or quarter bounds for `"YYYY-Q[1-4]"`.
"""
column: str
equals: str | int | float | None = None
in_values: list[Any] | None = None
between: tuple[Any, Any] | None = None
date_range: str | None = None
def kind(self) -> str:
"""Which filter shape is set; raises if none or more than one."""
set_fields = [
name for name in ("equals", "in_values", "between", "date_range")
if getattr(self, name) is not None
]
if len(set_fields) == 0:
raise PickValidationError(
f"filter on column {self.column!r} has no value field set"
)
if len(set_fields) > 1:
raise PickValidationError(
f"filter on column {self.column!r} sets multiple value fields: {set_fields}"
)
return set_fields[0]
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {"column": self.column}
for k in ("equals", "in_values", "between", "date_range"):
v = getattr(self, k)
if v is not None:
out[k] = list(v) if isinstance(v, tuple) else v
return out
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Filter":
if "column" not in d:
raise PickValidationError(f"filter missing 'column': {d!r}")
between = d.get("between")
if between is not None:
if not isinstance(between, (list, tuple)) or len(between) != 2:
raise PickValidationError(
f"filter 'between' must be a 2-element list, got {between!r}"
)
between = (between[0], between[1])
return cls(
column=d["column"],
equals=d.get("equals"),
in_values=d.get("in_values"),
between=between,
date_range=d.get("date_range"),
)
# ── OrderBy ─────────────────────────────────────────────────
@dataclass
class OrderBy:
"""ORDER BY clause. Either by metric or by a named dimension."""
by: Literal["metric", "dimension"]
direction: Literal["asc", "desc"] = "desc"
dimension: str | None = None
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {"by": self.by, "direction": self.direction}
if self.dimension is not None:
out["dimension"] = self.dimension
return out
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "OrderBy":
by = d.get("by")
if by not in ("metric", "dimension"):
raise PickValidationError(f"order_by.by must be 'metric'|'dimension', got {by!r}")
direction = d.get("direction", "desc")
if direction not in ("asc", "desc"):
raise PickValidationError(f"order_by.direction must be 'asc'|'desc', got {direction!r}")
dim = d.get("dimension")
if by == "dimension" and not dim:
raise PickValidationError("order_by.by='dimension' requires order_by.dimension")
return cls(by=by, direction=direction, dimension=dim)
# ── Pick ────────────────────────────────────────────────────
@dataclass
class Pick:
"""What the LLM emits — fully resolvable by the composer against recon.
Fields:
kind: literal "aggregate" (only supported shape in v1).
metric: must be a name in recon.metrics.
group_by: column refs (`col` or `table.col`); each resolved by
recon.resolve_column.
where: typed filters; the composer renders each against the column's
sql_type / semantic_type.
order_by: optional sort.
limit: optional row limit.
"""
kind: Literal["aggregate"]
metric: str
group_by: list[str] = field(default_factory=list)
where: list[Filter] = field(default_factory=list)
order_by: OrderBy | None = None
limit: int | None = None
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"kind": self.kind,
"metric": self.metric,
"group_by": list(self.group_by),
"where": [f.to_dict() for f in self.where],
}
if self.order_by is not None:
out["order_by"] = self.order_by.to_dict()
if self.limit is not None:
out["limit"] = self.limit
return out
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Pick":
kind = d.get("kind", "aggregate")
if kind != "aggregate":
raise PickValidationError(
f"only kind='aggregate' is supported; got {kind!r}"
)
metric = d.get("metric")
if not isinstance(metric, str) or not metric:
raise PickValidationError(f"pick missing 'metric': {d!r}")
group_by = d.get("group_by", []) or []
if not isinstance(group_by, list) or not all(isinstance(c, str) for c in group_by):
raise PickValidationError(f"pick.group_by must be list[str], got {group_by!r}")
where_raw = d.get("where", []) or []
if not isinstance(where_raw, list):
raise PickValidationError(f"pick.where must be a list, got {where_raw!r}")
where = [Filter.from_dict(f) for f in where_raw]
for f in where:
f.kind() # raises if shape is missing/ambiguous
order_by = OrderBy.from_dict(d["order_by"]) if d.get("order_by") else None
limit = d.get("limit")
if limit is not None and not isinstance(limit, int):
raise PickValidationError(f"pick.limit must be int or None, got {limit!r}")
return cls(
kind=kind,
metric=metric,
group_by=group_by,
where=where,
order_by=order_by,
limit=limit,
)
# ── ComposeResult ───────────────────────────────────────────
@dataclass
class ComposeResult:
"""What `compose()` returns. Same shape as the legacy T2SResult so call
sites can swap with minimal churn."""
sql: str
used_tables: list[str]