File size: 10,215 Bytes
81e5fe7 0e5fdb5 81e5fe7 49b0848 81e5fe7 5a60e93 81e5fe7 0e5fdb5 81e5fe7 49b0848 5a60e93 81e5fe7 0e5fdb5 81e5fe7 0e5fdb5 81e5fe7 49b0848 81e5fe7 0e5fdb5 81e5fe7 0e5fdb5 81e5fe7 0e5fdb5 81e5fe7 f282b15 81e5fe7 0e5fdb5 81e5fe7 f282b15 17e31c6 81e5fe7 0e5fdb5 81e5fe7 0e5fdb5 81e5fe7 0e5fdb5 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 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | """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,
merge,
quality,
relationship,
segmentation,
temporal,
visualization,
)
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"),
"analyze_merge": (merge.analyze_merge, "table"),
"render_chart": (visualization.render_chart, "chart"),
}
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"}
# Second data input (Pattern A, extended): analyze_merge takes a
# `data_right` placeholder the TaskRunner has resolved to another upstream
# ToolOutput. Materialize it the same way as `data` and hand the compute fn
# a DataFrame, not the raw envelope.
if "data_right" in kwargs:
df_right, err = _materialize(kwargs["data_right"])
if err is not None:
err = f"data_right: {err}"
logger.warning("tool returned error", tool=tool_name, error=err)
return ToolOutput(tool=tool_name, kind="error", error=err)
kwargs["data_right"] = df_right
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":
# retrieve_data shape: columns + rows (list-of-lists).
if data.columns is not None:
return _normalize_numeric(pd.DataFrame(data.rows or [], columns=data.columns)), None
# analyze_* shape: `value` is a list-of-dicts (e.g. analyze_aggregate),
# so a chained analyze_* / render_chart can consume an aggregate output.
if isinstance(data.value, list):
return _normalize_numeric(pd.DataFrame(data.value)), None
return None, f"cannot materialize 'data' of kind {data.kind!r}"
if isinstance(data, dict) and "columns" in data:
df = pd.DataFrame(data.get("rows") or [], columns=data["columns"])
return _normalize_numeric(df), None
# Serialized analyze_* table: {"kind": "table", "value": [ {...}, ... ]}.
if isinstance(data, dict) and isinstance(data.get("value"), list):
return _normalize_numeric(pd.DataFrame(data["value"])), None
# A {table_id/source_id} dict is a raw catalog reference the planner inlined
# instead of chaining a retrieve_data output (Pattern A). analyze_* tools never
# self-fetch, so give an actionable message rather than the opaque type name.
# The planner validator should reject this upstream (Check 9); this is the
# defensive net if a bad plan still reaches execution.
if isinstance(data, dict) and ("table_id" in data or "source_id" in data):
return None, (
"'data' is a table reference (table_id/source_id), not a retrieved "
"table — analyze_* consumes the output of retrieve_data, it does not "
"fetch data by id (Pattern A)"
)
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
|