Files
nvi/api/analyses/drill_down/analysis.py

90 lines
3.5 KiB
Python

"""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],
},
)