90 lines
3.5 KiB
Python
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],
|
|
},
|
|
)
|