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.
|
||||
|
||||
Both SQL queries are generated in one LLM call (paired prompt), then both
|
||||
executed, then a single interpretation pass diffs them. Single round-trip
|
||||
per LLM step → cheap and bounded latency.
|
||||
One LLM call picks the shape (metric + group_by + non-time filters); the
|
||||
Analysis injects each period as a typed date_range filter and the composer
|
||||
emits two SQLs deterministically. No LLM authors SQL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from api import langfuse_client as lf
|
||||
from api.analyses._pick import pick_for_question
|
||||
from api.analyses.base import Analysis
|
||||
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.prompts import load, render
|
||||
from api.runtime import events
|
||||
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
|
||||
|
||||
logger = logging.getLogger("nvi.analyses.compare_periods")
|
||||
@@ -28,44 +31,74 @@ class ComparePeriods(Analysis):
|
||||
name = "compare_periods"
|
||||
description = (
|
||||
"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 = {
|
||||
"question": {"type": "string", "description": "The metric / scope to compare."},
|
||||
"period_a": {"type": "string", "description": "First period label, e.g. '1995' or 'Q2 1996'."},
|
||||
"period_b": {"type": "string", "description": "Second period label, e.g. '1996' or 'Q3 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 '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:
|
||||
sub_q = args.get("question") or question
|
||||
period_a = args.get("period_a", "")
|
||||
period_b = args.get("period_b", "")
|
||||
allowed_metrics = args.get("allowed_metrics")
|
||||
|
||||
with lf.span("analysis.compare_periods", input=args) as span:
|
||||
try:
|
||||
recon = load_recon()
|
||||
|
||||
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(
|
||||
"generate_pair",
|
||||
output={"a": {"label": pair["a"]["label"], "sql": pair["a"]["sql"]},
|
||||
"b": {"label": pair["b"]["label"], "sql": pair["b"]["sql"]}},
|
||||
"pick_for_question", output={"pick": base_pick.to_dict()},
|
||||
))
|
||||
|
||||
await events.publish_current(events.tool_call_start("execute_sql", input={"label": pair["a"]["label"], "sql": pair["a"]["sql"]}))
|
||||
res_a = execute_sql(pair["a"]["sql"])
|
||||
date_col_ref = _date_column_for(base_pick.metric, recon)
|
||||
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(
|
||||
"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]},
|
||||
))
|
||||
|
||||
await events.publish_current(events.tool_call_start("execute_sql", input={"label": pair["b"]["label"], "sql": pair["b"]["sql"]}))
|
||||
res_b = execute_sql(pair["b"]["sql"])
|
||||
await events.publish_current(events.tool_call_start(
|
||||
"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(
|
||||
"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]},
|
||||
))
|
||||
|
||||
@@ -73,8 +106,8 @@ class ComparePeriods(Analysis):
|
||||
interpret_user = render(
|
||||
"compare_periods.interpret.user",
|
||||
question=sub_q,
|
||||
label_a=pair["a"]["label"],
|
||||
label_b=pair["b"]["label"],
|
||||
label_a=period_a,
|
||||
label_b=period_b,
|
||||
rows_a=repr(res_a.as_dicts()[:20]),
|
||||
rows_b=repr(res_b.as_dicts()[:20]),
|
||||
)
|
||||
@@ -98,48 +131,46 @@ class ComparePeriods(Analysis):
|
||||
analysis=self.name,
|
||||
summary=summary,
|
||||
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={
|
||||
"label_a": pair["a"]["label"],
|
||||
"label_b": pair["b"]["label"],
|
||||
"metric": base_pick.metric,
|
||||
"label_a": period_a,
|
||||
"label_b": period_b,
|
||||
"row_count_a": res_a.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]]:
|
||||
schema = load_recon()
|
||||
text = chat(
|
||||
system=load("compare_periods.pair"),
|
||||
user=render(
|
||||
"compare_periods.pair.user",
|
||||
question=question,
|
||||
period_a=period_a or "(infer)",
|
||||
period_b=period_b or "(infer)",
|
||||
schema_block=schema.render_tables(),
|
||||
metrics_block=schema.render_metrics(),
|
||||
),
|
||||
max_tokens=1024,
|
||||
span_name="compare_periods.pair",
|
||||
def _date_column_for(metric_name: str, recon: Recon) -> str:
|
||||
"""Return the date column ref (qualified) for the metric's from_table.
|
||||
|
||||
The composer needs to know which column carries time for the period
|
||||
filter. We look at the metric's source table and pick the first
|
||||
column whose sql_type is DATE / TIMESTAMP. Returned qualified
|
||||
(`table.column`) so resolve_column never trips on ambiguity."""
|
||||
metric = recon.metrics[metric_name]
|
||||
table = recon.tables[metric.from_table]
|
||||
for c in table.columns:
|
||||
sql_type = c.sql_type.upper()
|
||||
if sql_type.startswith("DATE") or sql_type.startswith("TIMESTAMP"):
|
||||
return f"{table.name}.{c.name}"
|
||||
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:
|
||||
m = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
|
||||
if m:
|
||||
return m.group(1)
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return text[start:end + 1]
|
||||
return text
|
||||
def _with_period_filter(pick: Pick, col_ref: str, period: str) -> Pick:
|
||||
"""Return a new Pick with a date_range filter on `col_ref` set to
|
||||
`period`. Strips any existing date_range filter on the same column
|
||||
so the Analysis-injected one takes precedence."""
|
||||
where = [
|
||||
f for f in pick.where
|
||||
if not (f.column == col_ref and f.date_range is not None)
|
||||
]
|
||||
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 api import langfuse_client as lf
|
||||
from api.analyses._pick import pick_for_question
|
||||
from api.analyses.base import Analysis
|
||||
from api.analyses.types import Finding
|
||||
from api.composer import compose
|
||||
from api.llm import chat
|
||||
from api.prompts import load, render
|
||||
from api.recon import load_recon
|
||||
from api.runtime import events
|
||||
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")
|
||||
|
||||
@@ -29,23 +31,39 @@ class DirectAnswer(Analysis):
|
||||
)
|
||||
args_schema = {
|
||||
"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:
|
||||
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:
|
||||
try:
|
||||
await events.publish_current(events.tool_call_start("text_to_sql", input={"question": sub_q}))
|
||||
t2s = text_to_sql(sub_q, hint_tables=hint_tables)
|
||||
recon = load_recon()
|
||||
|
||||
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(
|
||||
"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}))
|
||||
result = execute_sql(t2s.sql)
|
||||
await events.publish_current(events.tool_call_start(
|
||||
"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(
|
||||
"execute_sql",
|
||||
output={"row_count": result.row_count, "truncated": result.truncated,
|
||||
@@ -56,7 +74,7 @@ class DirectAnswer(Analysis):
|
||||
interpret_user = render(
|
||||
"direct_answer.interpret.user",
|
||||
question=sub_q,
|
||||
sql=t2s.sql,
|
||||
sql=composed.sql,
|
||||
rows=repr(result.as_dicts()[:20]),
|
||||
)
|
||||
await events.publish_current(events.llm_call(
|
||||
@@ -64,7 +82,10 @@ class DirectAnswer(Analysis):
|
||||
system_len=len(interpret_system),
|
||||
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:
|
||||
logger.exception("direct_answer failed")
|
||||
await events.publish_current(events.tool_call_end("direct_answer", error=str(e)))
|
||||
@@ -76,6 +97,10 @@ class DirectAnswer(Analysis):
|
||||
analysis=self.name,
|
||||
summary=summary,
|
||||
rows=result.as_dicts()[:20],
|
||||
sql=[t2s.sql],
|
||||
metadata={"row_count": result.row_count, "truncated": result.truncated},
|
||||
sql=[composed.sql],
|
||||
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.analyses.drill_down.types import Slice
|
||||
from api.composer import Pick, compose
|
||||
from api.llm import chat
|
||||
from api.prompts import load, render
|
||||
from api.recon import load_recon
|
||||
from api.runtime import events
|
||||
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")
|
||||
|
||||
@@ -69,54 +69,25 @@ async def decide_next(question: str, metric: str, dimensions: list[str],
|
||||
|
||||
# ── 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:
|
||||
"""Generate SQL for one slice, execute it, emit tool-call events
|
||||
around both, and return the Slice."""
|
||||
slice_q = build_slice_question(question, metric, dim)
|
||||
"""Compose SQL for one slice deterministically (metric + dim are already
|
||||
chosen by `decide_next`), execute it, emit tool-call events around both,
|
||||
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}))
|
||||
t2s = text_to_sql(slice_q)
|
||||
await events.publish_current(events.tool_call_start(
|
||||
"compose_sql", input={"pick": pick.to_dict()},
|
||||
))
|
||||
composed = compose(pick, recon)
|
||||
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(
|
||||
"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(
|
||||
"execute_sql",
|
||||
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(
|
||||
dimension=dim,
|
||||
sql=t2s.sql,
|
||||
sql=composed.sql,
|
||||
rows=result.as_dicts()[:20],
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user