recon + sqlglot validator + drill_down package; guard ReAct dimension picks against candidate list
This commit is contained in:
89
api/analyses/drill_down/analysis.py
Normal file
89
api/analyses/drill_down/analysis.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""drill_down Analysis — ReAct loop.
|
||||
|
||||
Pattern: bounded loop that picks a dimension to slice by, generates SQL,
|
||||
executes it, and decides whether to continue. From the runtime's view this
|
||||
is just `(args, question) → Finding`; internally it's a small ReAct agent.
|
||||
|
||||
This file owns the orchestration. The decision step, the slice execution,
|
||||
the interpretation, and the prompt-context formatters all live in
|
||||
`helpers.py`. Types and constants live in `types.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from api import langfuse_client as lf
|
||||
from api.analyses.base import Analysis
|
||||
from api.analyses.drill_down.helpers import decide_next, execute_slice, interpret
|
||||
from api.analyses.drill_down.types import ARGS_SCHEMA, DrillDownArgs, Slice
|
||||
from api.analyses.types import Finding
|
||||
|
||||
logger = logging.getLogger("nvi.analyses.drill_down")
|
||||
|
||||
|
||||
class DrillDown(Analysis):
|
||||
name = "drill_down"
|
||||
description = (
|
||||
"Iteratively slice a metric by candidate dimensions to find which "
|
||||
"ones explain the most variance. ReAct loop: pick a dimension, "
|
||||
"query, decide whether to continue. Best for open-ended 'which "
|
||||
"factors explain X' or 'why are some segments different' questions."
|
||||
)
|
||||
args_schema = ARGS_SCHEMA
|
||||
|
||||
async def run(self, args: dict[str, Any], question: str) -> Finding:
|
||||
a = DrillDownArgs.from_raw(args, default_question=question)
|
||||
|
||||
with lf.span("analysis.drill_down", input={"question": a.question, "metric": a.metric}) as span:
|
||||
try:
|
||||
slices = await self._loop(a)
|
||||
if not slices:
|
||||
return Finding(
|
||||
analysis=self.name, summary="",
|
||||
error="drill_down stopped before producing any slice",
|
||||
)
|
||||
summary = await interpret(a.question, a.metric, slices)
|
||||
except Exception as e:
|
||||
logger.exception("drill_down failed")
|
||||
span.update(output={"error": str(e)})
|
||||
return Finding(analysis=self.name, summary="", error=str(e))
|
||||
|
||||
span.update(output={"summary": summary, "iterations": len(slices)})
|
||||
return self._finalise(a, slices, summary)
|
||||
|
||||
async def _loop(self, a: DrillDownArgs) -> list[Slice]:
|
||||
"""Bounded ReAct loop. Each iteration: decide → slice. Stops when the
|
||||
LLM says so, when the budget runs out, or when a repeat is detected."""
|
||||
slices: list[Slice] = []
|
||||
tried: set[str] = set()
|
||||
for it in range(a.max_iters):
|
||||
remaining = a.max_iters - it
|
||||
decision = await decide_next(a.question, a.metric, a.dimensions, slices, remaining)
|
||||
if decision.get("action") == "stop":
|
||||
break
|
||||
dim = decision.get("dimension")
|
||||
if not dim or dim in tried:
|
||||
break
|
||||
tried.add(dim)
|
||||
slices.append(await execute_slice(
|
||||
a.question, a.metric, dim, decision.get("reason", ""),
|
||||
))
|
||||
return slices
|
||||
|
||||
def _finalise(self, a: DrillDownArgs, slices: list[Slice], summary: str) -> Finding:
|
||||
rows_combined = [
|
||||
{"dimension": s.dimension, **r} for s in slices for r in s.rows
|
||||
]
|
||||
return Finding(
|
||||
analysis=self.name,
|
||||
summary=summary,
|
||||
rows=rows_combined,
|
||||
sql=[s.sql for s in slices],
|
||||
metadata={
|
||||
"metric": a.metric,
|
||||
"iterations": len(slices),
|
||||
"dimensions_tried": [s.dimension for s in slices],
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user