codi-api-agent 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api_agent/__init__.py +60 -0
- api_agent/__main__.py +5 -0
- api_agent/_version.py +2 -0
- api_agent/agent.py +6901 -0
- api_agent/catalog.py +147 -0
- api_agent/chart.py +144 -0
- api_agent/cli.py +31 -0
- api_agent/config.py +296 -0
- api_agent/doc_extract.py +431 -0
- api_agent/graphql_loader.py +309 -0
- api_agent/llm.py +470 -0
- api_agent/log.py +138 -0
- api_agent/metrics.py +1030 -0
- api_agent/openapi_loader.py +560 -0
- api_agent/prompts/__init__.py +59 -0
- api_agent/prompts/advisory.py +52 -0
- api_agent/prompts/executor.py +175 -0
- api_agent/prompts/judges.py +207 -0
- api_agent/prompts/support.py +59 -0
- api_agent/prompts/synthesis.py +350 -0
- api_agent/router.py +294 -0
- api_agent/schemas.py +250 -0
- api_agent/spec_convert.py +86 -0
- api_agent/sql_loader.py +1254 -0
- api_agent/supervisor.py +178 -0
- api_agent/ui.py +918 -0
- codi_api_agent-0.3.1.dist-info/METADATA +260 -0
- codi_api_agent-0.3.1.dist-info/RECORD +31 -0
- codi_api_agent-0.3.1.dist-info/WHEEL +5 -0
- codi_api_agent-0.3.1.dist-info/entry_points.txt +2 -0
- codi_api_agent-0.3.1.dist-info/top_level.txt +1 -0
api_agent/metrics.py
ADDED
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
"""DERIVED METRICS — the arithmetic the model is forbidden to do, done by code.
|
|
2
|
+
|
|
3
|
+
Why this module exists
|
|
4
|
+
----------------------
|
|
5
|
+
`SYNTHESIS_SYSTEM` bans the model from computing any number (no sums, deltas, percentages,
|
|
6
|
+
margins, shares). That ban was earned: every prompt-level attempt to make the model do arithmetic
|
|
7
|
+
correctly has failed, and a wrong total stated confidently is worse than no total. See the
|
|
8
|
+
`reliability-over-prompts` rule.
|
|
9
|
+
|
|
10
|
+
But the ban is also the quality ceiling. Measured against the incumbent agent we are replacing,
|
|
11
|
+
its substantive answers average ~24 derived expressions ("$1.2M (-18.7%) versus prior year",
|
|
12
|
+
"margin collapsed from 25.8% to 8.5%", "three locations account for $1.1M of the decline") and
|
|
13
|
+
ours contain **zero** — because `_computed_facts` only ever offered min, max and average, so
|
|
14
|
+
min/max/average is the entire analytical vocabulary the model has to work with.
|
|
15
|
+
|
|
16
|
+
This module widens that vocabulary without touching the guarantee. Everything here is computed
|
|
17
|
+
deterministically from the evidence rows and handed to synthesis as VERIFIED facts; the model
|
|
18
|
+
quotes, it never calculates. Each fact also enters the grounding pool, so a figure the model
|
|
19
|
+
copies from here validates instead of being flagged as unsupported.
|
|
20
|
+
|
|
21
|
+
Design rules
|
|
22
|
+
------------
|
|
23
|
+
- **Pure.** No LLM, no I/O, no config. Rows in, facts out.
|
|
24
|
+
- **Never guess a pairing.** A delta is emitted only when the two columns are unambiguously
|
|
25
|
+
related by naming convention (see `_classify`). An unrecognised column produces no fact rather
|
|
26
|
+
than a wrong one.
|
|
27
|
+
- **Distinguish a DELTA column from a LEVEL column.** The warehouse ships both: `rev_vs_ly` is
|
|
28
|
+
already a difference, while `prior_year_actual` is a level. Treating one as the other silently
|
|
29
|
+
doubles or halves every percentage, so the distinction is explicit, not inferred from position.
|
|
30
|
+
- **Bounded output.** Facts are capped per evidence item; a 60-column table must not flood the
|
|
31
|
+
prompt with 400 derived figures.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import itertools
|
|
36
|
+
import re
|
|
37
|
+
import statistics
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
|
|
40
|
+
# Columns whose values must not be summed or differenced as if they were quantities. Mirrors
|
|
41
|
+
# agent._NON_ADDITIVE_KEY; duplicated here to keep this module free of agent imports (agent
|
|
42
|
+
# imports metrics, not the other way round).
|
|
43
|
+
_NON_ADDITIVE = re.compile(
|
|
44
|
+
r"pct|percent|%|ratio|(?:^|_)rate(?:$|_)|margin|average|(?:^|_)avg(?:$|_)|(?:^|_)mean(?:$|_)"
|
|
45
|
+
r"|median|(?:^|_| )per(?:$|_| )|yoy|mom|qoq|growth", re.I)
|
|
46
|
+
_ID_LIKE = re.compile(r"(?:^|_)(?:id|ids|code|zip|postal|rank|index|idx|key)(?:$|_)", re.I)
|
|
47
|
+
# A bare calendar column is a dimension, not a measure — but only when the WHOLE name is that
|
|
48
|
+
# dimension. `year`/`p_month` are dimensions; `prior_year_actual` is a money column and matching
|
|
49
|
+
# it here silently deleted every year-over-year comparison the warehouse ships.
|
|
50
|
+
_CALENDAR_COL = re.compile(r"^(?:p_)?(?:year|month|quarter|week|day|period)$", re.I)
|
|
51
|
+
_LABEL_HINT = ("month", "period", "date", "name", "label", "account", "item", "location",
|
|
52
|
+
"category", "card", "procedure", "provider")
|
|
53
|
+
|
|
54
|
+
# --- column-role vocabulary -------------------------------------------------------------------- #
|
|
55
|
+
# A column that is ALREADY a difference (actual minus comparison). Emitting `actual - column`
|
|
56
|
+
# for one of these would be nonsense, so they are handled as deltas: the comparison LEVEL is
|
|
57
|
+
# recovered as `actual - delta`.
|
|
58
|
+
_DELTA_LY = re.compile(r"(?:^|_)(?:vs_ly|vs_last_year|var_ly|delta_ly)(?:$|_)", re.I)
|
|
59
|
+
_DELTA_BUDGET = re.compile(r"(?:^|_)(?:vs_budget|vs_bud|var_budget|delta_budget)(?:$|_)", re.I)
|
|
60
|
+
_DELTA_PLAIN = re.compile(r"(?:^|_)(?:delta|variance|var)(?:$|_)", re.I)
|
|
61
|
+
# A column that is a LEVEL for a comparison period (a value, not a difference).
|
|
62
|
+
_LEVEL_PRIOR = re.compile(r"(?:^|_)(?:prior_year|prior|last_year|ly|py|previous)(?:$|_)", re.I)
|
|
63
|
+
_LEVEL_BUDGET = re.compile(r"(?:^|_)(?:budget|bud|plan|target)(?:$|_)", re.I)
|
|
64
|
+
# The current-period value.
|
|
65
|
+
_ACTUAL = re.compile(r"(?:^|_)(?:actual|curr|current|value|ytd_value|net|total)(?:$|_)", re.I)
|
|
66
|
+
_PCT_COL = re.compile(r"(?:^|_)(?:pct|percent|percentage)(?:$|_)|_pct$", re.I)
|
|
67
|
+
|
|
68
|
+
# Margin = profit / revenue. Both sides matched by name so a margin is only ever computed from
|
|
69
|
+
# two columns that genuinely are a profit and a revenue.
|
|
70
|
+
_PROFIT_COL = re.compile(r"ebitda|profit|income|margin_dollars", re.I)
|
|
71
|
+
_REVENUE_COL = re.compile(r"(?:^|_)(?:rev|revenue|net_production|production|sales)(?:$|_)|^rev", re.I)
|
|
72
|
+
|
|
73
|
+
MAX_FACTS_PER_ITEM = 24 # keeps a wide table from flooding the synthesis prompt
|
|
74
|
+
TOP_N = 3 # "the top 3 movers account for X of the change"
|
|
75
|
+
SMALL_TABLE = 12 # at or below this, every row gets its own derived comparison
|
|
76
|
+
|
|
77
|
+
# --- WHEN MAY WE AGGREGATE ACROSS ROWS? --------------------------------------------------------- #
|
|
78
|
+
# This is the guard that stops this module manufacturing confident nonsense, and it is deliberately
|
|
79
|
+
# structural rather than a list of known column names — a name list would encode one warehouse's
|
|
80
|
+
# conventions and fail silently on the next.
|
|
81
|
+
#
|
|
82
|
+
# The hazard: a result may be one metric across many entities/periods (summable), or a list where
|
|
83
|
+
# each row is a DIFFERENT metric. A chain-total result returns Revenue, Staff Expenses, Non Staff
|
|
84
|
+
# Expenses, Office Adj. and Adjusted EBITDA as five rows; summing that column adds revenue to
|
|
85
|
+
# expenses to EBITDA and yields a meaningless figure carrying a "code-verified" label — precisely
|
|
86
|
+
# the failure the no-derived-numbers rule exists to prevent.
|
|
87
|
+
#
|
|
88
|
+
# The rule adopted: CROSS-ROW SUMS ARE ALLOWED ONLY WHEN THE ROWS ARE INDEXED BY TIME. Summing a
|
|
89
|
+
# measure over successive periods is valid by construction; summing it over arbitrary row keys is
|
|
90
|
+
# not. PER-ROW arithmetic (a row's own actual against its own budget) is always safe because it
|
|
91
|
+
# involves no cross-row addition at all, and it carries most of the value anyway — "Adjusted EBITDA
|
|
92
|
+
# $5.5M, -18.7% versus prior year" is one row's numbers.
|
|
93
|
+
#
|
|
94
|
+
# `_has_rollup_row` is kept as a second, independent guard for the contribution analysis: if one
|
|
95
|
+
# row equals the sum of the others, the rows are components of a rollup rather than peers.
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Fact:
|
|
100
|
+
"""One code-computed number, with the arithmetic that produced it.
|
|
101
|
+
|
|
102
|
+
`basis` is shown to the model and to the user-facing trace: a derived figure that cannot say
|
|
103
|
+
how it was derived is exactly the kind of unfalsifiable claim this project refuses to make.
|
|
104
|
+
"""
|
|
105
|
+
label: str
|
|
106
|
+
value: float
|
|
107
|
+
basis: str
|
|
108
|
+
unit: str = "" # "" money/count · "%" percent · "pp" percentage points
|
|
109
|
+
|
|
110
|
+
def render(self) -> str:
|
|
111
|
+
if self.unit == "%":
|
|
112
|
+
num = f"{self.value:+,.1f}%"
|
|
113
|
+
elif self.unit == "pp":
|
|
114
|
+
num = f"{self.value:+,.1f} pp"
|
|
115
|
+
else:
|
|
116
|
+
num = f"{self.value:,.2f}"
|
|
117
|
+
return f"{self.label} = {num} [{self.basis}]"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------------- #
|
|
121
|
+
# Column inspection
|
|
122
|
+
# --------------------------------------------------------------------------- #
|
|
123
|
+
def _num(v) -> float | None:
|
|
124
|
+
"""A cell → float, or None when it isn't a real number.
|
|
125
|
+
|
|
126
|
+
COERCES rather than isinstance-tests. Postgres returns `Decimal` for every numeric column,
|
|
127
|
+
which is neither `int` nor `float`; a strict type check therefore skips every money column and
|
|
128
|
+
yields an empty result against the live warehouse while passing every test written with float
|
|
129
|
+
literals. That exact trap has already been paid for once in `sql_loader._describe_stdlib`.
|
|
130
|
+
Strings are not coerced — a numeric-looking label is a label, not a measure.
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(v, bool) or isinstance(v, str) or v is None:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
f = float(v)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
return None
|
|
138
|
+
return None if f != f or f in (float("inf"), float("-inf")) else f
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def numeric_columns(rows: list[dict]) -> dict[str, list[float]]:
|
|
142
|
+
"""`{column: values}` for every column numeric in at least one row, skipping id-like columns.
|
|
143
|
+
|
|
144
|
+
Rows with a missing/non-numeric cell contribute nothing for that column, so a column that is
|
|
145
|
+
numeric in only some rows still yields facts over the rows where it is real.
|
|
146
|
+
"""
|
|
147
|
+
cols: dict[str, list[float]] = {}
|
|
148
|
+
for row in rows:
|
|
149
|
+
if not isinstance(row, dict):
|
|
150
|
+
continue
|
|
151
|
+
for k, v in row.items():
|
|
152
|
+
if _ID_LIKE.search(k or "") or _CALENDAR_COL.match(k or ""):
|
|
153
|
+
continue
|
|
154
|
+
if (f := _num(v)) is not None:
|
|
155
|
+
cols.setdefault(k, []).append(f)
|
|
156
|
+
return cols
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def label_of(row: dict) -> str:
|
|
160
|
+
"""Best-effort human label for a row — a period/name/label field, else the first string."""
|
|
161
|
+
if not isinstance(row, dict):
|
|
162
|
+
return "?"
|
|
163
|
+
for k, v in row.items():
|
|
164
|
+
if isinstance(v, str) and v.strip() and any(t in k.lower() for t in _LABEL_HINT):
|
|
165
|
+
return v.strip()
|
|
166
|
+
for k, v in row.items():
|
|
167
|
+
if isinstance(v, str) and v.strip() and not _ID_LIKE.search(k):
|
|
168
|
+
return v.strip()
|
|
169
|
+
return "?"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _classify(col: str) -> tuple[str, str]:
|
|
173
|
+
"""`column -> (role, stem)`.
|
|
174
|
+
|
|
175
|
+
role ∈ {actual, delta_ly, delta_budget, delta, level_prior, level_budget, pct, other}
|
|
176
|
+
stem is the column with its role marker stripped, so `rev_actual` and `rev_vs_ly` share the
|
|
177
|
+
stem "rev" and can be paired. A flat schema (`actual` / `vs_ly`) shares the empty stem.
|
|
178
|
+
|
|
179
|
+
Order matters: `vs_ly_pct` is a percentage, not a delta, so pct is tested first; and
|
|
180
|
+
`prior_year_actual` is a prior LEVEL, not an actual, so level markers are tested before
|
|
181
|
+
`_ACTUAL`.
|
|
182
|
+
"""
|
|
183
|
+
c = col or ""
|
|
184
|
+
def stem(pattern: re.Pattern) -> str:
|
|
185
|
+
return re.sub(r"_+", "_", pattern.sub("_", c)).strip("_").replace("actual", "").strip("_")
|
|
186
|
+
|
|
187
|
+
if _PCT_COL.search(c):
|
|
188
|
+
return "pct", stem(_PCT_COL)
|
|
189
|
+
if _DELTA_LY.search(c):
|
|
190
|
+
return "delta_ly", stem(_DELTA_LY)
|
|
191
|
+
if _DELTA_BUDGET.search(c):
|
|
192
|
+
return "delta_budget", stem(_DELTA_BUDGET)
|
|
193
|
+
if _LEVEL_PRIOR.search(c):
|
|
194
|
+
return "level_prior", stem(_LEVEL_PRIOR)
|
|
195
|
+
if _LEVEL_BUDGET.search(c):
|
|
196
|
+
return "level_budget", stem(_LEVEL_BUDGET)
|
|
197
|
+
if _DELTA_PLAIN.search(c):
|
|
198
|
+
return "delta", stem(_DELTA_PLAIN)
|
|
199
|
+
if _ACTUAL.search(c):
|
|
200
|
+
return "actual", stem(_ACTUAL)
|
|
201
|
+
# A bare metric name (`ebitda`, `rev`, `curr_val`) IS the current-period level — the warehouse
|
|
202
|
+
# only spells out "actual" when a comparison column sits beside it in the same grid. Defaulting
|
|
203
|
+
# these to `actual` is what lets `ebitda` pair with `ebitda_prior`.
|
|
204
|
+
return "actual", c
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
_ISO_DATE = re.compile(r"^\s*(\d{4})-(\d{2})(?:-(\d{2}))?")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _as_period(v) -> tuple | None:
|
|
211
|
+
"""A cell → a sortable period key, or None when it isn't a period.
|
|
212
|
+
|
|
213
|
+
Detected by VALUE, not by column name, so this works on any warehouse: a `date`/`datetime`
|
|
214
|
+
object, or a string starting with an ISO year-month. Anything else is not a period.
|
|
215
|
+
"""
|
|
216
|
+
if hasattr(v, "year") and hasattr(v, "month"): # date / datetime
|
|
217
|
+
return (v.year, v.month, getattr(v, "day", 1))
|
|
218
|
+
if isinstance(v, str) and (m := _ISO_DATE.match(v)):
|
|
219
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 1))
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def period_column(rows: list[dict]) -> str | None:
|
|
224
|
+
"""The column that indexes these rows by TIME, or None.
|
|
225
|
+
|
|
226
|
+
Requires every row to carry a parseable period, all distinct, in non-decreasing order — i.e.
|
|
227
|
+
a genuine time series as the warehouse returns it. This is the sole licence for cross-row
|
|
228
|
+
aggregation (see the guard note above).
|
|
229
|
+
"""
|
|
230
|
+
if len(rows) < 2 or not isinstance(rows[0], dict):
|
|
231
|
+
return None
|
|
232
|
+
for col in rows[0]:
|
|
233
|
+
keys = [_as_period(r.get(col)) for r in rows if isinstance(r, dict)]
|
|
234
|
+
if len(keys) != len(rows) or any(k is None for k in keys):
|
|
235
|
+
continue
|
|
236
|
+
if len(set(keys)) == len(keys) and keys == sorted(keys):
|
|
237
|
+
return col
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _has_rollup_row(vals: list[float]) -> bool:
|
|
242
|
+
"""True when one value equals the sum of the others — the rows are components of a total,
|
|
243
|
+
not peers, so ranking or combining them double-counts."""
|
|
244
|
+
for i, v in enumerate(vals):
|
|
245
|
+
others = sum(x for j, x in enumerate(vals) if j != i)
|
|
246
|
+
if abs(v - others) <= max(1.0, abs(v) * 0.001):
|
|
247
|
+
return True
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _pct(delta: float, base: float) -> float | None:
|
|
252
|
+
"""Percent change, or None when the base is zero/absent — a percentage off a zero base is
|
|
253
|
+
meaningless (it is how "-100.3% YoY" got reported as a business fact off a broken load)."""
|
|
254
|
+
if not base:
|
|
255
|
+
return None
|
|
256
|
+
return round(delta / abs(base) * 100.0, 1)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# --------------------------------------------------------------------------- #
|
|
260
|
+
# Fact producers
|
|
261
|
+
# --------------------------------------------------------------------------- #
|
|
262
|
+
# The metric's identity is often NOT in the column name. Every `fn_rca_*_trend` in this warehouse
|
|
263
|
+
# returns the same generic `actual` / `budget` / `prior_year`, and names what it measures in the
|
|
264
|
+
# FUNCTION: `fn_rca_opendental_hyg_rev_per_patient_trend`. So a per-patient ratio arrived in a
|
|
265
|
+
# column called `actual`, `_NON_ADDITIVE` saw nothing to object to, and twelve months of "hygiene
|
|
266
|
+
# revenue per patient" were added together into a "Total: 10,614.9" — a number that is not a
|
|
267
|
+
# quantity of anything. Set by the caller from the evidence's tool name; blank keeps the old
|
|
268
|
+
# column-only behaviour, so nothing that does not pass it can change.
|
|
269
|
+
_CONTEXT_NON_ADDITIVE = _NON_ADDITIVE
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _is_additive(col: str, source: str = "") -> bool:
|
|
273
|
+
"""Can this column be summed across rows? The COLUMN name decides, and the SOURCE can veto."""
|
|
274
|
+
if _NON_ADDITIVE.search(col or ""):
|
|
275
|
+
return False
|
|
276
|
+
# Only a GENERIC column defers to its source. A column that names its own measure is trusted:
|
|
277
|
+
# `fn_..._per_patient_trend` also returns real money columns, and letting the function name
|
|
278
|
+
# veto them all would delete every legitimate total the warehouse ships.
|
|
279
|
+
if source and col.lower() in _GENERIC_MEASURE and _CONTEXT_NON_ADDITIVE.search(source):
|
|
280
|
+
return False
|
|
281
|
+
return True
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# Columns that carry no measure identity of their own — whatever the operation happens to return.
|
|
285
|
+
_GENERIC_MEASURE = frozenset({
|
|
286
|
+
"actual", "value", "current", "current_value", "curr_val", "amount", "total",
|
|
287
|
+
"budget", "prior_year", "prior", "prior_value", "last_year", "last_year_val",
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def column_totals(rows: list[dict], source: str = "") -> list[Fact]:
|
|
292
|
+
"""Sum every additive numeric column — TIME SERIES ONLY.
|
|
293
|
+
|
|
294
|
+
Gated on `period_column` because a total is only meaningful when the rows are successive
|
|
295
|
+
periods of the same measure. See the guard note above for the failure this prevents.
|
|
296
|
+
|
|
297
|
+
`source` is the operation the rows came from, and it exists because being a time series is not
|
|
298
|
+
on its own a licence to add: twelve successive months of hygiene revenue PER PATIENT are a
|
|
299
|
+
perfectly good series whose sum means nothing. The measure's name lives in the function, not in
|
|
300
|
+
the generic `actual` column, so without it the guard has nothing to see (`_is_additive`).
|
|
301
|
+
"""
|
|
302
|
+
period = period_column(rows)
|
|
303
|
+
if not period:
|
|
304
|
+
return []
|
|
305
|
+
span = f"{label_of(rows[0])} to {label_of(rows[-1])}"
|
|
306
|
+
return [Fact(f"{col}: total", round(sum(vals), 2), f"sum of {len(vals)} periods, {span}")
|
|
307
|
+
for col, vals in numeric_columns(rows).items()
|
|
308
|
+
if len(vals) >= 2 and _is_additive(col, source) and _classify(col)[0] != "pct"]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _pairs_in(cols) -> list[tuple[str, str, str]]:
|
|
312
|
+
"""Every `(actual_column, comparison_column, against)` pair implied by the column names."""
|
|
313
|
+
roles = {c: _classify(c) for c in cols}
|
|
314
|
+
actuals = {stem: c for c, (role, stem) in roles.items() if role == "actual"}
|
|
315
|
+
# Flat single-actual fallback: `curr_val` + `delta` don't share a stem, but if the table has
|
|
316
|
+
# exactly one actual-like column the pairing is unambiguous.
|
|
317
|
+
lone = next(iter(actuals.values())) if len(actuals) == 1 else None
|
|
318
|
+
out = []
|
|
319
|
+
for col, (role, stem) in roles.items():
|
|
320
|
+
if role not in ("delta_ly", "delta_budget", "delta", "level_prior", "level_budget"):
|
|
321
|
+
continue
|
|
322
|
+
actual_col = actuals.get(stem) or lone
|
|
323
|
+
if actual_col and actual_col != col:
|
|
324
|
+
out.append((actual_col, col, {"delta_ly": "last year", "level_prior": "last year",
|
|
325
|
+
"delta_budget": "budget", "level_budget": "budget"
|
|
326
|
+
}.get(role, "comparison")))
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _compare(actual: float, other: float, is_delta: bool) -> tuple[float, float]:
|
|
331
|
+
"""`(change, base)` for one actual/comparison pair.
|
|
332
|
+
|
|
333
|
+
The two warehouse shapes differ and conflating them halves or doubles every percentage:
|
|
334
|
+
* DELTA column (`rev_vs_ly`) — the change is GIVEN; the comparison level is `actual - change`.
|
|
335
|
+
* LEVEL column (`prior_year_actual`) — the change is `actual - level`.
|
|
336
|
+
"""
|
|
337
|
+
return (other, actual - other) if is_delta else (round(actual - other, 2), other)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def row_comparison_facts(rows: list[dict], focus: "tuple | set" = ()) -> list[Fact]:
|
|
341
|
+
"""Per-row change and percent change against each comparison column.
|
|
342
|
+
|
|
343
|
+
Always safe: every figure comes from a single row, so no cross-row addition happens and the
|
|
344
|
+
homogeneity of the rows is irrelevant. Emitted exhaustively for a small result (a KPI-card
|
|
345
|
+
list, a handful of locations); larger grids are covered by `contribution_facts` instead, which
|
|
346
|
+
surfaces the movers that matter rather than all N rows.
|
|
347
|
+
|
|
348
|
+
Skipped entirely for a TIME SERIES. There, the aggregates (year-to-date total, change versus
|
|
349
|
+
last year over the whole span, growth across the period) ARE the story, and the per-period
|
|
350
|
+
detail is already visible in the rendered table. Emitting twelve monthly comparisons alongside
|
|
351
|
+
them measurably backfired: the model quoted two arbitrary months and omitted the total and the
|
|
352
|
+
year-over-year change entirely, because the useful four facts were buried under a dozen
|
|
353
|
+
lookalikes. Fewer, better facts beat more facts.
|
|
354
|
+
"""
|
|
355
|
+
if period_column(rows) or not rows:
|
|
356
|
+
return []
|
|
357
|
+
if len(rows) > SMALL_TABLE:
|
|
358
|
+
# A BIG GRID still owes its comparisons to the rows the QUESTION is about. Suppressing
|
|
359
|
+
# them wholesale was measured as the largest single scoring defect: `fn_category_view` has
|
|
360
|
+
# no location parameter, so "the category view for Indianapolis, Fort Wayne and Clyde"
|
|
361
|
+
# fetches all 41 locations, per-row facts were dropped, and the answer could only state
|
|
362
|
+
# "revenue actual is 0.0, staff actual is 0.0 …" — no versus-last-year, no versus-budget,
|
|
363
|
+
# because the prompt forbids computing them and none had been supplied. Seven cases in one
|
|
364
|
+
# run stated no comparison at all while the data carried it.
|
|
365
|
+
#
|
|
366
|
+
# `focus` names the entities the question asked about; only those rows get their
|
|
367
|
+
# comparisons, so the "fewer, better facts" reasoning below still holds for the rest.
|
|
368
|
+
if not focus:
|
|
369
|
+
return []
|
|
370
|
+
want = {f.strip().lower() for f in focus if f and len(f.strip()) >= 4}
|
|
371
|
+
rows = [r for r in rows
|
|
372
|
+
if any(w in label_of(r).lower() for w in want)][:SMALL_TABLE]
|
|
373
|
+
if not rows:
|
|
374
|
+
return []
|
|
375
|
+
pairs = _pairs_in(numeric_columns(rows))
|
|
376
|
+
multi = len(rows) > 1
|
|
377
|
+
out: list[Fact] = []
|
|
378
|
+
for row in rows:
|
|
379
|
+
who = f"{label_of(row)}: " if multi else ""
|
|
380
|
+
for actual_col, other_col, against in pairs:
|
|
381
|
+
a, o = _num(row.get(actual_col)), _num(row.get(other_col))
|
|
382
|
+
if a is None or o is None:
|
|
383
|
+
continue
|
|
384
|
+
change, base = _compare(a, o, _classify(other_col)[0].startswith("delta"))
|
|
385
|
+
out.append(Fact(f"{who}{actual_col} vs {against}: change", round(change, 2),
|
|
386
|
+
f"{a:,.2f} against {o:,.2f}"))
|
|
387
|
+
if (p := _pct(change, base)) is not None:
|
|
388
|
+
out.append(Fact(f"{who}{actual_col} vs {against}: change %", p, unit="%",
|
|
389
|
+
basis=f"{change:,.2f} / {abs(base):,.2f}"))
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def series_comparison_facts(rows: list[dict], source: str = "") -> list[Fact]:
|
|
394
|
+
"""Pooled change vs last year / budget over a TIME SERIES — the year-to-date comparison.
|
|
395
|
+
|
|
396
|
+
Gated on `period_column` for the same reason as `column_totals`: this sums each column before
|
|
397
|
+
differencing them, which is only meaningful across periods — and it takes the same `source`
|
|
398
|
+
veto, because a year-to-date comparison of two summed RATIOS is exactly as meaningless as the
|
|
399
|
+
total was. Both sums would be nonsense, so their difference is too.
|
|
400
|
+
"""
|
|
401
|
+
if not period_column(rows):
|
|
402
|
+
return []
|
|
403
|
+
cols = {c: v for c, v in numeric_columns(rows).items() if _is_additive(c, source)}
|
|
404
|
+
sums = {c: round(sum(v), 2) for c, v in cols.items()}
|
|
405
|
+
span = f"{len(rows)} periods, {label_of(rows[0])} to {label_of(rows[-1])}"
|
|
406
|
+
out: list[Fact] = []
|
|
407
|
+
for actual_col, other_col, against in _pairs_in(cols):
|
|
408
|
+
change, base = _compare(sums[actual_col], sums[other_col],
|
|
409
|
+
_classify(other_col)[0].startswith("delta"))
|
|
410
|
+
out.append(Fact(f"{actual_col} vs {against}: change over {len(rows)} periods",
|
|
411
|
+
round(change, 2), f"{sums[actual_col]:,.2f} against {sums[other_col]:,.2f}, {span}"))
|
|
412
|
+
if (p := _pct(change, base)) is not None:
|
|
413
|
+
out.append(Fact(f"{actual_col} vs {against}: change % over {len(rows)} periods", p,
|
|
414
|
+
unit="%", basis=f"{change:,.2f} / {abs(base):,.2f}"))
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def margin_facts(rows: list[dict]) -> list[Fact]:
|
|
419
|
+
"""Margin (profit ÷ revenue) for the row set, and the widest/narrowest per-row margins.
|
|
420
|
+
|
|
421
|
+
A per-location margin is what turns "revenue is flat" into "revenue is flat but the margin
|
|
422
|
+
fell 17 points" — a movement invisible in the level columns alone.
|
|
423
|
+
"""
|
|
424
|
+
cols = numeric_columns(rows)
|
|
425
|
+
prof = next((c for c in cols if _PROFIT_COL.search(c) and _classify(c)[0] == "actual"), None)
|
|
426
|
+
rev = next((c for c in cols if _REVENUE_COL.search(c) and _classify(c)[0] == "actual"), None)
|
|
427
|
+
if not prof or not rev or prof == rev:
|
|
428
|
+
return []
|
|
429
|
+
out: list[Fact] = []
|
|
430
|
+
# An overall margin needs a pooled numerator and denominator, so it is allowed only where
|
|
431
|
+
# pooling is — across periods. Across arbitrary rows the per-row margins below stand alone.
|
|
432
|
+
if period_column(rows):
|
|
433
|
+
tot_p, tot_r = sum(cols[prof]), sum(cols[rev])
|
|
434
|
+
if tot_r:
|
|
435
|
+
out.append(Fact(f"{prof} margin (overall)", round(tot_p / tot_r * 100, 1), unit="%",
|
|
436
|
+
basis=f"{tot_p:,.2f} / {tot_r:,.2f} over {len(rows)} periods"))
|
|
437
|
+
per: list[tuple[float, str]] = []
|
|
438
|
+
for row in rows:
|
|
439
|
+
p, r = _num(row.get(prof)), _num(row.get(rev))
|
|
440
|
+
if p is not None and r:
|
|
441
|
+
per.append((round(p / r * 100, 1), label_of(row)))
|
|
442
|
+
if len(per) >= 2:
|
|
443
|
+
hi, lo = max(per), min(per)
|
|
444
|
+
out.append(Fact(f"{prof} margin: widest", hi[0], unit="%", basis=f"at {hi[1]}"))
|
|
445
|
+
out.append(Fact(f"{prof} margin: narrowest", lo[0], unit="%", basis=f"at {lo[1]}"))
|
|
446
|
+
out.append(Fact(f"{prof} margin: spread", round(hi[0] - lo[0], 1), unit="pp",
|
|
447
|
+
basis=f"{hi[1]} minus {lo[1]}"))
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def contribution_facts(rows: list[dict]) -> list[Fact]:
|
|
452
|
+
"""Top-N contributors to a change, and how concentrated that change is.
|
|
453
|
+
|
|
454
|
+
This is the "three locations account for $1.1M of the decline" shape — the single most
|
|
455
|
+
load-bearing sentence in an executive briefing, and one the model must never estimate.
|
|
456
|
+
"""
|
|
457
|
+
if len(rows) < 3:
|
|
458
|
+
return []
|
|
459
|
+
cols = numeric_columns(rows)
|
|
460
|
+
delta_col = next((c for c, (role, _) in ((c, _classify(c)) for c in cols)
|
|
461
|
+
if role in ("delta_ly", "delta_budget", "delta")), None)
|
|
462
|
+
if not delta_col:
|
|
463
|
+
return []
|
|
464
|
+
pairs = [(f, label_of(r)) for r in rows if (f := _num(r.get(delta_col))) is not None]
|
|
465
|
+
# Second guard, independent of the time-series rule: if one row equals the sum of the others,
|
|
466
|
+
# these rows are components of a rollup rather than peers, so ranking them and calling the
|
|
467
|
+
# leader a "top contributor" double-counts the total it is being measured against.
|
|
468
|
+
if len(pairs) < 3 or _has_rollup_row([v for v, _ in pairs]):
|
|
469
|
+
return []
|
|
470
|
+
falls = sorted((p for p in pairs if p[0] < 0), key=lambda p: p[0])
|
|
471
|
+
top = falls[:TOP_N]
|
|
472
|
+
out: list[Fact] = []
|
|
473
|
+
for val, name in top:
|
|
474
|
+
out.append(Fact(f"{delta_col}: {name}", round(val, 2), "row value"))
|
|
475
|
+
if not top:
|
|
476
|
+
return out
|
|
477
|
+
share = sum(v for v, _ in top)
|
|
478
|
+
out.append(Fact(f"{delta_col}: worst {len(top)} combined", round(share, 2),
|
|
479
|
+
basis=" + ".join(n for _, n in top)))
|
|
480
|
+
# Share is measured against the TOTAL DECLINE (the sum of the negative movers), not the net
|
|
481
|
+
# change. Against the net it exceeds 100% whenever gainers offset losers — arithmetically
|
|
482
|
+
# true, but "the top 3 are 108.7% of the change" reads as an error to an executive and
|
|
483
|
+
# invites them to distrust the rest of the briefing.
|
|
484
|
+
decline = sum(v for v, _ in falls)
|
|
485
|
+
# A share is only informative when something was left out; "the worst 3 are 100% of the
|
|
486
|
+
# decline" when exactly 3 rows declined states nothing and reads as padding.
|
|
487
|
+
if len(falls) > len(top) and (p := _pct(share, decline)) is not None:
|
|
488
|
+
out.append(Fact(f"{delta_col}: worst {len(top)} share of the total decline", abs(p),
|
|
489
|
+
unit="%", basis=f"{share:,.2f} of {decline:,.2f} across "
|
|
490
|
+
f"{len(falls)} declining rows"))
|
|
491
|
+
return out
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def series_facts(rows: list[dict]) -> list[Fact]:
|
|
495
|
+
"""First/last levels and the growth between them, for a time-ordered table.
|
|
496
|
+
|
|
497
|
+
Only fires when a period column is present AND rows are already in period order, which is how
|
|
498
|
+
the warehouse returns trends. Growth is stated over the actual span so "+11% over 12 months"
|
|
499
|
+
can never be mistaken for a monthly rate.
|
|
500
|
+
"""
|
|
501
|
+
if len(rows) < 3 or not period_column(rows):
|
|
502
|
+
return []
|
|
503
|
+
out: list[Fact] = []
|
|
504
|
+
for col, vals in numeric_columns(rows).items():
|
|
505
|
+
if len(vals) < 3 or not _is_additive(col) or _classify(col)[0] in ("pct", "delta",
|
|
506
|
+
"delta_ly", "delta_budget"):
|
|
507
|
+
continue
|
|
508
|
+
first, last = vals[0], vals[-1]
|
|
509
|
+
span = f"{label_of(rows[0])} to {label_of(rows[-1])}"
|
|
510
|
+
out.append(Fact(f"{col}: change over the period", round(last - first, 2),
|
|
511
|
+
f"{last:,.2f} at {label_of(rows[-1])} minus {first:,.2f} at {label_of(rows[0])}"))
|
|
512
|
+
if (p := _pct(last - first, first)) is not None:
|
|
513
|
+
out.append(Fact(f"{col}: change over the period %", p, unit="%", basis=span))
|
|
514
|
+
return out
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# --------------------------------------------------------------------------- #
|
|
518
|
+
# ATTRIBUTION — why a number moved, derived from structured data
|
|
519
|
+
# --------------------------------------------------------------------------- #
|
|
520
|
+
# The incumbent agent's most impressive answers quote manager commentary ("Dr. Peterson on personal
|
|
521
|
+
# leave since late Feb"). That data does not exist in this warehouse and cannot be obtained, so the
|
|
522
|
+
# causal story is reconstructed from the numbers instead — which is reproducible and checkable
|
|
523
|
+
# where a quoted note is neither.
|
|
524
|
+
#
|
|
525
|
+
# Two independent detectors, both self-gating on the SHAPE of the result, so neither needs to know
|
|
526
|
+
# which function produced it:
|
|
527
|
+
# * `decomposition_facts` — "the change splits into price / volume / mix"
|
|
528
|
+
# * `provider_continuity_facts` — "this earner's output stopped in March"
|
|
529
|
+
|
|
530
|
+
def _additive_identity(rows: list[dict]) -> tuple[str, list[str]] | None:
|
|
531
|
+
"""Find `(total_column, [component_columns])` where the components SUM to the total in every
|
|
532
|
+
row — i.e. the result is already a decomposition of a change into its drivers.
|
|
533
|
+
|
|
534
|
+
Detected ARITHMETICALLY rather than by column name, so this works on any warehouse that ships
|
|
535
|
+
a decomposition under any naming convention. An identity that holds across every row of a
|
|
536
|
+
real result is not a coincidence: with 100+ rows a spurious 3-column match is vanishingly
|
|
537
|
+
unlikely, and the all-rows requirement is what makes name-free detection safe here.
|
|
538
|
+
"""
|
|
539
|
+
cols = {c: v for c, v in numeric_columns(rows).items() if len(v) == len(rows)}
|
|
540
|
+
# A decomposition splits a CHANGE into its drivers, so every column in it — the total and each
|
|
541
|
+
# component — must be able to go negative. This is what separates the useful identity from the
|
|
542
|
+
# trivial one: a result carrying `curr_prod`, `prior_prod` and `total_delta` satisfies BOTH
|
|
543
|
+
# `total_delta = mix + price + volume` and `curr_prod = prior_prod + total_delta`, and the
|
|
544
|
+
# second is a bigger number but says nothing — it just restates that the level moved. Level
|
|
545
|
+
# columns are never negative; delta columns are, so requiring a negative excludes them.
|
|
546
|
+
signed = {c: v for c, v in cols.items() if any(x < 0 for x in v)}
|
|
547
|
+
if len(signed) < 3:
|
|
548
|
+
return None
|
|
549
|
+
names = sorted(signed)
|
|
550
|
+
best: tuple[str, list[str]] | None = None
|
|
551
|
+
for total in names:
|
|
552
|
+
others = [c for c in names if c != total]
|
|
553
|
+
if len(others) > 12: # keep the combinatorics bounded on a wide table
|
|
554
|
+
others = others[:12]
|
|
555
|
+
# THREE components minimum. A two-way split is an ACCOUNTING IDENTITY, not a decomposition
|
|
556
|
+
# of drivers: `revenue = ebitda + expenses` is exactly true on every row and says only that
|
|
557
|
+
# the books balance. Live, it was picked up on a financial trend and produced "change
|
|
558
|
+
# attributed to expenses" — a meaningless attribution, which then let the repair below
|
|
559
|
+
# overwrite an unrelated number in the prose. A driver split (price / volume / mix) has
|
|
560
|
+
# three or more.
|
|
561
|
+
for size in (3, 4):
|
|
562
|
+
for combo in itertools.combinations(others, size):
|
|
563
|
+
if all(abs(sum(r[c] for c in combo) - r[total]) <= max(0.01, abs(r[total]) * 1e-6)
|
|
564
|
+
for r in ({k: _num(v) for k, v in row.items()} for row in rows)
|
|
565
|
+
if all(r.get(c) is not None for c in combo) and r.get(total) is not None):
|
|
566
|
+
cand = (total, list(combo))
|
|
567
|
+
# Prefer the identity over the LARGEST total — among genuine change
|
|
568
|
+
# decompositions that is the headline one, not an internal sub-total.
|
|
569
|
+
if best is None or abs(sum(signed[total])) > abs(sum(signed[best[0]])):
|
|
570
|
+
best = cand
|
|
571
|
+
break
|
|
572
|
+
return best
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _shared_affix(names: list[str]) -> str:
|
|
576
|
+
"""The common trailing token shared by every component column (`mix_delta`, `price_delta`,
|
|
577
|
+
`volume_delta` → `_delta`), so labels can be shortened to `mix` / `price` / `volume` without
|
|
578
|
+
hardcoding what the columns are called."""
|
|
579
|
+
parts = [n.split("_") for n in names]
|
|
580
|
+
if len(parts) < 2 or not all(len(p) > 1 for p in parts):
|
|
581
|
+
return ""
|
|
582
|
+
tail = parts[0][-1]
|
|
583
|
+
return f"_{tail}" if all(p[-1] == tail for p in parts) else ""
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def decomposition_facts(rows: list[dict]) -> list[Fact]:
|
|
587
|
+
"""Split a change into the drivers that produced it — the "why", not just the "what".
|
|
588
|
+
|
|
589
|
+
This is the analysis the incumbent explicitly told us it does NOT have ("a formal
|
|
590
|
+
volume/mix/price analysis is not available"), while our warehouse ships it per procedure code.
|
|
591
|
+
Rolling the per-item rows up to `total = A + B + C` turns a 162-row table nobody reads into
|
|
592
|
+
one sentence: the change was $X, of which $A came from price, $B from volume, and mix cost $C.
|
|
593
|
+
"""
|
|
594
|
+
if len(rows) < 3:
|
|
595
|
+
return []
|
|
596
|
+
ident = _additive_identity(rows)
|
|
597
|
+
if not ident:
|
|
598
|
+
return []
|
|
599
|
+
total_col, comps = ident
|
|
600
|
+
cols = numeric_columns(rows)
|
|
601
|
+
total = round(sum(cols[total_col]), 2)
|
|
602
|
+
if not total:
|
|
603
|
+
return []
|
|
604
|
+
affix = _shared_affix(comps)
|
|
605
|
+
|
|
606
|
+
def nice(c: str) -> str:
|
|
607
|
+
return (c[:-len(affix)] if affix and c.endswith(affix) else c).replace("_", " ")
|
|
608
|
+
|
|
609
|
+
out = [Fact("total change", total, f"sum of {len(rows)} items")]
|
|
610
|
+
# Largest absolute contribution first — the driver that explains the move leads.
|
|
611
|
+
for comp in sorted(comps, key=lambda c: -abs(sum(cols[c]))):
|
|
612
|
+
val = round(sum(cols[comp]), 2)
|
|
613
|
+
out.append(Fact(f"change attributed to {nice(comp)}", val,
|
|
614
|
+
f"sum of {comp} across {len(rows)} items"))
|
|
615
|
+
# SIGNED share, unlike `contribution_facts`' share-of-a-decline: here the sign is the
|
|
616
|
+
# whole point. Price and volume ADDED to the move while mix SUBTRACTED from it, and an
|
|
617
|
+
# unsigned "62.3%" next to a negative dollar figure reads as a contradiction.
|
|
618
|
+
if (p := _pct(val, total)) is not None:
|
|
619
|
+
out.append(Fact(f"change attributed to {nice(comp)}: share of the move", p,
|
|
620
|
+
unit="%", basis=f"{val:,.2f} of {total:,.2f}"))
|
|
621
|
+
return out
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# A provider/employee production series: one row per person per period. Detected structurally —
|
|
625
|
+
# a repeating period column plus a text key — never by column name.
|
|
626
|
+
_COLLAPSE_RATIO = 0.25 # below a quarter of the person's own median = a discontinuity, not a dip
|
|
627
|
+
_ARTIFACT_SHARE = 0.6 # this share of people collapsing AT ONCE means the PERIOD is incomplete
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _entity_period_value(rows: list[dict]) -> tuple[list[str], str, str] | None:
|
|
631
|
+
"""`(entity_columns, period_column, value_column)` for a per-entity-per-period table."""
|
|
632
|
+
if len(rows) < 6 or not isinstance(rows[0], dict):
|
|
633
|
+
return None
|
|
634
|
+
period = next((c for c in rows[0]
|
|
635
|
+
if all(_as_period(r.get(c)) is not None for r in rows)
|
|
636
|
+
and len({_as_period(r.get(c)) for r in rows}) < len(rows)), None)
|
|
637
|
+
if not period:
|
|
638
|
+
return None # every period distinct → a plain trend, not this
|
|
639
|
+
entity = [c for c in rows[0]
|
|
640
|
+
if c != period and all(isinstance(r.get(c), str) for r in rows)]
|
|
641
|
+
nums = [c for c, v in numeric_columns(rows).items() if _is_additive(c) and len(v) == len(rows)]
|
|
642
|
+
if not entity or len(nums) != 1:
|
|
643
|
+
return None
|
|
644
|
+
return entity, period, nums[0]
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def provider_continuity_facts(rows: list[dict]) -> list[Fact]:
|
|
648
|
+
"""Detect an earner whose output STOPPED — the structured, dated shadow of the staffing event
|
|
649
|
+
a manager's note would have described.
|
|
650
|
+
|
|
651
|
+
Deliberately excludes an INCOMPLETE trailing period, detected structurally: if most people
|
|
652
|
+
collapse in the same final period, that is a partial data load, not simultaneous resignations.
|
|
653
|
+
Verified on the live warehouse — 9% of providers "collapsed" in each of the last two complete
|
|
654
|
+
months versus **100%** in the partial current month. Without this the detector would announce
|
|
655
|
+
that the entire clinical staff quit, every single month.
|
|
656
|
+
|
|
657
|
+
Reports nothing when nothing dropped out. That is the honest answer, and the whole point: it
|
|
658
|
+
is what lets an analysis say "the driver is not identifiable from the data" instead of
|
|
659
|
+
inventing a cause.
|
|
660
|
+
"""
|
|
661
|
+
shape = _entity_period_value(rows)
|
|
662
|
+
if not shape:
|
|
663
|
+
return []
|
|
664
|
+
ent_cols, period, value = shape
|
|
665
|
+
series: dict[str, dict] = {}
|
|
666
|
+
for r in rows:
|
|
667
|
+
key = " ".join(str(r[c]).strip() for c in ent_cols if r.get(c))
|
|
668
|
+
p, v = _as_period(r.get(period)), _num(r.get(value))
|
|
669
|
+
if key and p and v is not None:
|
|
670
|
+
series.setdefault(key, {})[p] = v
|
|
671
|
+
periods = sorted({p for s in series.values() for p in s})
|
|
672
|
+
if len(periods) < 4 or len(series) < 2:
|
|
673
|
+
return []
|
|
674
|
+
|
|
675
|
+
def collapsed(at, known: list) -> list[tuple[str, float, float]]:
|
|
676
|
+
"""(name, own median before `at`, value at `at`) for everyone who fell off a cliff.
|
|
677
|
+
|
|
678
|
+
A missing row is only read as zero when the person was ACTIVE IN THE PERIOD IMMEDIATELY
|
|
679
|
+
BEFORE — that is what makes it a stop rather than an absence. Without that check, anyone
|
|
680
|
+
who left years ago has no rows at all, reads as zero forever, and is re-reported as having
|
|
681
|
+
"just stopped" in whatever period is currently last: three long-departed providers were
|
|
682
|
+
announced as June departures on the live data.
|
|
683
|
+
"""
|
|
684
|
+
idx = known.index(at)
|
|
685
|
+
if idx == 0:
|
|
686
|
+
return []
|
|
687
|
+
just_before = known[idx - 1]
|
|
688
|
+
hits = []
|
|
689
|
+
for name, s in series.items():
|
|
690
|
+
prior = [v for p, v in s.items() if p < at]
|
|
691
|
+
was_active = s.get(just_before, 0.0)
|
|
692
|
+
if len(prior) < 3 or was_active <= 1000:
|
|
693
|
+
continue
|
|
694
|
+
base = statistics.median(prior[-6:]) # their own recent run-rate, not a lifetime mean
|
|
695
|
+
cur = s.get(at, 0.0)
|
|
696
|
+
if base > 1000 and cur < base * _COLLAPSE_RATIO:
|
|
697
|
+
hits.append((name, round(base, 2), round(cur, 2)))
|
|
698
|
+
return hits
|
|
699
|
+
|
|
700
|
+
last = periods[-1]
|
|
701
|
+
# Everyone who was active in the period before `last` is a candidate to have stopped at it;
|
|
702
|
+
# if MOST of them appear to have stopped at once, the period is a partial load, not an exodus.
|
|
703
|
+
eligible = sum(1 for s in series.values()
|
|
704
|
+
if len(s) >= 4 and s.get(periods[-2], 0.0) > 1000)
|
|
705
|
+
if eligible and len(collapsed(last, periods)) >= _ARTIFACT_SHARE * eligible:
|
|
706
|
+
periods = periods[:-1] # partial load — not a staffing event
|
|
707
|
+
if len(periods) < 4:
|
|
708
|
+
return []
|
|
709
|
+
last = periods[-1]
|
|
710
|
+
|
|
711
|
+
hits = sorted(collapsed(last, periods), key=lambda h: -(h[1] - h[2]))[:TOP_N]
|
|
712
|
+
when = f"{last[0]}-{last[1]:02d}"
|
|
713
|
+
return [Fact(f"production stopped: {name}", round(cur, 2),
|
|
714
|
+
f"ran at {base:,.2f}/period before {when}, then {cur:,.2f} in {when}")
|
|
715
|
+
for name, base, cur in hits]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# --------------------------------------------------------------------------- #
|
|
719
|
+
# DATA QUALITY — values that are almost certainly a loading fault, not a result
|
|
720
|
+
# --------------------------------------------------------------------------- #
|
|
721
|
+
# A broken load looks exactly like a catastrophic business event, and the difference is invisible
|
|
722
|
+
# to grounding: the figure IS in the data, so a "-100.3% year over year" reads as fully verified.
|
|
723
|
+
# Measured on this warehouse — enterprise revenue runs ~$5.2M/month for ten months, then reports
|
|
724
|
+
# -$32,063 and -$13,748, and expenses go NEGATIVE. Left unflagged, an answer reports that as a
|
|
725
|
+
# collapse and an executive acts on it.
|
|
726
|
+
#
|
|
727
|
+
# Every check is convention-free. Nothing here knows that "revenue" should be positive or that
|
|
728
|
+
# "expenses" are stored negative — sign conventions differ per table and per warehouse. What it
|
|
729
|
+
# knows is that a column which held ONE sign for months does not legitimately flip, and that a
|
|
730
|
+
# metric running at millions does not legitimately land near zero. Both are properties of the
|
|
731
|
+
# series, not of the domain.
|
|
732
|
+
|
|
733
|
+
COLLAPSE_FLOOR = 0.10 # under a tenth of the established run-rate is a fault, not a bad month
|
|
734
|
+
STABLE_MIN = 4 # periods needed before "normal" is established at all
|
|
735
|
+
ZERO_SHARE = 0.6 # this share of entities reporting nothing = the load, not the business
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@dataclass(frozen=True)
|
|
739
|
+
class Flag:
|
|
740
|
+
"""One data-quality finding. `where` locates it, `detail` states the evidence for it."""
|
|
741
|
+
kind: str # collapse | sign_flip | all_null | zero_population
|
|
742
|
+
column: str
|
|
743
|
+
where: str
|
|
744
|
+
detail: str
|
|
745
|
+
|
|
746
|
+
def render(self) -> str:
|
|
747
|
+
return f"{self.column} — {self.detail} ({self.where})"
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _series_flags(rows: list[dict], period: str) -> list[Flag]:
|
|
751
|
+
"""Trailing periods where a column breaks from its own established behaviour."""
|
|
752
|
+
out: list[Flag] = []
|
|
753
|
+
# Label from the PERIOD column itself, not `label_of` — the period is frequently a `date`
|
|
754
|
+
# object, and `label_of` only considers string fields, so every flag came out labelled "?".
|
|
755
|
+
labels = []
|
|
756
|
+
for r in rows:
|
|
757
|
+
p = _as_period(r.get(period))
|
|
758
|
+
labels.append(f"{p[0]}-{p[1]:02d}" if p else label_of(r))
|
|
759
|
+
for col, vals in numeric_columns(rows).items():
|
|
760
|
+
if len(vals) != len(rows) or not _is_additive(col) or _classify(col)[0] == "pct":
|
|
761
|
+
continue
|
|
762
|
+
bad: list[tuple[int, str]] = []
|
|
763
|
+
for i in range(len(vals) - 1, STABLE_MIN - 1, -1):
|
|
764
|
+
prior = vals[:i]
|
|
765
|
+
if len(prior) < STABLE_MIN:
|
|
766
|
+
break
|
|
767
|
+
base = statistics.median([abs(v) for v in prior])
|
|
768
|
+
if base < 1000: # too small for "collapse" to mean anything
|
|
769
|
+
break
|
|
770
|
+
signs = {v > 0 for v in prior if v}
|
|
771
|
+
flipped = len(signs) == 1 and vals[i] and (vals[i] > 0) not in signs
|
|
772
|
+
collapsed = abs(vals[i]) < base * COLLAPSE_FLOOR
|
|
773
|
+
if not (flipped or collapsed):
|
|
774
|
+
break # the trailing run of anomalies ends here
|
|
775
|
+
bad.append((i, "sign_flip" if flipped else "collapse"))
|
|
776
|
+
for i, kind in reversed(bad):
|
|
777
|
+
base = statistics.median([abs(v) for v in vals[:i]])
|
|
778
|
+
out.append(Flag(
|
|
779
|
+
kind=kind, column=col, where=labels[i],
|
|
780
|
+
detail=(f"{vals[i]:,.2f} against a {base:,.2f} run-rate over the preceding "
|
|
781
|
+
f"{i} periods"
|
|
782
|
+
+ (" — and the opposite sign to every one of them" if kind == "sign_flip"
|
|
783
|
+
else ""))))
|
|
784
|
+
return out
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _population_flags(rows: list[dict]) -> list[Flag]:
|
|
788
|
+
"""A per-entity result where most entities report nothing.
|
|
789
|
+
|
|
790
|
+
Deliberately a NOTE, never a rejection: a real location with no activity legitimately returns
|
|
791
|
+
zero, so this reports the shape of the result and lets the reader judge. It is a majority
|
|
792
|
+
threshold precisely so a handful of genuinely quiet entities never trips it.
|
|
793
|
+
"""
|
|
794
|
+
out: list[Flag] = []
|
|
795
|
+
for col, vals in numeric_columns(rows).items():
|
|
796
|
+
if len(vals) != len(rows) or len(vals) < 5 or not _is_additive(col):
|
|
797
|
+
continue
|
|
798
|
+
if _classify(col)[0] != "actual": # a delta is legitimately zero for a flat entity
|
|
799
|
+
continue
|
|
800
|
+
quiet = sum(1 for v in vals if abs(v) < 1)
|
|
801
|
+
if quiet >= ZERO_SHARE * len(vals) and max(abs(v) for v in vals) > 1000:
|
|
802
|
+
out.append(Flag(kind="zero_population", column=col,
|
|
803
|
+
where=f"{quiet} of {len(vals)} rows",
|
|
804
|
+
detail="report no value while others in the same result do"))
|
|
805
|
+
return out
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def data_quality_flags(rows: list[dict]) -> list[Flag]:
|
|
809
|
+
"""Values that are almost certainly a data fault rather than a result. See the note above.
|
|
810
|
+
|
|
811
|
+
Returns [] for healthy data, which is the common case and costs nothing.
|
|
812
|
+
"""
|
|
813
|
+
rows = [r for r in (rows or []) if isinstance(r, dict)]
|
|
814
|
+
if len(rows) < 2:
|
|
815
|
+
return []
|
|
816
|
+
out: list[Flag] = []
|
|
817
|
+
try:
|
|
818
|
+
if period := period_column(rows):
|
|
819
|
+
out += _series_flags(rows, period)
|
|
820
|
+
else:
|
|
821
|
+
out += _population_flags(rows)
|
|
822
|
+
# A column declared by the result but null in EVERY row — the model otherwise reports the
|
|
823
|
+
# metric as though it were measured and found to be nothing.
|
|
824
|
+
keys = {k for r in rows for k in r}
|
|
825
|
+
numeric = set(numeric_columns(rows))
|
|
826
|
+
for k in sorted(keys - numeric):
|
|
827
|
+
if all(r.get(k) is None for r in rows):
|
|
828
|
+
out.append(Flag(kind="all_null", column=k, where=f"all {len(rows)} rows",
|
|
829
|
+
detail="present in the result but empty throughout"))
|
|
830
|
+
except Exception: # a quality check must never be the reason an answer fails
|
|
831
|
+
return out
|
|
832
|
+
return out[:6]
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# --------------------------------------------------------------------------- #
|
|
836
|
+
# Public entry points
|
|
837
|
+
# --------------------------------------------------------------------------- #
|
|
838
|
+
def derive(rows: list[dict], cap: int = MAX_FACTS_PER_ITEM,
|
|
839
|
+
focus: "tuple | set" = (), source: str = "",
|
|
840
|
+
cumulative: bool = False) -> list[Fact]:
|
|
841
|
+
"""Every derived fact for one result set, de-duplicated and capped.
|
|
842
|
+
|
|
843
|
+
Ordered by usefulness to an executive summary, because `cap` truncates the tail: the pooled
|
|
844
|
+
comparisons and contributions carry the story ("down $4.2M, -11.5% versus last year"; "three
|
|
845
|
+
locations are 60% of the decline"), so they must survive the cap. Exhaustive per-row facts go
|
|
846
|
+
last and simply spend whatever budget remains — on a 12-month trend they would otherwise
|
|
847
|
+
crowd out the year-to-date totals they are far less useful than.
|
|
848
|
+
|
|
849
|
+
`cumulative` says each row ALREADY CONTAINS the rows before it — a trailing-twelve-month or
|
|
850
|
+
to-date series. Summing those double-counts every period: measured, a TTM revenue trend was
|
|
851
|
+
added up into "Total: $652,831,245.13", roughly twelve times the revenue that exists, and the
|
|
852
|
+
same rolling values were narrated as "the monthly actual revenue figures" when a month is
|
|
853
|
+
~$5M and the rows read ~$57M. Neither the column name (`actual`) nor the function name
|
|
854
|
+
(`fn_income_statement_trend`) says so — the fact lives in the ARGUMENT (`p_time_period=TTM`),
|
|
855
|
+
which is why `_is_additive`'s column/source test cannot see it and the caller must pass it.
|
|
856
|
+
"""
|
|
857
|
+
rows = [r for r in (rows or []) if isinstance(r, dict)]
|
|
858
|
+
if not rows:
|
|
859
|
+
return []
|
|
860
|
+
facts: list[Fact] = []
|
|
861
|
+
# ATTRIBUTION FIRST. When a result carries a decomposition or a dropped-out earner, that IS
|
|
862
|
+
# the answer to "why did this move" — it must survive the cap ahead of the ordinary totals and
|
|
863
|
+
# comparisons, which are merely restating what the table already shows.
|
|
864
|
+
# WHEN THE QUESTION NAMES ENTITIES, their own comparisons lead. `row_comparison_facts` runs
|
|
865
|
+
# last by default — right for a briefing, where the aggregates carry the story — but the cap
|
|
866
|
+
# then truncates before reaching it, so a question about three named locations got the
|
|
867
|
+
# enterprise decomposition and none of its own figures. Asked for, so first.
|
|
868
|
+
order = (decomposition_facts, provider_continuity_facts,
|
|
869
|
+
series_comparison_facts, contribution_facts, margin_facts, series_facts,
|
|
870
|
+
column_totals, row_comparison_facts)
|
|
871
|
+
if focus:
|
|
872
|
+
order = (row_comparison_facts,) + tuple(p for p in order if p is not row_comparison_facts)
|
|
873
|
+
if cumulative:
|
|
874
|
+
# The two producers that ADD ACROSS ROWS. Everything else — per-row comparisons, margins,
|
|
875
|
+
# extremes — stays: a rolling series has a perfectly good highest and lowest month, and
|
|
876
|
+
# that is most of what a trend question wants.
|
|
877
|
+
order = tuple(p for p in order if p not in (column_totals, series_comparison_facts))
|
|
878
|
+
for producer in order:
|
|
879
|
+
try:
|
|
880
|
+
if producer is row_comparison_facts:
|
|
881
|
+
facts.extend(producer(rows, focus))
|
|
882
|
+
elif producer in (column_totals, series_comparison_facts):
|
|
883
|
+
facts.extend(producer(rows, source)) # these two add ACROSS periods
|
|
884
|
+
else:
|
|
885
|
+
facts.extend(producer(rows))
|
|
886
|
+
except Exception: # a derived fact is an enhancement; never let one break synthesis
|
|
887
|
+
continue
|
|
888
|
+
seen, out = set(), []
|
|
889
|
+
for f in facts:
|
|
890
|
+
if f.label in seen:
|
|
891
|
+
continue
|
|
892
|
+
seen.add(f.label)
|
|
893
|
+
out.append(f)
|
|
894
|
+
return out[:cap]
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def facts_block(by_item: list[tuple[str, list[Fact]]]) -> str:
|
|
898
|
+
"""The DERIVED FACTS section appended to the synthesis prompt.
|
|
899
|
+
|
|
900
|
+
Deliberately emphatic about provenance: the model's standing instruction is that it may not
|
|
901
|
+
compute anything, so these have to read as values it is permitted to quote — otherwise it
|
|
902
|
+
omits them and we are back to the min/max/average ceiling.
|
|
903
|
+
"""
|
|
904
|
+
blocks = [f"[{eid}]\n" + "\n".join(f" - {f.render()}" for f in facts)
|
|
905
|
+
for eid, facts in by_item if facts]
|
|
906
|
+
if not blocks:
|
|
907
|
+
return ""
|
|
908
|
+
return (
|
|
909
|
+
"\n\nDERIVED FACTS — computed by CODE from the evidence above, VERIFIED and safe to quote. "
|
|
910
|
+
"These are the ONLY derived figures you may state (totals, changes, percentages, margins, "
|
|
911
|
+
"shares). Use them verbatim — do NOT recompute, adjust or round them differently, and do "
|
|
912
|
+
"NOT derive any figure that is not listed here. Each is followed by the arithmetic that "
|
|
913
|
+
"produced it; quote the figure, not the arithmetic:\n" + "\n\n".join(blocks)
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
# Facts an executive summary is incomplete without, most important first: the movement against
|
|
918
|
+
# last year, then against budget, then the period total, then growth across the span. Matched as
|
|
919
|
+
# SUBSTRINGS — the labels carry a trailing qualifier ("change over 12 periods"), and an
|
|
920
|
+
# `endswith` test silently dropped the single most important fact of the set.
|
|
921
|
+
_HEADLINE = ("vs last year: change", "vs budget: change", ": total", ": change over the period")
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def headline_facts(facts: list[Fact], limit: int = 3) -> list[Fact]:
|
|
925
|
+
"""The few facts a summary must not omit, most important first.
|
|
926
|
+
|
|
927
|
+
Each absolute figure is returned with its percentage immediately after it, so the guard can
|
|
928
|
+
never emit a bare "-2.6%" with nothing to anchor it. `limit` counts absolute figures, not
|
|
929
|
+
list entries — the percentages ride along.
|
|
930
|
+
"""
|
|
931
|
+
out: list[Fact] = []
|
|
932
|
+
for key in _HEADLINE:
|
|
933
|
+
for f in facts:
|
|
934
|
+
# A total of the COMPARISON column (last year's, budget's) is not a headline — it is
|
|
935
|
+
# the base the headline is measured against, and printing both reads as clutter.
|
|
936
|
+
if key == ": total" and _classify(f.label.split(":")[0])[0] != "actual":
|
|
937
|
+
continue
|
|
938
|
+
if key in f.label and f.unit != "%" and f not in out:
|
|
939
|
+
out.append(f)
|
|
940
|
+
# The percentage's label is the absolute one with "%" inserted, so it does not
|
|
941
|
+
# start with it — pair by normalising the "%" away rather than by prefix.
|
|
942
|
+
pct = next((p for p in facts
|
|
943
|
+
if p.unit == "%" and p.label.replace(" %", "") == f.label), None)
|
|
944
|
+
if pct:
|
|
945
|
+
out.append(pct)
|
|
946
|
+
if sum(1 for x in out if x.unit != "%") >= limit:
|
|
947
|
+
return out
|
|
948
|
+
return out
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _is_movement(f: Fact) -> bool:
|
|
952
|
+
"""A change carries an explicit sign; a level does not."""
|
|
953
|
+
return "change" in f.label or " vs " in f.label
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _fmt(f: Fact, money: str) -> str:
|
|
957
|
+
if f.unit == "%":
|
|
958
|
+
return f"{f.value:+,.1f}%"
|
|
959
|
+
if f.unit == "pp":
|
|
960
|
+
return f"{f.value:+,.1f} pp"
|
|
961
|
+
body = f"{money}{abs(f.value):,.2f}"
|
|
962
|
+
if f.value < 0:
|
|
963
|
+
return f"-{body}"
|
|
964
|
+
return f"+{body}" if _is_movement(f) else body
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def headline_facts_by_item(by_item: list[tuple[str, list[Fact]]], limit: int = 3) -> list[Fact]:
|
|
968
|
+
"""Headline facts from ONE evidence item — the first one that has any. NEVER blended across
|
|
969
|
+
items, and this is the entry point `agent.py` must call (not `headline_facts` on a pooled
|
|
970
|
+
list).
|
|
971
|
+
|
|
972
|
+
The hazard: two evidence items of the same shape (this year's trend, fetched alongside last
|
|
973
|
+
year's trend as its own call for comparison) produce IDENTICALLY LABELED facts with different
|
|
974
|
+
values — both are "actual vs last year: change", one -$2.5M, the other +$2.7M for an unrelated
|
|
975
|
+
older window. `derive()` dedupes by label WITHIN one item, so the collision only exists once
|
|
976
|
+
facts from several items are pooled. Pooling let the wrong item's figure outrank the one the
|
|
977
|
+
narrative actually discussed, appended right beside a different number under the identical
|
|
978
|
+
name — confusing at best, and it silently mispairs the percentage too, since a label-based
|
|
979
|
+
pairing search finds whichever item's percentage comes first, not the one beside the value
|
|
980
|
+
being paired. Scoping to one item at a time is the same principle as this codebase's existing
|
|
981
|
+
ONE SOURCE PER TABLE rule (`agent.py`): each evidence item tells its own story.
|
|
982
|
+
"""
|
|
983
|
+
for _eid, facts in by_item:
|
|
984
|
+
if heads := headline_facts(facts, limit):
|
|
985
|
+
return heads
|
|
986
|
+
return []
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def render_headline(facts: list[Fact], currency: bool = True) -> str:
|
|
990
|
+
"""One compact line of headline figures, each change followed by its percentage in brackets.
|
|
991
|
+
|
|
992
|
+
Pairing normalises the "%" away rather than testing a prefix: the percentage's label is the
|
|
993
|
+
absolute one with "%" INSERTED (`change % over 12 periods`), so it never starts with the
|
|
994
|
+
absolute label, and a prefix test silently split the pair across two clauses.
|
|
995
|
+
"""
|
|
996
|
+
money = "$" if currency else ""
|
|
997
|
+
parts, i = [], 0
|
|
998
|
+
while i < len(facts):
|
|
999
|
+
f = facts[i]
|
|
1000
|
+
nxt = facts[i + 1] if i + 1 < len(facts) else None
|
|
1001
|
+
paired = (nxt is not None and nxt.unit == "%"
|
|
1002
|
+
and nxt.label.replace(" %", "") == f.label)
|
|
1003
|
+
tail = f" ({nxt.value:+,.1f}%)" if paired else ""
|
|
1004
|
+
parts.append(f"{_clean(f.label)} {_fmt(f, money)}{tail}")
|
|
1005
|
+
i += 2 if paired else 1
|
|
1006
|
+
return " · ".join(parts)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _clean(label: str) -> str:
|
|
1010
|
+
"""A fact label → a short phrase a reader can parse without the column name.
|
|
1011
|
+
|
|
1012
|
+
`actual vs last year: change over 12 periods` → `vs last year`
|
|
1013
|
+
`prior_year: total` → `prior year total`
|
|
1014
|
+
`actual: change over the period` → `change across the period`
|
|
1015
|
+
"""
|
|
1016
|
+
s = re.sub(r"\s*over \d+ periods$", "", label)
|
|
1017
|
+
s = re.sub(r"^actual\b\s*", "", s) # the default column name says nothing
|
|
1018
|
+
s = s.replace(": change over the period", "change across the period")
|
|
1019
|
+
s = re.sub(r":\s*change$", "", s)
|
|
1020
|
+
s = s.replace(": total", " total")
|
|
1021
|
+
return re.sub(r"\s+", " ", s.replace("_", " ")).strip(": ").strip() or label
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def fact_values(by_item: list[tuple[str, list[Fact]]]) -> list[float]:
|
|
1025
|
+
"""Every derived value, for the grounding pool.
|
|
1026
|
+
|
|
1027
|
+
Without this the model quotes a figure we handed it and the grounding check flags its own
|
|
1028
|
+
verified number as unsupported — which then sinks the rubric and can trigger an abstain.
|
|
1029
|
+
"""
|
|
1030
|
+
return [f.value for _eid, facts in by_item for f in facts]
|