File size: 6,021 Bytes
49b0848 | 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 | """analyze_merge — combine TWO upstream tables on shared keys (KM-608).
The only analytics "family" tool with a SECOND data input. In ONE call it joins
two already-materialized tables (`data` = LEFT, `data_right` = RIGHT) on one or
more shared key columns and returns the combined rows. This is what unlocks the
"which X has BOTH the worst A and the biggest B" question shape: A and B come
from two separate `retrieve_data` pulls (e.g. PA-by-section and backlog-by-section)
that must be aligned per X before either can be judged against the other. Without
a two-input combine the run dies with ColumnNotFoundError because no single tool
ever sees both metrics.
Pattern A, extended: it takes TWO `"${t<id>}"` placeholders. The invoker
materializes BOTH into DataFrames before calling this function (no self-fetch);
the `on` key(s) reference the column aliases the upstream queries produced.
STATUS: compute layer only — takes two already-materialized DataFrames. The
wrapper layer (the ToolOutput envelope, dual-arg materialization, ToolSpec
registration) lives in src/tools/invoker.py + registry.py. Keeping compute
separate from data-fetching keeps this easy to unit-test and stable when wrapped.
"""
from __future__ import annotations
import pandas as pd
from src.tools.analytics.descriptive import ColumnNotFoundError
# Join types the tool understands. Whitelisted so an unknown `how` fails loudly
# instead of silently doing the wrong thing. "cross" is deliberately excluded —
# it ignores `on` and is never the right tool for the "align two metrics" shape.
SUPPORTED_HOWS = ("inner", "left", "right", "outer")
class UnsupportedJoinError(ValueError):
"""Requested join type is not in SUPPORTED_HOWS (maps to error_code UNSUPPORTED_JOIN)."""
def _clean(value: object) -> object:
"""Coerce a scalar to a JSON-clean Python value.
An outer/left/right join introduces `NaN` for non-matching rows, and numpy /
pandas scalars (numpy.int64, pandas.Timestamp) are not JSON-serializable —
normalise all three so the returned rows are clean.
"""
if isinstance(value, pd.Timestamp):
return value.isoformat()
if value is None:
return None
try:
if pd.isna(value):
return None
except (TypeError, ValueError):
pass # non-scalar / unhashable — leave as-is
if hasattr(value, "item"):
return value.item()
return value
# Prompt-style description read by the Planner to decide WHEN to pick this tool.
DESCRIPTION = """\
Summary: Combine TWO upstream tables into one by joining on shared key column(s) \
(a pandas merge). `data` is the LEFT table, `data_right` is the RIGHT table; `on` \
is the shared column alias(es) present in BOTH. Returns the combined rows, one \
per matched key (join type controlled by `how`, default inner).
USE WHEN a question needs TWO different metrics per the SAME entity and those \
metrics come from two separate pulls — the tell-tale shape is "which X has BOTH \
A and B" (e.g. "which section has the worst PA AND the biggest backlog", "top \
customers by revenue that also have the most complaints"). Plan it as two \
retrieve_data tasks (one per metric, each keyed by X), then analyze_merge on X.
SETTING KEYS: `on` must be column alias(es) that exist in BOTH tables (the entity \
you align on, e.g. section_id). Use `suffixes` (default ["_left","_right"]) to \
disambiguate non-key columns that share a name across the two tables. `how`: \
inner (only matched keys), left/right (keep one side), outer (keep all).
DON'T USE WHEN:
- both metrics can be pulled in ONE retrieve_data query -> just retrieve_data
- it groups/aggregates a single table -> analyze_aggregate
Example questions:
- "which section has the worst PA and the biggest maintenance backlog"
- "regions in the top 10 for sales that are also bottom 10 for margin"
- "products low on stock that also have high demand"
"""
def analyze_merge(
df: pd.DataFrame,
data_right: pd.DataFrame,
on: list[str] | str,
how: str = "inner",
suffixes: tuple[str, str] | list[str] = ("_left", "_right"),
) -> list[dict[str, object]]:
"""Join two already-materialized tables on shared key column(s).
Args:
df: LEFT table (in the real system the invoker materializes this from the
`data` placeholder).
data_right: RIGHT table (materialized from the `data_right` placeholder).
on: shared key column alias(es) present in BOTH tables. A bare string is
treated as a single key.
how: join type — one of SUPPORTED_HOWS (default "inner").
suffixes: 2-element (left, right) suffixes applied to non-key columns that
collide by name across the two tables.
Returns:
list[dict]: one row per merged record, values JSON-clean (NaN -> None).
Raises:
ColumnNotFoundError: if `on` is empty or a key is absent from either side.
UnsupportedJoinError: if `how` is not supported.
ValueError: if `suffixes` is not a 2-element sequence.
"""
keys = [on] if isinstance(on, str) else list(on)
if not keys:
raise ColumnNotFoundError("merge 'on' must name at least one shared key column")
if how not in SUPPORTED_HOWS:
raise UnsupportedJoinError(
f"unsupported join '{how}'; supported: {list(SUPPORTED_HOWS)}"
)
missing_left = [c for c in keys if c not in df.columns]
missing_right = [c for c in keys if c not in data_right.columns]
if missing_left or missing_right:
raise ColumnNotFoundError(
f"join key(s) not found — left missing {missing_left}, "
f"right missing {missing_right}"
)
suf = tuple(suffixes)
if len(suf) != 2:
raise ValueError(f"suffixes must be a 2-element (left, right) sequence, got {suffixes!r}")
merged = df.merge(data_right, on=keys, how=how, suffixes=suf)
return [{k: _clean(v) for k, v in rec.items()} for rec in merged.to_dict("records")]
|