recon-driven sql composer; pick → compose → execute; llm out of structural sql
This commit is contained in:
95
api/analyses/_narrow.py
Normal file
95
api/analyses/_narrow.py
Normal 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)
|
||||||
144
api/analyses/_pick.py
Normal file
144
api/analyses/_pick.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""pick_for_question — one constrained LLM call that returns a typed Pick.
|
||||||
|
|
||||||
|
Used by L2 Analyses that need to translate a free-form question into the
|
||||||
|
composer's input. The model is shown:
|
||||||
|
- the narrowed metric catalog (name + description + unit)
|
||||||
|
- the narrowed candidate column refs (bare or `table.column`)
|
||||||
|
- the filter grammar (via the system prompt)
|
||||||
|
|
||||||
|
It returns JSON. We validate the JSON into a `Pick` and fail fast on any
|
||||||
|
shape error — no local retry. Per the project's retry feedback, recovery
|
||||||
|
belongs at a future global layer that emits a visible event when it fires.
|
||||||
|
|
||||||
|
If the model returns `{"error": "..."}` (the prompt's escape hatch for
|
||||||
|
"this question can't be expressed"), we raise PickValidationError with
|
||||||
|
the model's reason — the caller surfaces it cleanly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from api import langfuse_client as lf
|
||||||
|
from api.analyses._narrow import Candidates, narrow_candidates
|
||||||
|
from api.composer.types import Pick, PickValidationError
|
||||||
|
from api.llm import chat
|
||||||
|
from api.prompts import load, render
|
||||||
|
from api.recon import load_recon
|
||||||
|
from api.recon.types import Recon
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PickOutcome:
|
||||||
|
pick: Pick
|
||||||
|
candidates: Candidates # the narrowed set the model saw (kept for tracing)
|
||||||
|
|
||||||
|
|
||||||
|
def pick_for_question(
|
||||||
|
question: str,
|
||||||
|
*,
|
||||||
|
recon: Recon | None = None,
|
||||||
|
allowed_metrics: list[str] | None = None,
|
||||||
|
max_tokens: int = 512,
|
||||||
|
span_name: str = "pick.gen",
|
||||||
|
) -> PickOutcome:
|
||||||
|
"""Run one LLM call to translate `question` into a Pick.
|
||||||
|
|
||||||
|
`allowed_metrics`: optional list to restrict the candidate metrics
|
||||||
|
(used by Analyses that already know which metric the planner chose,
|
||||||
|
so we don't waste tokens listing every metric in the catalog).
|
||||||
|
"""
|
||||||
|
recon = recon or load_recon()
|
||||||
|
candidates = narrow_candidates(recon, allowed_metrics=allowed_metrics)
|
||||||
|
|
||||||
|
metrics_block = _render_metrics_block(candidates)
|
||||||
|
columns_block = _render_columns_block(candidates)
|
||||||
|
|
||||||
|
system = load("pick.system")
|
||||||
|
user = render(
|
||||||
|
"pick.user",
|
||||||
|
question=question,
|
||||||
|
metrics_block=metrics_block,
|
||||||
|
columns_block=columns_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
with lf.span(
|
||||||
|
"pick_for_question",
|
||||||
|
input={
|
||||||
|
"question": question,
|
||||||
|
"metric_candidates": [m.name for m in candidates.metrics],
|
||||||
|
"column_candidate_count": len(candidates.column_refs),
|
||||||
|
},
|
||||||
|
) as span:
|
||||||
|
raw = chat(system=system, user=user, max_tokens=max_tokens, span_name=span_name)
|
||||||
|
payload = _parse_json(raw)
|
||||||
|
if "error" in payload:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"LLM declined to pick: {payload['error']!s}"
|
||||||
|
)
|
||||||
|
pick = Pick.from_dict(payload)
|
||||||
|
# Bind-check the pick against recon up-front — the composer would
|
||||||
|
# raise the same errors later, but raising here makes the failure
|
||||||
|
# event happen at the pick step instead of compose.
|
||||||
|
_validate_against_recon(pick, recon)
|
||||||
|
span.update(output=pick.to_dict())
|
||||||
|
return PickOutcome(pick=pick, candidates=candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_metrics_block(candidates: Candidates) -> str:
|
||||||
|
if not candidates.metrics:
|
||||||
|
return "(no candidate metrics)"
|
||||||
|
lines: list[str] = []
|
||||||
|
for m in candidates.metrics:
|
||||||
|
unit = f" [{m.unit}]" if m.unit else ""
|
||||||
|
lines.append(f"- {m.name}{unit}: {m.description}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_columns_block(candidates: Candidates) -> str:
|
||||||
|
if not candidates.column_refs:
|
||||||
|
return "(no candidate columns)"
|
||||||
|
return "\n".join(f"- {c}" for c in candidates.column_refs)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(text: str) -> dict:
|
||||||
|
"""Best-effort: prefer a ```json``` fence, otherwise extract the first
|
||||||
|
{...} block. Matches the existing pattern in drill_down._parse_json."""
|
||||||
|
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
||||||
|
raw = m.group(1) if m else text
|
||||||
|
start, end = raw.find("{"), raw.rfind("}")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"pick_for_question returned no JSON object: {text[:200]!r}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(raw[start:end + 1])
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"pick_for_question returned invalid JSON: {e!s}; raw={raw[:200]!r}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_against_recon(pick: Pick, recon: Recon) -> None:
|
||||||
|
"""Surface metric/column resolution errors as PickValidationError up
|
||||||
|
front so the trace marks the pick step (not compose) as the failure."""
|
||||||
|
if pick.metric not in recon.metrics:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"picked metric {pick.metric!r} is not in recon"
|
||||||
|
)
|
||||||
|
for ref in pick.group_by:
|
||||||
|
try:
|
||||||
|
recon.resolve_column(ref)
|
||||||
|
except ValueError as e:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"group_by column {ref!r}: {e}"
|
||||||
|
) from e
|
||||||
|
for f in pick.where:
|
||||||
|
try:
|
||||||
|
recon.resolve_column(f.column)
|
||||||
|
except ValueError as e:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"where filter on {f.column!r}: {e}"
|
||||||
|
) from e
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
"""compare_periods Analysis — CoT with two queries.
|
"""compare_periods Analysis — CoT with two queries.
|
||||||
|
|
||||||
Both SQL queries are generated in one LLM call (paired prompt), then both
|
One LLM call picks the shape (metric + group_by + non-time filters); the
|
||||||
executed, then a single interpretation pass diffs them. Single round-trip
|
Analysis injects each period as a typed date_range filter and the composer
|
||||||
per LLM step → cheap and bounded latency.
|
emits two SQLs deterministically. No LLM authors SQL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
from dataclasses import replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api import langfuse_client as lf
|
from api import langfuse_client as lf
|
||||||
|
from api.analyses._pick import pick_for_question
|
||||||
from api.analyses.base import Analysis
|
from api.analyses.base import Analysis
|
||||||
from api.analyses.types import Finding
|
from api.analyses.types import Finding
|
||||||
|
from api.composer import compose
|
||||||
|
from api.composer.types import Filter, Pick
|
||||||
from api.llm import chat
|
from api.llm import chat
|
||||||
from api.prompts import load, render
|
from api.prompts import load, render
|
||||||
from api.runtime import events
|
|
||||||
from api.recon import load_recon
|
from api.recon import load_recon
|
||||||
|
from api.recon.types import Recon
|
||||||
|
from api.runtime import events
|
||||||
from api.tools.execute_sql import execute_sql
|
from api.tools.execute_sql import execute_sql
|
||||||
|
|
||||||
logger = logging.getLogger("nvi.analyses.compare_periods")
|
logger = logging.getLogger("nvi.analyses.compare_periods")
|
||||||
@@ -28,44 +31,74 @@ class ComparePeriods(Analysis):
|
|||||||
name = "compare_periods"
|
name = "compare_periods"
|
||||||
description = (
|
description = (
|
||||||
"Compare a metric across two time windows (e.g. Q2 vs Q3, 1995 vs 1996). "
|
"Compare a metric across two time windows (e.g. Q2 vs Q3, 1995 vs 1996). "
|
||||||
"Generates two parallel SQL queries and diffs the results."
|
"Picks one shape, composes two SQLs with different date filters, diffs the results."
|
||||||
)
|
)
|
||||||
args_schema = {
|
args_schema = {
|
||||||
"question": {"type": "string", "description": "The metric / scope to compare."},
|
"question": {"type": "string", "description": "The metric / scope to compare."},
|
||||||
"period_a": {"type": "string", "description": "First period label, e.g. '1995' or 'Q2 1996'."},
|
"period_a": {"type": "string", "description": "First period label, e.g. '1995' or '1996-Q2'."},
|
||||||
"period_b": {"type": "string", "description": "Second period label, e.g. '1996' or 'Q3 1996'."},
|
"period_b": {"type": "string", "description": "Second period label, e.g. '1996' or '1996-Q3'."},
|
||||||
|
"allowed_metrics": {"type": "array", "items": "string", "description": "Metrics the planner judged relevant (optional)."},
|
||||||
}
|
}
|
||||||
|
|
||||||
async def run(self, args: dict[str, Any], question: str) -> Finding:
|
async def run(self, args: dict[str, Any], question: str) -> Finding:
|
||||||
sub_q = args.get("question") or question
|
sub_q = args.get("question") or question
|
||||||
period_a = args.get("period_a", "")
|
period_a = args.get("period_a", "")
|
||||||
period_b = args.get("period_b", "")
|
period_b = args.get("period_b", "")
|
||||||
|
allowed_metrics = args.get("allowed_metrics")
|
||||||
|
|
||||||
with lf.span("analysis.compare_periods", input=args) as span:
|
with lf.span("analysis.compare_periods", input=args) as span:
|
||||||
try:
|
try:
|
||||||
|
recon = load_recon()
|
||||||
|
|
||||||
await events.publish_current(events.tool_call_start(
|
await events.publish_current(events.tool_call_start(
|
||||||
"generate_pair", input={"question": sub_q, "period_a": period_a, "period_b": period_b},
|
"pick_for_question", input={"question": sub_q},
|
||||||
))
|
))
|
||||||
pair = _generate_pair(sub_q, period_a, period_b)
|
outcome = pick_for_question(
|
||||||
|
sub_q, recon=recon, allowed_metrics=allowed_metrics,
|
||||||
|
span_name="compare_periods.pick",
|
||||||
|
)
|
||||||
|
base_pick = outcome.pick
|
||||||
await events.publish_current(events.tool_call_end(
|
await events.publish_current(events.tool_call_end(
|
||||||
"generate_pair",
|
"pick_for_question", output={"pick": base_pick.to_dict()},
|
||||||
output={"a": {"label": pair["a"]["label"], "sql": pair["a"]["sql"]},
|
|
||||||
"b": {"label": pair["b"]["label"], "sql": pair["b"]["sql"]}},
|
|
||||||
))
|
))
|
||||||
|
|
||||||
await events.publish_current(events.tool_call_start("execute_sql", input={"label": pair["a"]["label"], "sql": pair["a"]["sql"]}))
|
date_col_ref = _date_column_for(base_pick.metric, recon)
|
||||||
res_a = execute_sql(pair["a"]["sql"])
|
pick_a = _with_period_filter(base_pick, date_col_ref, period_a)
|
||||||
|
pick_b = _with_period_filter(base_pick, date_col_ref, period_b)
|
||||||
|
|
||||||
|
await events.publish_current(events.tool_call_start(
|
||||||
|
"compose_sql", input={"label": period_a, "pick": pick_a.to_dict()},
|
||||||
|
))
|
||||||
|
composed_a = compose(pick_a, recon)
|
||||||
|
await events.publish_current(events.tool_call_end(
|
||||||
|
"compose_sql", output={"label": period_a, "sql": composed_a.sql},
|
||||||
|
))
|
||||||
|
|
||||||
|
await events.publish_current(events.tool_call_start(
|
||||||
|
"compose_sql", input={"label": period_b, "pick": pick_b.to_dict()},
|
||||||
|
))
|
||||||
|
composed_b = compose(pick_b, recon)
|
||||||
|
await events.publish_current(events.tool_call_end(
|
||||||
|
"compose_sql", output={"label": period_b, "sql": composed_b.sql},
|
||||||
|
))
|
||||||
|
|
||||||
|
await events.publish_current(events.tool_call_start(
|
||||||
|
"execute_sql", input={"label": period_a, "sql": composed_a.sql},
|
||||||
|
))
|
||||||
|
res_a = execute_sql(composed_a.sql)
|
||||||
await events.publish_current(events.tool_call_end(
|
await events.publish_current(events.tool_call_end(
|
||||||
"execute_sql",
|
"execute_sql",
|
||||||
output={"label": pair["a"]["label"], "row_count": res_a.row_count,
|
output={"label": period_a, "row_count": res_a.row_count,
|
||||||
"preview": res_a.as_dicts()[:5]},
|
"preview": res_a.as_dicts()[:5]},
|
||||||
))
|
))
|
||||||
|
|
||||||
await events.publish_current(events.tool_call_start("execute_sql", input={"label": pair["b"]["label"], "sql": pair["b"]["sql"]}))
|
await events.publish_current(events.tool_call_start(
|
||||||
res_b = execute_sql(pair["b"]["sql"])
|
"execute_sql", input={"label": period_b, "sql": composed_b.sql},
|
||||||
|
))
|
||||||
|
res_b = execute_sql(composed_b.sql)
|
||||||
await events.publish_current(events.tool_call_end(
|
await events.publish_current(events.tool_call_end(
|
||||||
"execute_sql",
|
"execute_sql",
|
||||||
output={"label": pair["b"]["label"], "row_count": res_b.row_count,
|
output={"label": period_b, "row_count": res_b.row_count,
|
||||||
"preview": res_b.as_dicts()[:5]},
|
"preview": res_b.as_dicts()[:5]},
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -73,8 +106,8 @@ class ComparePeriods(Analysis):
|
|||||||
interpret_user = render(
|
interpret_user = render(
|
||||||
"compare_periods.interpret.user",
|
"compare_periods.interpret.user",
|
||||||
question=sub_q,
|
question=sub_q,
|
||||||
label_a=pair["a"]["label"],
|
label_a=period_a,
|
||||||
label_b=pair["b"]["label"],
|
label_b=period_b,
|
||||||
rows_a=repr(res_a.as_dicts()[:20]),
|
rows_a=repr(res_a.as_dicts()[:20]),
|
||||||
rows_b=repr(res_b.as_dicts()[:20]),
|
rows_b=repr(res_b.as_dicts()[:20]),
|
||||||
)
|
)
|
||||||
@@ -98,48 +131,46 @@ class ComparePeriods(Analysis):
|
|||||||
analysis=self.name,
|
analysis=self.name,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
rows=[
|
rows=[
|
||||||
{"period": pair["a"]["label"], **r} for r in res_a.as_dicts()[:20]
|
{"period": period_a, **r} for r in res_a.as_dicts()[:20]
|
||||||
] + [
|
] + [
|
||||||
{"period": pair["b"]["label"], **r} for r in res_b.as_dicts()[:20]
|
{"period": period_b, **r} for r in res_b.as_dicts()[:20]
|
||||||
],
|
],
|
||||||
sql=[pair["a"]["sql"], pair["b"]["sql"]],
|
sql=[composed_a.sql, composed_b.sql],
|
||||||
metadata={
|
metadata={
|
||||||
"label_a": pair["a"]["label"],
|
"metric": base_pick.metric,
|
||||||
"label_b": pair["b"]["label"],
|
"label_a": period_a,
|
||||||
|
"label_b": period_b,
|
||||||
"row_count_a": res_a.row_count,
|
"row_count_a": res_a.row_count,
|
||||||
"row_count_b": res_b.row_count,
|
"row_count_b": res_b.row_count,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_pair(question: str, period_a: str, period_b: str) -> dict[str, dict[str, str]]:
|
def _date_column_for(metric_name: str, recon: Recon) -> str:
|
||||||
schema = load_recon()
|
"""Return the date column ref (qualified) for the metric's from_table.
|
||||||
text = chat(
|
|
||||||
system=load("compare_periods.pair"),
|
The composer needs to know which column carries time for the period
|
||||||
user=render(
|
filter. We look at the metric's source table and pick the first
|
||||||
"compare_periods.pair.user",
|
column whose sql_type is DATE / TIMESTAMP. Returned qualified
|
||||||
question=question,
|
(`table.column`) so resolve_column never trips on ambiguity."""
|
||||||
period_a=period_a or "(infer)",
|
metric = recon.metrics[metric_name]
|
||||||
period_b=period_b or "(infer)",
|
table = recon.tables[metric.from_table]
|
||||||
schema_block=schema.render_tables(),
|
for c in table.columns:
|
||||||
metrics_block=schema.render_metrics(),
|
sql_type = c.sql_type.upper()
|
||||||
),
|
if sql_type.startswith("DATE") or sql_type.startswith("TIMESTAMP"):
|
||||||
max_tokens=1024,
|
return f"{table.name}.{c.name}"
|
||||||
span_name="compare_periods.pair",
|
raise ValueError(
|
||||||
|
f"no date/timestamp column on table {table.name!r} for metric {metric_name!r}"
|
||||||
)
|
)
|
||||||
obj = json.loads(_extract_json(text))
|
|
||||||
for k in ("a", "b"):
|
|
||||||
if k not in obj or "sql" not in obj[k] or "label" not in obj[k]:
|
|
||||||
raise ValueError(f"pair generator returned malformed payload: missing {k}")
|
|
||||||
obj[k]["sql"] = obj[k]["sql"].strip().rstrip(";").strip()
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_json(text: str) -> str:
|
def _with_period_filter(pick: Pick, col_ref: str, period: str) -> Pick:
|
||||||
m = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
|
"""Return a new Pick with a date_range filter on `col_ref` set to
|
||||||
if m:
|
`period`. Strips any existing date_range filter on the same column
|
||||||
return m.group(1)
|
so the Analysis-injected one takes precedence."""
|
||||||
start, end = text.find("{"), text.rfind("}")
|
where = [
|
||||||
if start >= 0 and end > start:
|
f for f in pick.where
|
||||||
return text[start:end + 1]
|
if not (f.column == col_ref and f.date_range is not None)
|
||||||
return text
|
]
|
||||||
|
where.append(Filter(column=col_ref, date_range=period))
|
||||||
|
return replace(pick, where=where)
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api import langfuse_client as lf
|
from api import langfuse_client as lf
|
||||||
|
from api.analyses._pick import pick_for_question
|
||||||
from api.analyses.base import Analysis
|
from api.analyses.base import Analysis
|
||||||
from api.analyses.types import Finding
|
from api.analyses.types import Finding
|
||||||
|
from api.composer import compose
|
||||||
from api.llm import chat
|
from api.llm import chat
|
||||||
from api.prompts import load, render
|
from api.prompts import load, render
|
||||||
|
from api.recon import load_recon
|
||||||
from api.runtime import events
|
from api.runtime import events
|
||||||
from api.tools.execute_sql import execute_sql
|
from api.tools.execute_sql import execute_sql
|
||||||
from api.tools.text_to_sql import text_to_sql
|
|
||||||
|
|
||||||
logger = logging.getLogger("nvi.analyses.direct_answer")
|
logger = logging.getLogger("nvi.analyses.direct_answer")
|
||||||
|
|
||||||
@@ -29,23 +31,39 @@ class DirectAnswer(Analysis):
|
|||||||
)
|
)
|
||||||
args_schema = {
|
args_schema = {
|
||||||
"question": {"type": "string", "description": "Refined question this Analysis should answer."},
|
"question": {"type": "string", "description": "Refined question this Analysis should answer."},
|
||||||
"hint_tables": {"type": "array", "items": "string", "description": "Tables the planner thinks are relevant (optional)."},
|
"allowed_metrics": {"type": "array", "items": "string", "description": "Metrics the planner judged relevant (optional; defaults to all)."},
|
||||||
}
|
}
|
||||||
|
|
||||||
async def run(self, args: dict[str, Any], question: str) -> Finding:
|
async def run(self, args: dict[str, Any], question: str) -> Finding:
|
||||||
sub_q = args.get("question") or question
|
sub_q = args.get("question") or question
|
||||||
hint_tables = args.get("hint_tables")
|
allowed_metrics = args.get("allowed_metrics")
|
||||||
|
|
||||||
with lf.span("analysis.direct_answer", input={"question": sub_q}) as span:
|
with lf.span("analysis.direct_answer", input={"question": sub_q}) as span:
|
||||||
try:
|
try:
|
||||||
await events.publish_current(events.tool_call_start("text_to_sql", input={"question": sub_q}))
|
recon = load_recon()
|
||||||
t2s = text_to_sql(sub_q, hint_tables=hint_tables)
|
|
||||||
|
await events.publish_current(events.tool_call_start(
|
||||||
|
"pick_for_question", input={"question": sub_q},
|
||||||
|
))
|
||||||
|
outcome = pick_for_question(
|
||||||
|
sub_q, recon=recon, allowed_metrics=allowed_metrics,
|
||||||
|
span_name="direct_answer.pick",
|
||||||
|
)
|
||||||
await events.publish_current(events.tool_call_end(
|
await events.publish_current(events.tool_call_end(
|
||||||
"text_to_sql", output={"sql": t2s.sql, "tables": t2s.used_tables},
|
"pick_for_question", output={"pick": outcome.pick.to_dict()},
|
||||||
))
|
))
|
||||||
|
|
||||||
await events.publish_current(events.tool_call_start("execute_sql", input={"sql": t2s.sql}))
|
await events.publish_current(events.tool_call_start(
|
||||||
result = execute_sql(t2s.sql)
|
"compose_sql", input={"pick": outcome.pick.to_dict()},
|
||||||
|
))
|
||||||
|
composed = compose(outcome.pick, recon)
|
||||||
|
await events.publish_current(events.tool_call_end(
|
||||||
|
"compose_sql",
|
||||||
|
output={"sql": composed.sql, "tables": composed.used_tables},
|
||||||
|
))
|
||||||
|
|
||||||
|
await events.publish_current(events.tool_call_start("execute_sql", input={"sql": composed.sql}))
|
||||||
|
result = execute_sql(composed.sql)
|
||||||
await events.publish_current(events.tool_call_end(
|
await events.publish_current(events.tool_call_end(
|
||||||
"execute_sql",
|
"execute_sql",
|
||||||
output={"row_count": result.row_count, "truncated": result.truncated,
|
output={"row_count": result.row_count, "truncated": result.truncated,
|
||||||
@@ -56,7 +74,7 @@ class DirectAnswer(Analysis):
|
|||||||
interpret_user = render(
|
interpret_user = render(
|
||||||
"direct_answer.interpret.user",
|
"direct_answer.interpret.user",
|
||||||
question=sub_q,
|
question=sub_q,
|
||||||
sql=t2s.sql,
|
sql=composed.sql,
|
||||||
rows=repr(result.as_dicts()[:20]),
|
rows=repr(result.as_dicts()[:20]),
|
||||||
)
|
)
|
||||||
await events.publish_current(events.llm_call(
|
await events.publish_current(events.llm_call(
|
||||||
@@ -64,7 +82,10 @@ class DirectAnswer(Analysis):
|
|||||||
system_len=len(interpret_system),
|
system_len=len(interpret_system),
|
||||||
user_len=len(interpret_user),
|
user_len=len(interpret_user),
|
||||||
))
|
))
|
||||||
summary = chat(system=interpret_system, user=interpret_user, max_tokens=512)
|
summary = chat(
|
||||||
|
system=interpret_system, user=interpret_user, max_tokens=512,
|
||||||
|
span_name="direct_answer.interpret",
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("direct_answer failed")
|
logger.exception("direct_answer failed")
|
||||||
await events.publish_current(events.tool_call_end("direct_answer", error=str(e)))
|
await events.publish_current(events.tool_call_end("direct_answer", error=str(e)))
|
||||||
@@ -76,6 +97,10 @@ class DirectAnswer(Analysis):
|
|||||||
analysis=self.name,
|
analysis=self.name,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
rows=result.as_dicts()[:20],
|
rows=result.as_dicts()[:20],
|
||||||
sql=[t2s.sql],
|
sql=[composed.sql],
|
||||||
metadata={"row_count": result.row_count, "truncated": result.truncated},
|
metadata={
|
||||||
|
"row_count": result.row_count,
|
||||||
|
"truncated": result.truncated,
|
||||||
|
"metric": outcome.pick.metric,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ from typing import Any
|
|||||||
|
|
||||||
from api import langfuse_client as lf
|
from api import langfuse_client as lf
|
||||||
from api.analyses.drill_down.types import Slice
|
from api.analyses.drill_down.types import Slice
|
||||||
|
from api.composer import Pick, compose
|
||||||
from api.llm import chat
|
from api.llm import chat
|
||||||
from api.prompts import load, render
|
from api.prompts import load, render
|
||||||
from api.recon import load_recon
|
from api.recon import load_recon
|
||||||
from api.runtime import events
|
from api.runtime import events
|
||||||
from api.tools.execute_sql import execute_sql
|
from api.tools.execute_sql import execute_sql
|
||||||
from api.tools.text_to_sql import text_to_sql
|
|
||||||
|
|
||||||
logger = logging.getLogger("nvi.analyses.drill_down.helpers")
|
logger = logging.getLogger("nvi.analyses.drill_down.helpers")
|
||||||
|
|
||||||
@@ -69,54 +69,25 @@ async def decide_next(question: str, metric: str, dimensions: list[str],
|
|||||||
|
|
||||||
# ── Slice execution ──
|
# ── Slice execution ──
|
||||||
|
|
||||||
def build_slice_question(question: str, metric: str, dim: str) -> str:
|
|
||||||
"""Construct a slice question with explicit table/join hints from the
|
|
||||||
recon graph. Stops the LLM from inventing FROM clauses that omit the
|
|
||||||
table that owns the dimension column.
|
|
||||||
"""
|
|
||||||
recon = load_recon()
|
|
||||||
dim_owners = recon.owning_tables(dim)
|
|
||||||
metric_def = recon.metrics.get(metric)
|
|
||||||
metric_table = metric_def.from_table if metric_def else None
|
|
||||||
|
|
||||||
hints: list[str] = []
|
|
||||||
if dim_owners:
|
|
||||||
hints.append(f"- The dimension column `{dim}` lives in table: {', '.join(dim_owners)}.")
|
|
||||||
if metric_table:
|
|
||||||
hints.append(f"- The metric `{metric}` is defined over table `{metric_table}`.")
|
|
||||||
if metric_def and metric_def.filter:
|
|
||||||
hints.append(f" Apply this filter for the metric: {metric_def.filter}.")
|
|
||||||
if metric_def:
|
|
||||||
hints.append(f" Compute the metric as: {metric_def.sql} (use this expression verbatim).")
|
|
||||||
if dim_owners and metric_table and dim_owners[0] != metric_table:
|
|
||||||
path = recon.join_path(metric_table, dim_owners[0])
|
|
||||||
if path:
|
|
||||||
hints.append(f"- Required JOIN path: {' → '.join(path)}.")
|
|
||||||
|
|
||||||
hint_block = ("\n".join(hints) + "\n\n") if hints else ""
|
|
||||||
return (
|
|
||||||
f"{question}\n\n"
|
|
||||||
f"Slice the metric `{metric}` by `{dim}` and return the top rows by metric value.\n\n"
|
|
||||||
f"{hint_block}"
|
|
||||||
f"GROUP BY the dimension. ORDER BY the metric DESC. Limit to top 10."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_slice(question: str, metric: str, dim: str, reason: str) -> Slice:
|
async def execute_slice(question: str, metric: str, dim: str, reason: str) -> Slice:
|
||||||
"""Generate SQL for one slice, execute it, emit tool-call events
|
"""Compose SQL for one slice deterministically (metric + dim are already
|
||||||
around both, and return the Slice."""
|
chosen by `decide_next`), execute it, emit tool-call events around both,
|
||||||
slice_q = build_slice_question(question, metric, dim)
|
and return the Slice. Zero LLM calls — recon authors the SQL."""
|
||||||
|
recon = load_recon()
|
||||||
|
pick = Pick(kind="aggregate", metric=metric, group_by=[dim], limit=10)
|
||||||
|
|
||||||
await events.publish_current(events.tool_call_start("text_to_sql", input={"question": slice_q}))
|
await events.publish_current(events.tool_call_start(
|
||||||
t2s = text_to_sql(slice_q)
|
"compose_sql", input={"pick": pick.to_dict()},
|
||||||
|
))
|
||||||
|
composed = compose(pick, recon)
|
||||||
await events.publish_current(events.tool_call_end(
|
await events.publish_current(events.tool_call_end(
|
||||||
"text_to_sql", output={"sql": t2s.sql, "tables": t2s.used_tables},
|
"compose_sql", output={"sql": composed.sql, "tables": composed.used_tables},
|
||||||
))
|
))
|
||||||
|
|
||||||
await events.publish_current(events.tool_call_start(
|
await events.publish_current(events.tool_call_start(
|
||||||
"execute_sql", input={"sql": t2s.sql, "dimension": dim},
|
"execute_sql", input={"sql": composed.sql, "dimension": dim},
|
||||||
))
|
))
|
||||||
result = execute_sql(t2s.sql)
|
result = execute_sql(composed.sql)
|
||||||
await events.publish_current(events.tool_call_end(
|
await events.publish_current(events.tool_call_end(
|
||||||
"execute_sql",
|
"execute_sql",
|
||||||
output={"dimension": dim, "row_count": result.row_count,
|
output={"dimension": dim, "row_count": result.row_count,
|
||||||
@@ -125,7 +96,7 @@ async def execute_slice(question: str, metric: str, dim: str, reason: str) -> Sl
|
|||||||
|
|
||||||
return Slice(
|
return Slice(
|
||||||
dimension=dim,
|
dimension=dim,
|
||||||
sql=t2s.sql,
|
sql=composed.sql,
|
||||||
rows=result.as_dicts()[:20],
|
rows=result.as_dicts()[:20],
|
||||||
reason=reason,
|
reason=reason,
|
||||||
)
|
)
|
||||||
|
|||||||
26
api/composer/__init__.py
Normal file
26
api/composer/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Recon-driven SQL composer.
|
||||||
|
|
||||||
|
The LLM picks a *shape* (which metric, which dimensions, which filters);
|
||||||
|
the composer walks recon and emits the SQL deterministically. Column–table
|
||||||
|
binding, joins, and quoting are graph traversals, not LLM guesses.
|
||||||
|
|
||||||
|
See `def/schema-as-constraint.md` for the architectural argument.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from api.composer.types import (
|
||||||
|
ComposeResult,
|
||||||
|
Filter,
|
||||||
|
OrderBy,
|
||||||
|
Pick,
|
||||||
|
PickValidationError,
|
||||||
|
)
|
||||||
|
from api.composer.compose import compose
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"compose",
|
||||||
|
"ComposeResult",
|
||||||
|
"Filter",
|
||||||
|
"OrderBy",
|
||||||
|
"Pick",
|
||||||
|
"PickValidationError",
|
||||||
|
]
|
||||||
288
api/composer/compose.py
Normal file
288
api/composer/compose.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""compose(pick, recon) → ComposeResult.
|
||||||
|
|
||||||
|
Deterministic SQL emission from a typed Pick. The composer:
|
||||||
|
1. Resolves the metric → from_table + sql expression + default filter.
|
||||||
|
2. Resolves each group_by column → owning table (via recon.resolve_column).
|
||||||
|
3. Computes the join graph: join_path(metric.from_table, owner) per dim,
|
||||||
|
deduped, walked in declared order to pick the right relationship edge.
|
||||||
|
4. Renders SELECT / FROM + JOINs / WHERE / GROUP BY / ORDER BY / LIMIT
|
||||||
|
with every identifier quoted via sqlglot's `identify=True` round-trip
|
||||||
|
as a paranoid post-check.
|
||||||
|
|
||||||
|
No LLM call anywhere in this module. If the Pick can't be expressed against
|
||||||
|
recon, the composer raises `PickValidationError` — the caller surfaces it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlglot
|
||||||
|
|
||||||
|
from api.composer.dates import resolve_date_range
|
||||||
|
from api.composer.types import (
|
||||||
|
ComposeResult,
|
||||||
|
Filter,
|
||||||
|
OrderBy,
|
||||||
|
Pick,
|
||||||
|
PickValidationError,
|
||||||
|
)
|
||||||
|
from api.recon.types import Recon, Relationship
|
||||||
|
|
||||||
|
|
||||||
|
# ── Alias allocation ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _alias_for(table: str, used: dict[str, str]) -> str:
|
||||||
|
"""Pick a short alias for `table` that's unique within the query.
|
||||||
|
|
||||||
|
First letter, then first-two, then numbered. `used` maps alias→table so
|
||||||
|
we can grow on collision.
|
||||||
|
"""
|
||||||
|
for base in (table[:1], table[:2], table[:3], table):
|
||||||
|
if base and base not in used:
|
||||||
|
return base
|
||||||
|
i = 1
|
||||||
|
while True:
|
||||||
|
cand = f"{table[:1]}{i}"
|
||||||
|
if cand not in used:
|
||||||
|
return cand
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Join graph ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _find_edge(recon: Recon, a: str, b: str) -> Relationship:
|
||||||
|
"""Return the relationship that joins tables a and b (either direction).
|
||||||
|
Raises if no declared FK connects them."""
|
||||||
|
for r in recon.relationships:
|
||||||
|
if (r.from_table == a and r.to_table == b) or (r.from_table == b and r.to_table == a):
|
||||||
|
return r
|
||||||
|
raise PickValidationError(
|
||||||
|
f"no declared relationship between {a!r} and {b!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_joins(recon: Recon, base: str, targets: list[str],
|
||||||
|
aliases: dict[str, str]) -> list[str]:
|
||||||
|
"""Compute the union of join paths from `base` to each target table,
|
||||||
|
emit JOIN clauses in walk order. Allocates aliases for any intermediate
|
||||||
|
table that wasn't already in `aliases`."""
|
||||||
|
visited: set[str] = {base}
|
||||||
|
clauses: list[str] = []
|
||||||
|
for target in targets:
|
||||||
|
path = recon.join_path(base, target)
|
||||||
|
if path is None:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"no join path from {base!r} to {target!r} in recon"
|
||||||
|
)
|
||||||
|
for i in range(1, len(path)):
|
||||||
|
prev, cur = path[i - 1], path[i]
|
||||||
|
if cur in visited:
|
||||||
|
continue
|
||||||
|
edge = _find_edge(recon, prev, cur)
|
||||||
|
# Edge direction tells us which side has which column.
|
||||||
|
if edge.from_table == prev:
|
||||||
|
left_t, left_c, right_t, right_c = edge.from_table, edge.from_column, edge.to_table, edge.to_column
|
||||||
|
else:
|
||||||
|
left_t, left_c, right_t, right_c = edge.to_table, edge.to_column, edge.from_table, edge.from_column
|
||||||
|
# Ensure both sides have aliases.
|
||||||
|
for t in (left_t, right_t):
|
||||||
|
if t not in aliases:
|
||||||
|
aliases[t] = _alias_for(t, {a: t for t, a in aliases.items()})
|
||||||
|
la, ra = aliases[left_t], aliases[right_t]
|
||||||
|
clauses.append(
|
||||||
|
f'JOIN "{cur}" AS "{aliases[cur]}" '
|
||||||
|
f'ON "{la}"."{left_c}" = "{ra}"."{right_c}"'
|
||||||
|
)
|
||||||
|
visited.add(cur)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
|
||||||
|
# ── Filter rendering ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _render_literal(v) -> str:
|
||||||
|
"""Render a Python value as a SQL literal. Strings are single-quoted with
|
||||||
|
embedded quotes escaped. Numbers pass through."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return "'" + v.replace("'", "''") + "'"
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return "TRUE" if v else "FALSE"
|
||||||
|
if v is None:
|
||||||
|
return "NULL"
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_filter(f: Filter, recon: Recon, aliases: dict[str, str]) -> str:
|
||||||
|
"""Render one Filter to a SQL predicate. Resolves the column's owning
|
||||||
|
table and aliases it (allocating an alias if needed — and a join later
|
||||||
|
if that introduces a new table)."""
|
||||||
|
table, col = recon.resolve_column(f.column)
|
||||||
|
if table not in aliases:
|
||||||
|
aliases[table] = _alias_for(table, {a: t for t, a in aliases.items()})
|
||||||
|
qualified = f'"{aliases[table]}"."{col.name}"'
|
||||||
|
|
||||||
|
kind = f.kind()
|
||||||
|
if kind == "equals":
|
||||||
|
return f"{qualified} = {_render_literal(f.equals)}"
|
||||||
|
if kind == "in_values":
|
||||||
|
if not f.in_values:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"filter on {f.column!r} has empty in_values"
|
||||||
|
)
|
||||||
|
rendered = ", ".join(_render_literal(v) for v in f.in_values)
|
||||||
|
return f"{qualified} IN ({rendered})"
|
||||||
|
if kind == "between":
|
||||||
|
lo, hi = f.between
|
||||||
|
return f"{qualified} BETWEEN {_render_literal(lo)} AND {_render_literal(hi)}"
|
||||||
|
if kind == "date_range":
|
||||||
|
return resolve_date_range(f.date_range, col, qualified)
|
||||||
|
raise PickValidationError(f"unknown filter kind {kind!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── ORDER BY rendering ──────────────────────────────────────
|
||||||
|
|
||||||
|
def _render_order_by(ob: OrderBy, metric_name: str, dim_select: dict[str, str]) -> str:
|
||||||
|
"""`metric_name` is the alias of the metric SELECT expression;
|
||||||
|
`dim_select` maps dimension ref (column ref) → output alias."""
|
||||||
|
if ob.by == "metric":
|
||||||
|
return f'"{metric_name}" {ob.direction.upper()}'
|
||||||
|
# by == "dimension"
|
||||||
|
if ob.dimension not in dim_select:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"order_by.dimension={ob.dimension!r} is not in group_by ({list(dim_select)})"
|
||||||
|
)
|
||||||
|
return f'"{dim_select[ob.dimension]}" {ob.direction.upper()}'
|
||||||
|
|
||||||
|
|
||||||
|
# ── Metric expression rewriting ─────────────────────────────
|
||||||
|
|
||||||
|
def _qualify_metric_sql(sql_fragment: str, table_alias: str) -> str:
|
||||||
|
"""Rewrite bare column refs inside the metric's SQL expression so they
|
||||||
|
point at the metric table's alias.
|
||||||
|
|
||||||
|
Metric.sql in metrics.yaml is written as an unaliased expression (e.g.
|
||||||
|
`AVG(CASE WHEN status='B' ... END)`). We need it qualified to the
|
||||||
|
metric table's alias so the composer can introduce other tables via
|
||||||
|
joins without ambiguity. Uses sqlglot to parse and rewrite identifier
|
||||||
|
refs that don't already have a table prefix.
|
||||||
|
"""
|
||||||
|
tree = sqlglot.parse_one(sql_fragment, dialect="postgres")
|
||||||
|
for col in tree.find_all(sqlglot.exp.Column):
|
||||||
|
if col.table:
|
||||||
|
continue
|
||||||
|
# Inject the alias as the table qualifier.
|
||||||
|
col.set("table", sqlglot.exp.Identifier(this=table_alias, quoted=True))
|
||||||
|
return tree.sql(dialect="postgres", identify=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _qualify_metric_filter(filter_fragment: str, table_alias: str) -> str:
|
||||||
|
"""Same as _qualify_metric_sql but for the metric's optional WHERE filter.
|
||||||
|
metric.filter is written as a bare boolean expression."""
|
||||||
|
return _qualify_metric_sql(filter_fragment, table_alias)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Compose ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def compose(pick: Pick, recon: Recon) -> ComposeResult:
|
||||||
|
"""Render `pick` to SQL against `recon`. Raises PickValidationError on
|
||||||
|
any reference the recon can't resolve."""
|
||||||
|
|
||||||
|
# 1. Metric.
|
||||||
|
if pick.metric not in recon.metrics:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"metric {pick.metric!r} not in recon (known: {sorted(recon.metrics)})"
|
||||||
|
)
|
||||||
|
metric = recon.metrics[pick.metric]
|
||||||
|
if metric.from_table not in recon.tables:
|
||||||
|
raise PickValidationError(
|
||||||
|
f"metric {pick.metric!r} declares from_table={metric.from_table!r} "
|
||||||
|
f"but no such table in recon"
|
||||||
|
)
|
||||||
|
|
||||||
|
aliases: dict[str, str] = {}
|
||||||
|
aliases[metric.from_table] = _alias_for(metric.from_table, {})
|
||||||
|
base_alias = aliases[metric.from_table]
|
||||||
|
|
||||||
|
# 2. Resolve every group_by column to (table, Column) and collect target
|
||||||
|
# tables that aren't the metric's table — those need joins.
|
||||||
|
group_bindings: list[tuple[str, str, str, str]] = [] # (ref, table, col_name, output_alias)
|
||||||
|
extra_tables: list[str] = []
|
||||||
|
dim_select_alias: dict[str, str] = {}
|
||||||
|
for ref in pick.group_by:
|
||||||
|
table, col = recon.resolve_column(ref)
|
||||||
|
if table not in aliases:
|
||||||
|
if table != metric.from_table:
|
||||||
|
extra_tables.append(table)
|
||||||
|
aliases[table] = _alias_for(table, {a: t for t, a in aliases.items()})
|
||||||
|
out_alias = col.name if ref == col.name else ref.replace(".", "_")
|
||||||
|
group_bindings.append((ref, table, col.name, out_alias))
|
||||||
|
dim_select_alias[ref] = out_alias
|
||||||
|
|
||||||
|
# 3. Resolve filter columns first (they may also introduce new tables we
|
||||||
|
# need to join). Rendered separately so we can keep their predicates
|
||||||
|
# in WHERE.
|
||||||
|
filter_clauses: list[str] = []
|
||||||
|
for f in pick.where:
|
||||||
|
table, _ = recon.resolve_column(f.column)
|
||||||
|
if table not in aliases:
|
||||||
|
if table != metric.from_table:
|
||||||
|
extra_tables.append(table)
|
||||||
|
aliases[table] = _alias_for(table, {a: t for t, a in aliases.items()})
|
||||||
|
filter_clauses.append(_render_filter(f, recon, aliases))
|
||||||
|
|
||||||
|
# 4. Build JOINs for the union of extra tables.
|
||||||
|
join_clauses = _build_joins(recon, metric.from_table, extra_tables, aliases)
|
||||||
|
|
||||||
|
# 5. SELECT list: dimension columns first (in group_by order), then the
|
||||||
|
# metric expression.
|
||||||
|
select_parts: list[str] = []
|
||||||
|
for ref, table, col_name, out_alias in group_bindings:
|
||||||
|
select_parts.append(f'"{aliases[table]}"."{col_name}" AS "{out_alias}"')
|
||||||
|
metric_expr = _qualify_metric_sql(metric.sql, base_alias)
|
||||||
|
select_parts.append(f'{metric_expr} AS "{metric.name}"')
|
||||||
|
|
||||||
|
# 6. WHERE: metric's default filter (if any) ANDed with the Pick's filters.
|
||||||
|
where_parts: list[str] = []
|
||||||
|
if metric.filter:
|
||||||
|
where_parts.append(_qualify_metric_filter(metric.filter, base_alias))
|
||||||
|
where_parts.extend(filter_clauses)
|
||||||
|
|
||||||
|
# 7. GROUP BY (just the dim output aliases) + ORDER BY + LIMIT.
|
||||||
|
group_by_clause = ""
|
||||||
|
if group_bindings:
|
||||||
|
group_by_clause = "GROUP BY " + ", ".join(
|
||||||
|
f'"{out_alias}"' for _, _, _, out_alias in group_bindings
|
||||||
|
)
|
||||||
|
|
||||||
|
order_by_clause = ""
|
||||||
|
if pick.order_by is not None:
|
||||||
|
order_by_clause = "ORDER BY " + _render_order_by(
|
||||||
|
pick.order_by, metric.name, dim_select_alias
|
||||||
|
)
|
||||||
|
elif group_bindings:
|
||||||
|
# Sensible default: largest metric first.
|
||||||
|
order_by_clause = f'ORDER BY "{metric.name}" DESC'
|
||||||
|
|
||||||
|
limit_clause = f"LIMIT {pick.limit}" if pick.limit is not None else ""
|
||||||
|
|
||||||
|
# 8. Assemble.
|
||||||
|
sql_parts = [
|
||||||
|
"SELECT " + ", ".join(select_parts),
|
||||||
|
f'FROM "{metric.from_table}" AS "{base_alias}"',
|
||||||
|
]
|
||||||
|
sql_parts.extend(join_clauses)
|
||||||
|
if where_parts:
|
||||||
|
sql_parts.append("WHERE " + " AND ".join(where_parts))
|
||||||
|
if group_by_clause:
|
||||||
|
sql_parts.append(group_by_clause)
|
||||||
|
if order_by_clause:
|
||||||
|
sql_parts.append(order_by_clause)
|
||||||
|
if limit_clause:
|
||||||
|
sql_parts.append(limit_clause)
|
||||||
|
raw = "\n".join(sql_parts)
|
||||||
|
|
||||||
|
# 9. Paranoid re-parse: catches composer bugs by round-tripping through
|
||||||
|
# sqlglot. identify=True keeps every identifier quoted.
|
||||||
|
parsed = sqlglot.parse_one(raw, dialect="postgres")
|
||||||
|
sql = parsed.sql(dialect="postgres", identify=True)
|
||||||
|
|
||||||
|
used = sorted(aliases)
|
||||||
|
return ComposeResult(sql=sql, used_tables=used)
|
||||||
73
api/composer/dates.py
Normal file
73
api/composer/dates.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Date-range resolver for typed Filters.
|
||||||
|
|
||||||
|
Given a `Filter` with `date_range="YYYY"` or `date_range="YYYY-Q[1-4]"` and
|
||||||
|
the target column, render a SQL fragment that evaluates correctly under the
|
||||||
|
column's actual storage type. Dispatched in two layers:
|
||||||
|
|
||||||
|
1. `Column.semantic_type` if set (extension point — e.g. `"date_yymmdd"` for
|
||||||
|
datasets that ship YYMMDD-encoded integers).
|
||||||
|
2. Otherwise `Column.sql_type` (`DATE`, `TIMESTAMP`, …) — the default path
|
||||||
|
for warehouses that store dates as real date columns.
|
||||||
|
|
||||||
|
Inclusive on both ends. Bounds quoted with single quotes (Postgres parses
|
||||||
|
date literals from strings).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from api.composer.types import PickValidationError
|
||||||
|
from api.recon.types import Column
|
||||||
|
|
||||||
|
_YEAR_ONLY = re.compile(r"^\d{4}$")
|
||||||
|
_YEAR_QUARTER = re.compile(r"^(\d{4})-Q([1-4])$")
|
||||||
|
|
||||||
|
|
||||||
|
_QUARTER_BOUNDS = {
|
||||||
|
1: ("01-01", "03-31"),
|
||||||
|
2: ("04-01", "06-30"),
|
||||||
|
3: ("07-01", "09-30"),
|
||||||
|
4: ("10-01", "12-31"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bounds(spec: str) -> tuple[str, str]:
|
||||||
|
"""Parse a date_range spec to (lo, hi) calendar dates as ISO strings."""
|
||||||
|
if _YEAR_ONLY.match(spec):
|
||||||
|
year = spec
|
||||||
|
return f"{year}-01-01", f"{year}-12-31"
|
||||||
|
m = _YEAR_QUARTER.match(spec)
|
||||||
|
if m:
|
||||||
|
year, q = m.group(1), int(m.group(2))
|
||||||
|
lo_mmdd, hi_mmdd = _QUARTER_BOUNDS[q]
|
||||||
|
return f"{year}-{lo_mmdd}", f"{year}-{hi_mmdd}"
|
||||||
|
raise PickValidationError(
|
||||||
|
f"unsupported date_range {spec!r}; expected 'YYYY' or 'YYYY-Q1..Q4'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_date_range(spec: str, column: Column, qualified_col: str) -> str:
|
||||||
|
"""Render the WHERE fragment for `<qualified_col> BETWEEN ...`.
|
||||||
|
|
||||||
|
`qualified_col` is the already-quoted reference the composer wants in
|
||||||
|
the SQL (e.g. `"l"."date"`). Returns just the predicate, no `WHERE`.
|
||||||
|
"""
|
||||||
|
lo, hi = _bounds(spec)
|
||||||
|
semantic = (column.semantic_type or "").lower()
|
||||||
|
|
||||||
|
if semantic == "date_yymmdd":
|
||||||
|
# Column stores YYMMDD as integer (some BIRD datasets ship this way).
|
||||||
|
return (
|
||||||
|
f"TO_DATE(LPAD({qualified_col}::text, 6, '0'), 'YYMMDD') "
|
||||||
|
f"BETWEEN '{lo}' AND '{hi}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
sql_type = column.sql_type.upper()
|
||||||
|
if sql_type.startswith("DATE") or sql_type.startswith("TIMESTAMP"):
|
||||||
|
return f"{qualified_col} BETWEEN '{lo}' AND '{hi}'"
|
||||||
|
|
||||||
|
raise PickValidationError(
|
||||||
|
f"date_range on column with sql_type={column.sql_type!r} and "
|
||||||
|
f"semantic_type={column.semantic_type!r} is not supported"
|
||||||
|
)
|
||||||
189
api/composer/types.py
Normal file
189
api/composer/types.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""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: `<col> = <value>`
|
||||||
|
- column + in_values: `<col> IN (<values>)`
|
||||||
|
- column + between: `<col> BETWEEN <a> AND <b>` (inclusive, both ends).
|
||||||
|
- column + date_range: `<col> BETWEEN '<yyyy>-01-01' AND '<yyyy>-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]
|
||||||
@@ -27,7 +27,7 @@ tables:
|
|||||||
columns:
|
columns:
|
||||||
account.district_id: Geographic district the account belongs to (joins to district).
|
account.district_id: Geographic district the account belongs to (joins to district).
|
||||||
account.frequency: Statement issuance frequency. "POPLATEK MESICNE" = monthly, "POPLATEK TYDNE" = weekly, "POPLATEK PO OBRATU" = on transaction.
|
account.frequency: Statement issuance frequency. "POPLATEK MESICNE" = monthly, "POPLATEK TYDNE" = weekly, "POPLATEK PO OBRATU" = on transaction.
|
||||||
account.date: Date the account was opened (YYMMDD as integer).
|
account.date: Date the account was opened.
|
||||||
|
|
||||||
client.gender: '"M" or "F". Derived from birth_number.'
|
client.gender: '"M" or "F". Derived from birth_number.'
|
||||||
client.birth_date: Date of birth, normalised. Original birth_number encoded gender by adding 50 to the month field for women.
|
client.birth_date: Date of birth, normalised. Original birth_number encoded gender by adding 50 to the month field for women.
|
||||||
@@ -54,7 +54,7 @@ columns:
|
|||||||
district.A16: Number of crimes committed in 1996.
|
district.A16: Number of crimes committed in 1996.
|
||||||
|
|
||||||
loan.account_id: The account the loan was granted to.
|
loan.account_id: The account the loan was granted to.
|
||||||
loan.date: Loan grant date (YYMMDD as integer).
|
loan.date: Date the loan was granted.
|
||||||
loan.amount: Total amount of the loan (CZK).
|
loan.amount: Total amount of the loan (CZK).
|
||||||
loan.duration: Loan duration in months.
|
loan.duration: Loan duration in months.
|
||||||
loan.payments: Monthly payment (CZK).
|
loan.payments: Monthly payment (CZK).
|
||||||
@@ -65,7 +65,7 @@ columns:
|
|||||||
card.issued: Date the card was issued.
|
card.issued: Date the card was issued.
|
||||||
|
|
||||||
trans.account_id: The account the transaction belongs to.
|
trans.account_id: The account the transaction belongs to.
|
||||||
trans.date: Transaction date (YYMMDD as integer).
|
trans.date: Transaction date.
|
||||||
trans.type: '"PRIJEM" (credit) or "VYDAJ" (debit).'
|
trans.type: '"PRIJEM" (credit) or "VYDAJ" (debit).'
|
||||||
trans.operation: Mode of operation, e.g. "VKLAD" (cash credit), "VYBER" (cash withdrawal), "PREVOD Z UCTU" (transfer from another bank).
|
trans.operation: Mode of operation, e.g. "VKLAD" (cash credit), "VYBER" (cash withdrawal), "PREVOD Z UCTU" (transfer from another bank).
|
||||||
trans.amount: Amount (CZK).
|
trans.amount: Amount (CZK).
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ Provider selection is via `settings.llm_provider`. Supported:
|
|||||||
Every call opens a Langfuse `generation` span automatically, tagged with the
|
Every call opens a Langfuse `generation` span automatically, tagged with the
|
||||||
model name and populated with token usage from the provider's response.
|
model name and populated with token usage from the provider's response.
|
||||||
Spans nest under whatever observation the caller has open, so the trace
|
Spans nest under whatever observation the caller has open, so the trace
|
||||||
hierarchy shows `text_to_sql > llm.chat` with usage on the inner node.
|
hierarchy shows the wrapping step (e.g. `pick_for_question`) with the
|
||||||
|
`llm.chat` generation as a child carrying the usage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -119,7 +120,7 @@ def _active_model() -> str:
|
|||||||
def chat(*, system: str, user: str, max_tokens: int = 1024, span_name: str = "llm.chat") -> str:
|
def chat(*, system: str, user: str, max_tokens: int = 1024, span_name: str = "llm.chat") -> str:
|
||||||
"""Run a single chat completion. Opens a Langfuse generation span around
|
"""Run a single chat completion. Opens a Langfuse generation span around
|
||||||
the call with the model name + token usage. `span_name` is the label
|
the call with the model name + token usage. `span_name` is the label
|
||||||
shown in Langfuse — pass something descriptive (e.g. "text_to_sql.gen")
|
shown in Langfuse — pass something descriptive (e.g. "direct_answer.pick")
|
||||||
so traces are scannable."""
|
so traces are scannable."""
|
||||||
provider = get_settings().llm_provider.lower()
|
provider = get_settings().llm_provider.lower()
|
||||||
impl = _PROVIDERS.get(provider)
|
impl = _PROVIDERS.get(provider)
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ def plan(question: str) -> Plan:
|
|||||||
user = render(
|
user = render(
|
||||||
"planner.user",
|
"planner.user",
|
||||||
question=question,
|
question=question,
|
||||||
table_names=", ".join(schema.table_names()),
|
tables_block=schema.render_tables_brief(),
|
||||||
metrics_block=schema.render_metrics(),
|
metrics_block=schema.render_metrics_brief(),
|
||||||
catalog=json.dumps(catalog(), indent=2),
|
catalog=json.dumps(catalog(), indent=2),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
35
api/prompts/pick.system.txt
Normal file
35
api/prompts/pick.system.txt
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
You translate a business question into a typed Pick that an SQL composer
|
||||||
|
will execute deterministically. You do NOT write SQL — you choose from
|
||||||
|
finite, known sets and the composer builds the query.
|
||||||
|
|
||||||
|
OUTPUT FORMAT — JSON, no prose, no markdown fences:
|
||||||
|
|
||||||
|
{
|
||||||
|
"kind": "aggregate",
|
||||||
|
"metric": "<name from the candidate metrics list>",
|
||||||
|
"group_by": ["<column ref>", ...],
|
||||||
|
"where": [<filter>, ...],
|
||||||
|
"order_by": {"by": "metric"|"dimension", "direction": "asc"|"desc", "dimension": "<col ref>"?},
|
||||||
|
"limit": <integer>
|
||||||
|
}
|
||||||
|
|
||||||
|
FILTER SHAPES — exactly one value field per filter:
|
||||||
|
|
||||||
|
{"column": "<col ref>", "equals": "<value>"}
|
||||||
|
{"column": "<col ref>", "in_values": ["<v1>", "<v2>", ...]}
|
||||||
|
{"column": "<col ref>", "between": [<lo>, <hi>]}
|
||||||
|
{"column": "<col ref>", "date_range": "<YYYY|YYYY-Q1..Q4>"}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
|
||||||
|
- `metric` MUST be one of the candidate metric names. Don't invent.
|
||||||
|
- Every column ref in `group_by` and in `where[*].column` MUST appear in
|
||||||
|
the candidate columns list. If the same name lives on multiple tables,
|
||||||
|
it will be listed as `table.column` — pick the qualified form.
|
||||||
|
- Don't include `group_by` or `where` if not needed (empty list / omit).
|
||||||
|
- Only ONE filter per column. Don't repeat.
|
||||||
|
- If you cannot express the question using the candidate metrics +
|
||||||
|
columns + filter shapes, return:
|
||||||
|
{"error": "<one short sentence on what's missing>"}
|
||||||
|
- No SQL fragments, no raw expressions, no CASE, no subqueries.
|
||||||
|
- Output ONLY the JSON object. No explanation, no markdown.
|
||||||
10
api/prompts/pick.user.txt
Normal file
10
api/prompts/pick.user.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
QUESTION:
|
||||||
|
{question}
|
||||||
|
|
||||||
|
CANDIDATE METRICS (pick one for `metric`):
|
||||||
|
{metrics_block}
|
||||||
|
|
||||||
|
CANDIDATE COLUMNS (pick from these for `group_by` and `where[*].column`):
|
||||||
|
{columns_block}
|
||||||
|
|
||||||
|
Return the Pick as JSON (or the error object). No other text.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
Question: {question}
|
Question: {question}
|
||||||
|
|
||||||
Warehouse tables: {table_names}
|
Warehouse tables:
|
||||||
|
{tables_block}
|
||||||
|
|
||||||
Metric catalog:
|
Metric catalog:
|
||||||
{metrics_block}
|
{metrics_block}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
Task: translate the analyst's question into a single Postgres SELECT (or WITH … SELECT) against the BIRD `financial` schema (PKDD'99 Czech bank).
|
|
||||||
|
|
||||||
Output: exactly one fenced ```sql block, nothing else — no prose, no second block, no trailing semicolon required.
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
- search_path is set to `financial`; don't qualify table names with the schema.
|
|
||||||
- **Postgres folds unquoted identifiers to lowercase.** Always double-quote any column or table name that contains an uppercase letter — including all the `district` columns: `"A2"`, `"A3"`, `"A4"`, `"A5"`, `"A6"`, `"A7"`, `"A8"`, `"A9"`, `"A10"`, `"A11"`, `"A12"`, `"A13"`, `"A14"`, `"A15"`, `"A16"`. Mixing quoted and unquoted forms of the same column (`"A3"` and `A13`) will fail at runtime — be consistent.
|
|
||||||
- Quote identifiers that collide with SQL keywords (notably `"order"`).
|
|
||||||
- Dates in the source are YYMMDD integers (e.g. 950315 → 1995-03-15). Convert with `TO_DATE(LPAD(date::text, 6, '0'), 'YYMMDD')` whenever you need a real date for filtering, comparison, or display.
|
|
||||||
- Loan `status` values: 'A' finished OK, 'B' finished defaulted, 'C' running OK, 'D' running in debt.
|
|
||||||
- Transaction `type` values: 'PRIJEM' credit, 'VYDAJ' debit.
|
|
||||||
- Read-only: never emit DDL or DML.
|
|
||||||
|
|
||||||
When the question maps to a metric in the catalog you're given, copy the metric's SQL fragment verbatim — don't paraphrase it.
|
|
||||||
|
|
||||||
If the question is ambiguous, pick the most defensible interpretation and proceed; don't ask for clarification.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
Question:
|
|
||||||
{question}
|
|
||||||
|
|
||||||
Schema (only the tables that look relevant; you may use any of these):
|
|
||||||
{schema_block}
|
|
||||||
|
|
||||||
Metric catalog (copy the SQL verbatim when the question maps to one):
|
|
||||||
{metrics_block}
|
|
||||||
|
|
||||||
Return only the SQL in a ```sql fenced block.{retry_hint}
|
|
||||||
@@ -80,16 +80,25 @@ def _read_yaml(path: Path) -> dict[str, Any]:
|
|||||||
return yaml.safe_load(path.read_text()) or {}
|
return yaml.safe_load(path.read_text()) or {}
|
||||||
|
|
||||||
|
|
||||||
def _parse_column_descs(raw: dict[str, Any]) -> dict[str, str]:
|
def _parse_column_specs(raw: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||||
"""Accept either the flat form `{table.col: desc}` or the nested form
|
"""Accept either the flat form `{table.col: <spec>}` or the nested form
|
||||||
`{table: {col: desc}}`. Returns flat form."""
|
`{table: {col: <spec>}}`. Each `<spec>` is either a bare description
|
||||||
out: dict[str, str] = {}
|
string OR a dict `{desc?: str, type?: str}` where `type` is a domain-
|
||||||
for k, v in raw.items():
|
level semantic type (e.g. "date_yymmdd"). Returns flat form: each value
|
||||||
|
is normalised to `{desc, type}`."""
|
||||||
|
def norm(v: Any) -> dict[str, Any]:
|
||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
for col, desc in v.items():
|
return {"desc": v.get("desc") or v.get("description"), "type": v.get("type")}
|
||||||
out[f"{k}.{col}"] = desc
|
return {"desc": v, "type": None}
|
||||||
|
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
for k, v in raw.items():
|
||||||
|
if isinstance(v, dict) and not ("desc" in v or "description" in v or "type" in v):
|
||||||
|
# Nested: {table: {col: spec}}.
|
||||||
|
for col, sub in v.items():
|
||||||
|
out[f"{k}.{col}"] = norm(sub)
|
||||||
else:
|
else:
|
||||||
out[k] = v
|
out[k] = norm(v)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -101,19 +110,20 @@ def merge_into_recon(dataset: str, extracted: dict[str, Any]) -> Recon:
|
|||||||
metrics_yaml = _read_yaml(DATASETS_DIR / dataset / "metrics.yaml")
|
metrics_yaml = _read_yaml(DATASETS_DIR / dataset / "metrics.yaml")
|
||||||
|
|
||||||
table_descs: dict[str, str] = aug.get("tables", {}) or {}
|
table_descs: dict[str, str] = aug.get("tables", {}) or {}
|
||||||
col_descs = _parse_column_descs(aug.get("columns", {}) or {})
|
col_specs = _parse_column_specs(aug.get("columns", {}) or {})
|
||||||
|
|
||||||
tables: dict[str, Table] = {}
|
tables: dict[str, Table] = {}
|
||||||
for tname, t_data in extracted["tables"].items():
|
for tname, t_data in extracted["tables"].items():
|
||||||
cols = [
|
cols = []
|
||||||
Column(
|
for c in t_data["columns"]:
|
||||||
|
spec = col_specs.get(f"{tname}.{c['name']}", {})
|
||||||
|
cols.append(Column(
|
||||||
name=c["name"],
|
name=c["name"],
|
||||||
sql_type=c["sql_type"],
|
sql_type=c["sql_type"],
|
||||||
nullable=c["nullable"],
|
nullable=c["nullable"],
|
||||||
description=col_descs.get(f"{tname}.{c['name']}"),
|
description=spec.get("desc"),
|
||||||
)
|
semantic_type=spec.get("type"),
|
||||||
for c in t_data["columns"]
|
))
|
||||||
]
|
|
||||||
tables[tname] = Table(
|
tables[tname] = Table(
|
||||||
name=tname,
|
name=tname,
|
||||||
description=table_descs.get(tname),
|
description=table_descs.get(tname),
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class Column:
|
|||||||
sql_type: str
|
sql_type: str
|
||||||
nullable: bool
|
nullable: bool
|
||||||
description: str | None = None
|
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
|
@classmethod
|
||||||
def from_inspector(cls, info: dict[str, Any], description: str | None = None) -> "Column":
|
def from_inspector(cls, info: dict[str, Any], description: str | None = None) -> "Column":
|
||||||
@@ -40,12 +44,16 @@ class Column:
|
|||||||
"sql_type": self.sql_type,
|
"sql_type": self.sql_type,
|
||||||
"nullable": self.nullable,
|
"nullable": self.nullable,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
|
"semantic_type": self.semantic_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict[str, Any]) -> "Column":
|
def from_dict(cls, d: dict[str, Any]) -> "Column":
|
||||||
return cls(name=d["name"], sql_type=d["sql_type"],
|
return cls(
|
||||||
nullable=d["nullable"], description=d.get("description"))
|
name=d["name"], sql_type=d["sql_type"],
|
||||||
|
nullable=d["nullable"], description=d.get("description"),
|
||||||
|
semantic_type=d.get("semantic_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -144,7 +152,7 @@ class Recon:
|
|||||||
column_to_tables: dict[str, list[str]] = field(default_factory=dict)
|
column_to_tables: dict[str, list[str]] = field(default_factory=dict)
|
||||||
relationships: list[Relationship] = field(default_factory=list)
|
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]:
|
def table_names(self) -> list[str]:
|
||||||
return sorted(self.tables)
|
return sorted(self.tables)
|
||||||
@@ -156,6 +164,37 @@ class Recon:
|
|||||||
"""Tables that have a column with this name (case-sensitive)."""
|
"""Tables that have a column with this name (case-sensitive)."""
|
||||||
return self.column_to_tables.get(column, [])
|
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:
|
def join_path(self, src: str, dst: str) -> list[str] | None:
|
||||||
"""Shortest sequence of tables from src to dst via declared relationships.
|
"""Shortest sequence of tables from src to dst via declared relationships.
|
||||||
Returns None if no path exists. The path includes both endpoints."""
|
Returns None if no path exists. The path includes both endpoints."""
|
||||||
@@ -220,6 +259,29 @@ class Recon:
|
|||||||
)
|
)
|
||||||
return "\n".join(lines)
|
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:
|
def render_relationships(self) -> str:
|
||||||
if not self.relationships:
|
if not self.relationships:
|
||||||
return "(no relationships declared)"
|
return "(no relationships declared)"
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
"""LLM-driven NL → SQL with sqlglot validation and one retry."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
import sqlglot
|
|
||||||
|
|
||||||
from api import langfuse_client as lf
|
|
||||||
from api.llm import chat
|
|
||||||
from api.prompts import load, render
|
|
||||||
from api.recon import load_recon
|
|
||||||
from api.recon.validate import ReconValidationError, validate_sql
|
|
||||||
from api.tools.types import T2SResult
|
|
||||||
|
|
||||||
logger = logging.getLogger("nvi.tools.text_to_sql")
|
|
||||||
|
|
||||||
|
|
||||||
def text_to_sql(question: str, *, hint_tables: list[str] | None = None) -> T2SResult:
|
|
||||||
recon = load_recon()
|
|
||||||
tables = hint_tables or recon.table_names()
|
|
||||||
system = load("text_to_sql.system")
|
|
||||||
|
|
||||||
def _user(retry_hint: str = "") -> str:
|
|
||||||
return render(
|
|
||||||
"text_to_sql.user",
|
|
||||||
question=question,
|
|
||||||
schema_block=recon.render_tables(tables),
|
|
||||||
metrics_block=recon.render_metrics(),
|
|
||||||
retry_hint=retry_hint,
|
|
||||||
)
|
|
||||||
|
|
||||||
with lf.span(
|
|
||||||
"text_to_sql",
|
|
||||||
as_type="generation",
|
|
||||||
input={"question": question, "hint_tables": hint_tables},
|
|
||||||
) as span:
|
|
||||||
raw = _extract_sql(chat(system=system, user=_user()))
|
|
||||||
sql = _normalize(raw) # raises on parse error — fail fast
|
|
||||||
validate_sql(sql, recon) # raises ReconValidationError — fail fast
|
|
||||||
|
|
||||||
result = T2SResult(sql=sql, used_tables=_extract_tables(sql))
|
|
||||||
span.update(output={"sql": sql, "used_tables": result.used_tables})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_sql(text: str) -> str:
|
|
||||||
m = re.search(r"```sql\s*(.*?)```", text, re.DOTALL | re.IGNORECASE)
|
|
||||||
if m:
|
|
||||||
return m.group(1).strip().rstrip(";").strip()
|
|
||||||
return text.strip().rstrip(";").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize(sql: str) -> str:
|
|
||||||
"""Parse the LLM's SQL, then re-render with every identifier quoted.
|
|
||||||
|
|
||||||
Postgres folds unquoted identifiers to lowercase, so `d.A13` becomes
|
|
||||||
`d.a13` and breaks against case-preserving columns like district."A13".
|
|
||||||
Re-rendering with `identify=True` puts double quotes around every
|
|
||||||
identifier — case is preserved, and Postgres treats a quoted lowercase
|
|
||||||
identifier the same as the unquoted form, so this is safe in both
|
|
||||||
directions.
|
|
||||||
"""
|
|
||||||
parsed = sqlglot.parse_one(sql, dialect="postgres")
|
|
||||||
if parsed is None:
|
|
||||||
raise ValueError("sqlglot returned no parse tree")
|
|
||||||
return parsed.sql(dialect="postgres", identify=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_tables(sql: str) -> list[str]:
|
|
||||||
try:
|
|
||||||
parsed = sqlglot.parse_one(sql, dialect="postgres")
|
|
||||||
return sorted({t.name for t in parsed.find_all(sqlglot.exp.Table)})
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
229
tests/test_composer_compose.py
Normal file
229
tests/test_composer_compose.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""Composer SQL emission — golden-string tests against a representative recon.
|
||||||
|
|
||||||
|
The fixture mirrors enough of the financial dataset to exercise: metric
|
||||||
|
with default filter, group_by with single hop and multi-hop joins, typed
|
||||||
|
date_range filter on a real DATE column, and ORDER BY by both metric and
|
||||||
|
dimension. Adjust goldens cautiously — if a string changes, confirm the
|
||||||
|
new SQL still semantically matches before updating the test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from api.composer import compose
|
||||||
|
from api.composer.types import Filter, OrderBy, Pick, PickValidationError
|
||||||
|
from api.recon.types import Column, Metric, Recon, Relationship, Table
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_recon() -> Recon:
|
||||||
|
cols_account = [
|
||||||
|
Column("account_id", "BIGINT", False),
|
||||||
|
Column("district_id", "BIGINT", True),
|
||||||
|
]
|
||||||
|
cols_district = [
|
||||||
|
Column("district_id", "BIGINT", False),
|
||||||
|
Column("A2", "TEXT", True),
|
||||||
|
Column("A3", "TEXT", True),
|
||||||
|
]
|
||||||
|
cols_loan = [
|
||||||
|
Column("loan_id", "BIGINT", False),
|
||||||
|
Column("account_id", "BIGINT", True),
|
||||||
|
Column("amount", "BIGINT", True),
|
||||||
|
Column("status", "TEXT", True),
|
||||||
|
Column("date", "DATE", True),
|
||||||
|
]
|
||||||
|
rels = [
|
||||||
|
Relationship("account", "district_id", "district", "district_id"),
|
||||||
|
Relationship("loan", "account_id", "account", "account_id"),
|
||||||
|
]
|
||||||
|
c2t: dict[str, list[str]] = {}
|
||||||
|
for t in [Table("account", "accounts", cols_account),
|
||||||
|
Table("district", "districts", cols_district),
|
||||||
|
Table("loan", "loans", cols_loan)]:
|
||||||
|
for c in t.columns:
|
||||||
|
c2t.setdefault(c.name, []).append(t.name)
|
||||||
|
for k in c2t:
|
||||||
|
c2t[k] = sorted(c2t[k])
|
||||||
|
|
||||||
|
metrics = {
|
||||||
|
"loan_default_rate": Metric(
|
||||||
|
name="loan_default_rate",
|
||||||
|
description="Default rate over finished loans.",
|
||||||
|
sql="AVG(CASE WHEN status = 'B' THEN 1.0 WHEN status = 'A' THEN 0.0 END)",
|
||||||
|
from_table="loan",
|
||||||
|
filter="status IN ('A','B')",
|
||||||
|
unit="ratio",
|
||||||
|
),
|
||||||
|
"loan_volume": Metric(
|
||||||
|
name="loan_volume",
|
||||||
|
description="Total CZK lent.",
|
||||||
|
sql="SUM(amount)",
|
||||||
|
from_table="loan",
|
||||||
|
filter=None,
|
||||||
|
unit="CZK",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return Recon(
|
||||||
|
schema="financial",
|
||||||
|
tables={
|
||||||
|
"account": Table("account", "accounts", cols_account),
|
||||||
|
"district": Table("district", "districts", cols_district),
|
||||||
|
"loan": Table("loan", "loans", cols_loan),
|
||||||
|
},
|
||||||
|
metrics=metrics,
|
||||||
|
column_to_tables=c2t,
|
||||||
|
relationships=rels,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Aggregate, no group_by ──────────────────────────────────
|
||||||
|
|
||||||
|
def test_metric_only_no_group_by():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(kind="aggregate", metric="loan_volume")
|
||||||
|
out = compose(pick, r)
|
||||||
|
assert out.sql == 'SELECT SUM("l"."amount") AS "loan_volume" FROM "loan" AS "l"'
|
||||||
|
assert out.used_tables == ["loan"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_with_default_filter_no_group_by():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(kind="aggregate", metric="loan_default_rate")
|
||||||
|
out = compose(pick, r)
|
||||||
|
expected = (
|
||||||
|
"SELECT AVG(CASE WHEN \"l\".\"status\" = 'B' THEN 1.0 "
|
||||||
|
"WHEN \"l\".\"status\" = 'A' THEN 0.0 END) AS \"loan_default_rate\" "
|
||||||
|
"FROM \"loan\" AS \"l\" "
|
||||||
|
"WHERE \"l\".\"status\" IN ('A', 'B')"
|
||||||
|
)
|
||||||
|
assert out.sql == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── Group by — single hop ───────────────────────────────────
|
||||||
|
|
||||||
|
def test_group_by_multi_hop_join():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(
|
||||||
|
kind="aggregate",
|
||||||
|
metric="loan_default_rate",
|
||||||
|
group_by=["A2"],
|
||||||
|
limit=10,
|
||||||
|
)
|
||||||
|
out = compose(pick, r)
|
||||||
|
# loan → account → district, A2 owned by district.
|
||||||
|
assert 'FROM "loan" AS "l"' in out.sql
|
||||||
|
assert 'JOIN "account" AS "a" ON "l"."account_id" = "a"."account_id"' in out.sql
|
||||||
|
assert (
|
||||||
|
'JOIN "district" AS "d" ON "a"."district_id" = "d"."district_id"' in out.sql
|
||||||
|
)
|
||||||
|
assert 'GROUP BY "A2"' in out.sql
|
||||||
|
# default ORDER BY metric DESC
|
||||||
|
assert 'ORDER BY "loan_default_rate" DESC' in out.sql
|
||||||
|
assert "LIMIT 10" in out.sql
|
||||||
|
assert set(out.used_tables) == {"loan", "account", "district"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Filters ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_where_equals_filter():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(
|
||||||
|
kind="aggregate",
|
||||||
|
metric="loan_volume",
|
||||||
|
where=[Filter(column="status", equals="D")],
|
||||||
|
)
|
||||||
|
out = compose(pick, r)
|
||||||
|
assert "WHERE \"l\".\"status\" = 'D'" in out.sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_where_in_values_filter():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(
|
||||||
|
kind="aggregate",
|
||||||
|
metric="loan_volume",
|
||||||
|
where=[Filter(column="status", in_values=["C", "D"])],
|
||||||
|
)
|
||||||
|
out = compose(pick, r)
|
||||||
|
assert "\"l\".\"status\" IN ('C', 'D')" in out.sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_where_date_range_on_real_date_column():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(
|
||||||
|
kind="aggregate",
|
||||||
|
metric="loan_default_rate",
|
||||||
|
where=[Filter(column="date", date_range="1996")],
|
||||||
|
)
|
||||||
|
out = compose(pick, r)
|
||||||
|
assert "\"l\".\"date\" BETWEEN '1996-01-01' AND '1996-12-31'" in out.sql
|
||||||
|
# metric's default filter still ANDed in
|
||||||
|
assert "\"l\".\"status\" IN ('A', 'B')" in out.sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_filter_ands_with_pick_filter():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(
|
||||||
|
kind="aggregate",
|
||||||
|
metric="loan_default_rate",
|
||||||
|
where=[Filter(column="date", date_range="1996")],
|
||||||
|
)
|
||||||
|
out = compose(pick, r)
|
||||||
|
# Both filters in the same WHERE clause, joined by AND.
|
||||||
|
assert " AND " in out.sql
|
||||||
|
|
||||||
|
|
||||||
|
# ── ORDER BY by dimension ───────────────────────────────────
|
||||||
|
|
||||||
|
def test_order_by_dimension():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(
|
||||||
|
kind="aggregate",
|
||||||
|
metric="loan_volume",
|
||||||
|
group_by=["A3"],
|
||||||
|
order_by=OrderBy(by="dimension", direction="asc", dimension="A3"),
|
||||||
|
)
|
||||||
|
out = compose(pick, r)
|
||||||
|
assert 'ORDER BY "A3" ASC' in out.sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_by_dimension_not_in_group_by_raises():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(
|
||||||
|
kind="aggregate",
|
||||||
|
metric="loan_volume",
|
||||||
|
group_by=["A2"],
|
||||||
|
order_by=OrderBy(by="dimension", direction="asc", dimension="A3"),
|
||||||
|
)
|
||||||
|
with pytest.raises(PickValidationError, match="not in group_by"):
|
||||||
|
compose(pick, r)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation rejections ───────────────────────────────────
|
||||||
|
|
||||||
|
def test_unknown_metric_raises():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(kind="aggregate", metric="moon_phase")
|
||||||
|
with pytest.raises(PickValidationError, match="not in recon"):
|
||||||
|
compose(pick, r)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_group_by_column_raises():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(kind="aggregate", metric="loan_volume", group_by=["nonsense"])
|
||||||
|
with pytest.raises(ValueError, match="no table has a column named 'nonsense'"):
|
||||||
|
compose(pick, r)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambiguous_group_by_column_raises():
|
||||||
|
r = _fixture_recon()
|
||||||
|
# account_id lives on both account and loan; must be qualified.
|
||||||
|
pick = Pick(kind="aggregate", metric="loan_volume", group_by=["account_id"])
|
||||||
|
with pytest.raises(ValueError, match="ambiguous"):
|
||||||
|
compose(pick, r)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qualified_disambiguates():
|
||||||
|
r = _fixture_recon()
|
||||||
|
pick = Pick(kind="aggregate", metric="loan_volume", group_by=["loan.account_id"])
|
||||||
|
out = compose(pick, r)
|
||||||
|
# Output alias becomes "loan_account_id" since the ref was qualified.
|
||||||
|
assert '"loan_account_id"' in out.sql
|
||||||
50
tests/test_composer_dates.py
Normal file
50
tests/test_composer_dates.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Date-range resolver tests — bounds parsing + dispatch on column type."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.composer.dates import _bounds, resolve_date_range
|
||||||
|
from api.composer.types import PickValidationError
|
||||||
|
from api.recon.types import Column
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_year():
|
||||||
|
assert _bounds("1996") == ("1996-01-01", "1996-12-31")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_quarter_q1():
|
||||||
|
assert _bounds("1996-Q1") == ("1996-01-01", "1996-03-31")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_quarter_q3():
|
||||||
|
assert _bounds("1996-Q3") == ("1996-07-01", "1996-09-30")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_unsupported_raises():
|
||||||
|
with pytest.raises(PickValidationError, match="unsupported"):
|
||||||
|
_bounds("Q3-1996")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_real_date_column():
|
||||||
|
col = Column(name="date", sql_type="DATE", nullable=False)
|
||||||
|
out = resolve_date_range("1996", col, '"l"."date"')
|
||||||
|
assert out == "\"l\".\"date\" BETWEEN '1996-01-01' AND '1996-12-31'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_timestamp_column():
|
||||||
|
col = Column(name="created_at", sql_type="TIMESTAMP WITHOUT TIME ZONE", nullable=True)
|
||||||
|
out = resolve_date_range("1996-Q2", col, '"x"."created_at"')
|
||||||
|
assert out == "\"x\".\"created_at\" BETWEEN '1996-04-01' AND '1996-06-30'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_yymmdd_semantic_type():
|
||||||
|
col = Column(name="date", sql_type="INTEGER", nullable=False,
|
||||||
|
semantic_type="date_yymmdd")
|
||||||
|
out = resolve_date_range("1996", col, '"l"."date"')
|
||||||
|
assert "TO_DATE(LPAD(" in out
|
||||||
|
assert "BETWEEN '1996-01-01' AND '1996-12-31'" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unsupported_column_type_raises():
|
||||||
|
col = Column(name="x", sql_type="TEXT", nullable=True)
|
||||||
|
with pytest.raises(PickValidationError, match="not supported"):
|
||||||
|
resolve_date_range("1996", col, '"x"."x"')
|
||||||
@@ -62,13 +62,13 @@ def test_tool_call_shapes_carry_analysis_field():
|
|||||||
"""Tool/LLM events must include the active Analysis name from the
|
"""Tool/LLM events must include the active Analysis name from the
|
||||||
contextvar — UI groups sub-events by it. None when outside an Analysis."""
|
contextvar — UI groups sub-events by it. None when outside an Analysis."""
|
||||||
# Outside any Analysis: analysis is None.
|
# Outside any Analysis: analysis is None.
|
||||||
s = events.tool_call_start("text_to_sql", input={"q": "?"})
|
s = events.tool_call_start("compose_sql", input={"q": "?"})
|
||||||
assert s["type"] == "tool_call_start"
|
assert s["type"] == "tool_call_start"
|
||||||
assert s["tool"] == "text_to_sql"
|
assert s["tool"] == "compose_sql"
|
||||||
assert s["analysis"] is None
|
assert s["analysis"] is None
|
||||||
assert s["input"] == {"q": "?"}
|
assert s["input"] == {"q": "?"}
|
||||||
|
|
||||||
e = events.tool_call_end("text_to_sql", output={"sql": "..."})
|
e = events.tool_call_end("compose_sql", output={"sql": "..."})
|
||||||
assert e["analysis"] is None
|
assert e["analysis"] is None
|
||||||
assert e["output"] == {"sql": "..."}
|
assert e["output"] == {"sql": "..."}
|
||||||
assert e["error"] is None
|
assert e["error"] is None
|
||||||
|
|||||||
@@ -21,13 +21,19 @@ MODULES = [
|
|||||||
"api.recon.types",
|
"api.recon.types",
|
||||||
"api.recon.build",
|
"api.recon.build",
|
||||||
"api.tools.db",
|
"api.tools.db",
|
||||||
"api.tools.text_to_sql",
|
|
||||||
"api.tools.execute_sql",
|
"api.tools.execute_sql",
|
||||||
"api.tools.types",
|
"api.tools.types",
|
||||||
|
"api.composer",
|
||||||
|
"api.composer.types",
|
||||||
|
"api.composer.compose",
|
||||||
|
"api.composer.dates",
|
||||||
"api.analyses.base",
|
"api.analyses.base",
|
||||||
"api.analyses.types",
|
"api.analyses.types",
|
||||||
|
"api.analyses._pick",
|
||||||
|
"api.analyses._narrow",
|
||||||
"api.analyses.direct_answer",
|
"api.analyses.direct_answer",
|
||||||
"api.analyses.compare_periods",
|
"api.analyses.compare_periods",
|
||||||
|
"api.analyses.drill_down",
|
||||||
"api.analyses.registry",
|
"api.analyses.registry",
|
||||||
"api.plan.planner",
|
"api.plan.planner",
|
||||||
"api.plan.types",
|
"api.plan.types",
|
||||||
|
|||||||
122
tests/test_pick_shape.py
Normal file
122
tests/test_pick_shape.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Pick / Filter / OrderBy validation — round-trips and rejection cases."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.composer.types import Filter, OrderBy, Pick, PickValidationError
|
||||||
|
|
||||||
|
|
||||||
|
# ── Filter ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_filter_equals_roundtrip():
|
||||||
|
f = Filter.from_dict({"column": "status", "equals": "B"})
|
||||||
|
assert f.kind() == "equals"
|
||||||
|
assert f.to_dict() == {"column": "status", "equals": "B"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_in_values_roundtrip():
|
||||||
|
f = Filter.from_dict({"column": "status", "in_values": ["A", "B"]})
|
||||||
|
assert f.kind() == "in_values"
|
||||||
|
assert f.to_dict() == {"column": "status", "in_values": ["A", "B"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_between_normalised_to_tuple():
|
||||||
|
f = Filter.from_dict({"column": "amount", "between": [1000, 5000]})
|
||||||
|
assert f.kind() == "between"
|
||||||
|
assert f.between == (1000, 5000)
|
||||||
|
# round-trip emits a list, not a tuple
|
||||||
|
assert f.to_dict() == {"column": "amount", "between": [1000, 5000]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_date_range_roundtrip():
|
||||||
|
f = Filter.from_dict({"column": "date", "date_range": "1996"})
|
||||||
|
assert f.kind() == "date_range"
|
||||||
|
assert f.to_dict() == {"column": "date", "date_range": "1996"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_missing_value_raises():
|
||||||
|
with pytest.raises(PickValidationError, match="no value field"):
|
||||||
|
Filter(column="x").kind()
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_multiple_value_fields_raises():
|
||||||
|
f = Filter(column="x", equals="A", in_values=["A", "B"])
|
||||||
|
with pytest.raises(PickValidationError, match="multiple value fields"):
|
||||||
|
f.kind()
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_missing_column_raises():
|
||||||
|
with pytest.raises(PickValidationError, match="missing 'column'"):
|
||||||
|
Filter.from_dict({"equals": "B"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_between_wrong_arity_raises():
|
||||||
|
with pytest.raises(PickValidationError, match="2-element"):
|
||||||
|
Filter.from_dict({"column": "x", "between": [1, 2, 3]})
|
||||||
|
|
||||||
|
|
||||||
|
# ── OrderBy ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_order_by_metric():
|
||||||
|
ob = OrderBy.from_dict({"by": "metric", "direction": "desc"})
|
||||||
|
assert ob.by == "metric"
|
||||||
|
assert ob.direction == "desc"
|
||||||
|
assert ob.to_dict() == {"by": "metric", "direction": "desc"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_by_dimension_requires_dimension():
|
||||||
|
with pytest.raises(PickValidationError, match="requires order_by.dimension"):
|
||||||
|
OrderBy.from_dict({"by": "dimension"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_by_invalid_direction_raises():
|
||||||
|
with pytest.raises(PickValidationError, match="direction"):
|
||||||
|
OrderBy.from_dict({"by": "metric", "direction": "sideways"})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pick ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_pick_minimal():
|
||||||
|
p = Pick.from_dict({"kind": "aggregate", "metric": "loan_volume"})
|
||||||
|
assert p.metric == "loan_volume"
|
||||||
|
assert p.group_by == []
|
||||||
|
assert p.where == []
|
||||||
|
assert p.order_by is None
|
||||||
|
assert p.limit is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_full_roundtrip():
|
||||||
|
raw = {
|
||||||
|
"kind": "aggregate",
|
||||||
|
"metric": "loan_default_rate",
|
||||||
|
"group_by": ["A2"],
|
||||||
|
"where": [{"column": "date", "date_range": "1996"}],
|
||||||
|
"order_by": {"by": "metric", "direction": "desc"},
|
||||||
|
"limit": 10,
|
||||||
|
}
|
||||||
|
p = Pick.from_dict(raw)
|
||||||
|
assert p.to_dict() == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_rejects_non_aggregate_kind():
|
||||||
|
with pytest.raises(PickValidationError, match="aggregate"):
|
||||||
|
Pick.from_dict({"kind": "ratio", "metric": "x"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_missing_metric_raises():
|
||||||
|
with pytest.raises(PickValidationError, match="metric"):
|
||||||
|
Pick.from_dict({"kind": "aggregate"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_group_by_must_be_list_of_strings():
|
||||||
|
with pytest.raises(PickValidationError, match="group_by"):
|
||||||
|
Pick.from_dict({"kind": "aggregate", "metric": "x", "group_by": [1, 2]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_where_must_be_list():
|
||||||
|
with pytest.raises(PickValidationError, match="where must be a list"):
|
||||||
|
Pick.from_dict({"kind": "aggregate", "metric": "x", "where": {"foo": "bar"}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_limit_must_be_int():
|
||||||
|
with pytest.raises(PickValidationError, match="limit"):
|
||||||
|
Pick.from_dict({"kind": "aggregate", "metric": "x", "limit": "ten"})
|
||||||
@@ -252,7 +252,8 @@ export function useRunStream() {
|
|||||||
t_started: elapsed(),
|
t_started: elapsed(),
|
||||||
})
|
})
|
||||||
const detail = d.tool === 'execute_sql' ? (d.input?.sql || '') :
|
const detail = d.tool === 'execute_sql' ? (d.input?.sql || '') :
|
||||||
d.tool === 'text_to_sql' ? (d.input?.question || '') :
|
d.tool === 'pick_for_question' ? (d.input?.question || '') :
|
||||||
|
d.tool === 'compose_sql' && d.input?.pick ? `metric=${d.input.pick.metric}` :
|
||||||
JSON.stringify(d.input)
|
JSON.stringify(d.input)
|
||||||
push('debug', d.tool, `→ ${detail}`)
|
push('debug', d.tool, `→ ${detail}`)
|
||||||
})
|
})
|
||||||
@@ -271,14 +272,14 @@ export function useRunStream() {
|
|||||||
push('error', d.tool, `✗ ${d.error}`)
|
push('error', d.tool, `✗ ${d.error}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (d.tool === 'text_to_sql' && d.output?.sql) {
|
if (d.tool === 'compose_sql' && d.output?.sql) {
|
||||||
push('info', d.tool, `✓ ${d.output.sql}`)
|
push('info', d.tool, `✓ ${d.output.sql}`)
|
||||||
|
} else if (d.tool === 'pick_for_question' && d.output?.pick) {
|
||||||
|
push('info', d.tool, `✓ ${d.output.pick.metric} (${(d.output.pick.group_by || []).join(', ') || 'no group_by'})`)
|
||||||
} else if (d.tool === 'execute_sql') {
|
} else if (d.tool === 'execute_sql') {
|
||||||
const label = d.output?.dimension ? `${d.output.dimension}: ` :
|
const label = d.output?.dimension ? `${d.output.dimension}: ` :
|
||||||
d.output?.label ? `${d.output.label}: ` : ''
|
d.output?.label ? `${d.output.label}: ` : ''
|
||||||
push('info', d.tool, `✓ ${label}${d.output?.row_count} rows`)
|
push('info', d.tool, `✓ ${label}${d.output?.row_count} rows`)
|
||||||
} else if (d.tool === 'generate_pair') {
|
|
||||||
push('info', d.tool, `✓ a=${d.output?.a?.label} · b=${d.output?.b?.label}`)
|
|
||||||
} else {
|
} else {
|
||||||
push('info', d.tool, '✓')
|
push('info', d.tool, '✓')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ function subEvents(nodeId: string): ToolCall[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shortPreview(call: ToolCall): string {
|
function shortPreview(call: ToolCall): string {
|
||||||
if (call.tool === 'text_to_sql' && call.output?.sql) return call.output.sql
|
if (call.tool === 'compose_sql' && call.output?.sql) return call.output.sql
|
||||||
if (call.tool === 'execute_sql' && call.input?.sql) return call.input.sql
|
if (call.tool === 'execute_sql' && call.input?.sql) return call.input.sql
|
||||||
if (call.tool === 'generate_pair' && call.output) {
|
if (call.tool === 'pick_for_question' && call.output?.pick) {
|
||||||
return `A: ${call.output.a?.label}\n${call.output.a?.sql}\n\nB: ${call.output.b?.label}\n${call.output.b?.sql}`
|
return JSON.stringify(call.output.pick, null, 2)
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user