File size: 8,111 Bytes
81e5fe7 93d9381 81e5fe7 93d9381 81e5fe7 93d9381 81e5fe7 93d9381 81e5fe7 93d9381 81e5fe7 6bda333 81e5fe7 6bda333 81e5fe7 6bda333 81e5fe7 6bda333 81e5fe7 6bda333 81e5fe7 6bda333 81e5fe7 6bda333 81e5fe7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | """AnalyticsToolInvoker — the runtime seam implementation (KM-465).
Implements the `ToolInvoker` Protocol the slow-path TaskRunner calls
(src/agents/slow_path/invoker.py). One method, `invoke(tool_name, args)`, does the
whole job for the `analyze_*` family:
1. Look the tool up in a name -> (compute fn, output_kind) dispatch map; an unknown
name returns an error envelope (never an exception).
2. Materialize the Pattern A `data` argument — which the TaskRunner has already
resolved to the upstream task's `ToolOutput` (kind="table") — into a DataFrame.
3. Call the pure compute function with the remaining args as keyword arguments
(their names match the compute signatures one-to-one).
4. Wrap the result in a `ToolOutput` with the tool's declared `kind`.
Frozen guarantee (§8.4): **never throws.** Any failure — unknown tool, bad data,
or an exception from compute (e.g. GroupNotFoundError) — comes back as
`ToolOutput(kind="error", error=...)`, so the TaskRunner's degrade-and-continue
keeps working.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import pandas as pd
from src.middlewares.logging import get_logger
from src.tools.analytics import (
aggregation,
comparison,
decomposition,
descriptive,
quality,
relationship,
segmentation,
temporal,
)
from src.tools.contracts import ToolOutput
from src.tools.data_access import DATA_ACCESS_TOOLS, DataAccessToolInvoker
logger = get_logger("analytics_invoker")
# tool name -> (compute callable, ToolOutput.kind it produces). Kept in lockstep
# with src/tools/registry.py output_kind values.
_DISPATCH: dict[str, tuple[Callable[..., Any], str]] = {
"analyze_descriptive": (descriptive.analyze_descriptive, "stats"),
"analyze_aggregate": (aggregation.analyze_aggregate, "table"),
"analyze_comparison": (comparison.analyze_comparison, "stats"),
"analyze_contribution": (decomposition.analyze_contribution, "table"),
"analyze_profile": (quality.analyze_profile, "stats"),
"analyze_correlation": (relationship.analyze_correlation, "stats"),
"analyze_segment": (segmentation.analyze_segment, "table"),
"analyze_trend": (temporal.analyze_trend, "series"),
}
class AnalyticsToolInvoker:
"""Never-throwing invoker for the `analyze_*` tools (implements ToolInvoker)."""
async def invoke(self, tool_name: str, args: dict[str, Any]) -> ToolOutput:
entry = _DISPATCH.get(tool_name)
if entry is None:
logger.warning("tool returned error", tool=tool_name, error="unknown tool")
return ToolOutput(
tool=tool_name, kind="error", error=f"unknown tool {tool_name!r}"
)
fn, kind = entry
df, err = _materialize(args.get("data"))
if err is not None:
logger.warning("tool returned error", tool=tool_name, error=err)
return ToolOutput(tool=tool_name, kind="error", error=err)
kwargs = {k: v for k, v in args.items() if k != "data"}
try:
result = fn(df, **kwargs)
except Exception as exc: # noqa: BLE001 — never-throw seam (§8.4)
error = f"{type(exc).__name__}: {exc}"
# Never-throw is intentional (§8.4), but a swallowed failure was
# invisible: log it so a failed analysis step is diagnosable instead
# of only surfacing as a vague "could not compute" in the answer.
logger.warning("tool returned error", tool=tool_name, error=error)
return ToolOutput(tool=tool_name, kind="error", error=error)
return ToolOutput(tool=tool_name, kind=kind, value=result)
class CompositeToolInvoker:
"""One `invoke()` for the whole tool surface (KM-465 #4).
The TaskRunner only ever calls one `ToolInvoker`. This composes the two
families behind a single dispatch: the stateless `AnalyticsToolInvoker`
(`analyze_*`) and the per-request stateful `DataAccessToolInvoker`
(catalog/query/retrieval, which need the authenticated `user_id`). Routing
is by tool name; an unknown name falls through to the analytics invoker,
which returns the standard unknown-tool error envelope.
Constructed per-request — the Coordinator injects the request's `user_id`
and `CatalogReader` into the data-access invoker (INV-7: the agent layer
stays tool-agnostic).
Frozen guarantee (§8.4): **never throws** — both sub-invokers return
`ToolOutput(kind="error", ...)` on any failure.
"""
def __init__(
self,
data_access: DataAccessToolInvoker,
analytics: AnalyticsToolInvoker | None = None,
) -> None:
self._data_access = data_access
self._analytics = analytics or AnalyticsToolInvoker()
async def invoke(self, tool_name: str, args: dict[str, Any]) -> ToolOutput:
if tool_name in DATA_ACCESS_TOOLS:
return await self._data_access.invoke(tool_name, args)
return await self._analytics.invoke(tool_name, args)
def _materialize(data: Any) -> tuple[pd.DataFrame, None] | tuple[None, str]:
"""Turn the resolved `data` argument into a DataFrame.
Accepts the upstream `ToolOutput` (kind="table"), a raw DataFrame, or a
{"columns", "rows"} dict (a serialized table). Returns (df, None) on success
or (None, error_message) on failure — the caller wraps the message.
Numeric columns are normalized (see `_normalize_numeric`): DB NUMERIC values
arrive as Python `Decimal`, and tabular sources sometimes store numbers as
text — both break the float math in the `analyze_*` compute functions (or make
a numeric column invisible to `is_numeric_dtype`). Normalizing here fixes the
whole tool family in one place.
"""
if data is None:
return None, "missing 'data' argument (no upstream table to analyze)"
if isinstance(data, pd.DataFrame):
return _normalize_numeric(data), None
if isinstance(data, ToolOutput):
if data.kind == "error":
return None, f"upstream data unavailable: {data.error}"
if data.kind != "table" or data.columns is None:
return None, f"cannot materialize 'data' of kind {data.kind!r}"
return _normalize_numeric(pd.DataFrame(data.rows or [], columns=data.columns)), None
if isinstance(data, dict) and "columns" in data:
df = pd.DataFrame(data.get("rows") or [], columns=data["columns"])
return _normalize_numeric(df), None
return None, f"unsupported 'data' type: {type(data).__name__}"
def _normalize_numeric(df: pd.DataFrame) -> pd.DataFrame:
"""Coerce object-columns that are really numeric into numeric dtype in place.
Two sources of "numbers hiding in object columns" break the analyze_* tools:
- DB drivers (asyncpg) return NUMERIC/DECIMAL as Python `Decimal`, which
raises `TypeError` on `float + Decimal` in share-of-total / cumulative math.
- Tabular files (CSV/XLSX, or a stale Parquet) sometimes store numbers as
text, so a numeric column is invisible to `pd.api.types.is_numeric_dtype`
and tools like `analyze_correlation` see "0 numeric columns".
Both are fixed by converting only the columns that are *entirely* numeric to a
numeric dtype. A column with any non-numeric value (e.g. a category like
"Online"/"Offline") fails the all-parseable check and is left untouched, so
genuine categoricals are never mangled. Empty/None cells become NaN, which the
compute functions already handle.
Caveat: all-digit identifier columns stored as text (e.g. a zero-padded code
"007") are treated as numeric — acceptable for an analytics data path.
"""
for col in df.columns:
if df[col].dtype != object:
continue
converted = pd.to_numeric(df[col], errors="coerce")
# Convert only when every originally-present value parsed as a number, so
# a single non-numeric value keeps the column as-is.
if converted.notna().sum() == df[col].notna().sum():
df[col] = converted
return df
|