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