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