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.
@@ -0,0 +1,1254 @@
1
+ """Load a Postgres schema's functions as catalog Tools — the SQL (warehouse) backend.
2
+
3
+ The mirror of `openapi_loader`: where that turns OpenAPI operations into HTTP GET tools,
4
+ this introspects a warehouse schema's functions (``pg_proc``) and exposes each one as a
5
+ read-only tool, so the SAME pipeline (router → executor → synthesis → evaluator) runs over
6
+ direct SQL instead of an HTTP API. Parameter names and types come straight from the
7
+ database catalog — ground truth, so the type-inference mismatches an HTTP wrapper can
8
+ introduce (e.g. a text-vs-integer cast on ``p_num_months``) can't happen here.
9
+
10
+ Read-only enforcement is deterministic and layered — never prompt-based:
11
+ * only introspected (whitelisted) functions are callable — the model can NEVER run
12
+ free-form SQL; there is no tool that accepts a SQL string;
13
+ * every connection opens with ``default_transaction_read_only=on`` plus a
14
+ ``statement_timeout``;
15
+ * arguments are server-side bound (named notation with an explicit ``::type`` cast from
16
+ the introspected type), never interpolated into the SQL text;
17
+ * every call is capped with a LIMIT mirroring the HTTP loader's list handling.
18
+ Connect with a SELECT-only role for defense in depth: grants are the real wall — the
19
+ options above make accidents impossible even if a wider role is supplied.
20
+
21
+ Failure classification: results carry a ``kind`` (auth | unreachable | server_error) from
22
+ the Postgres SQLSTATE, so the agent's abstain logic doesn't depend on HTTP status strings.
23
+ Evidence sources are ``sql://host:port/db/schema.fn`` URIs — `urlparse().netloc` yields the
24
+ "host" the agent's failure bookkeeping already keys on.
25
+
26
+ psycopg (v3) is imported lazily so the rest of the package works without it
27
+ (``pip install codi-api-agent[sql]``); tests inject a fake ``connect``.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ import re
34
+ import statistics
35
+ from collections import Counter
36
+ from pathlib import Path
37
+ from urllib.parse import urlparse
38
+
39
+ from .catalog import Catalog, Tool
40
+ from .log import get_logger, trunc
41
+ from .openapi_loader import (
42
+ _REDACTED,
43
+ _compact_json,
44
+ _has_phone_number,
45
+ _is_phone_field,
46
+ _is_pii_key,
47
+ _redact_text,
48
+ )
49
+
50
+ _LOG = get_logger("api_agent.sql")
51
+
52
+
53
+ def _redact_row(row: dict) -> dict:
54
+ """The same PII policy `_compact_json` applies, for the full-width `rows` payload —
55
+ raw rows must never leave the executor with personal/contact values intact."""
56
+ out = {}
57
+ for k, v in row.items():
58
+ if _is_pii_key(k):
59
+ out[k] = _REDACTED
60
+ elif _is_phone_field(k) and _has_phone_number(v):
61
+ out[k] = _REDACTED
62
+ elif isinstance(v, str):
63
+ out[k] = _redact_text(v)
64
+ else:
65
+ out[k] = v
66
+ return out
67
+
68
+ # Introspection: every plain function (prokind='f') in the schema matching the prefix.
69
+ # pg_get_function_identity_arguments gives "p_year integer, p_name character varying" —
70
+ # names + types, no DEFAULT clauses; pronargdefaults says how many TRAILING args have one.
71
+ _INTROSPECT_SQL = """
72
+ SELECT p.proname,
73
+ pg_get_function_identity_arguments(p.oid) AS args,
74
+ obj_description(p.oid, 'pg_proc') AS comment,
75
+ p.pronargdefaults AS num_defaults,
76
+ pg_get_function_result(p.oid) AS result_type
77
+ FROM pg_proc p
78
+ JOIN pg_namespace n ON n.oid = p.pronamespace
79
+ WHERE n.nspname = %(schema)s
80
+ AND p.prokind = 'f'
81
+ AND p.proname LIKE %(pattern)s
82
+ ORDER BY p.proname, p.pronargs DESC, p.oid
83
+ """
84
+ # Ordering note: overloads arrive richest-signature-first (most args), so the fullest
85
+ # variant gets the clean tool name and a legacy short-arg overload gets a `_2` suffix.
86
+
87
+ # Postgres type name -> JSON-schema type the model fills in.
88
+ _PG_INT = {"smallint", "integer", "bigint", "int2", "int4", "int8", "serial", "bigserial"}
89
+ _PG_NUM = {"numeric", "decimal", "real", "double precision", "float4", "float8", "money"}
90
+ _PG_BOOL = {"boolean", "bool"}
91
+
92
+ _ARG_MODES = ("IN ", "INOUT ", "OUT ", "VARIADIC ")
93
+
94
+
95
+ def _json_type(pgtype: str) -> str:
96
+ t = pgtype.strip().lower()
97
+ if t in _PG_INT:
98
+ return "integer"
99
+ if t in _PG_NUM:
100
+ return "number"
101
+ if t in _PG_BOOL:
102
+ return "boolean"
103
+ return "string" # text/varchar/date/timestamp/uuid/json… — the ::cast restores the real type
104
+
105
+
106
+ def parse_identity_args(args_text: str) -> list[tuple[str, str]]:
107
+ """Parse ``pg_get_function_identity_arguments`` output into [(name, pgtype)].
108
+
109
+ Handles multi-word types ("character varying", "timestamp with time zone"), typmod
110
+ parens ("numeric(10,2)") and mode prefixes. OUT args (no name binding needed) are
111
+ skipped; an unnamed arg (type only) gets a positional placeholder name.
112
+ """
113
+ out: list[tuple[str, str]] = []
114
+ if not (args_text or "").strip():
115
+ return out
116
+ # split on top-level commas only (numeric(10,2) has a comma inside parens)
117
+ parts, depth, cur = [], 0, ""
118
+ for ch in args_text:
119
+ if ch == "(":
120
+ depth += 1
121
+ elif ch == ")":
122
+ depth -= 1
123
+ if ch == "," and depth == 0:
124
+ parts.append(cur)
125
+ cur = ""
126
+ else:
127
+ cur += ch
128
+ if cur.strip():
129
+ parts.append(cur)
130
+ for i, part in enumerate(parts):
131
+ p = part.strip()
132
+ mode = "IN"
133
+ for m in _ARG_MODES:
134
+ if p.startswith(m):
135
+ mode = m.strip()
136
+ p = p[len(m):].strip()
137
+ break
138
+ if mode == "OUT": # result column, not a call argument
139
+ continue
140
+ # First token is the arg NAME iff more tokens follow that form a type; a
141
+ # single-token part is an unnamed arg of that type (identity args omit names then).
142
+ tokens = p.split(None, 1)
143
+ if len(tokens) == 2 and re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_$]*", tokens[0]):
144
+ out.append((tokens[0], tokens[1].strip()))
145
+ else:
146
+ out.append((f"arg{i + 1}", p))
147
+ return out
148
+
149
+
150
+ def _humanize(proname: str, fn_prefix: str) -> str:
151
+ core = proname[len(fn_prefix):] if fn_prefix and proname.startswith(fn_prefix) else proname
152
+ return core.replace("_", " ").strip()
153
+
154
+
155
+ # Parameters whose text argument must be one of a fixed set of values (e.g.
156
+ # p_time_period ∈ {MTD, QTD, YTD, TTM}). The DB catalog can't express this — the allowed
157
+ # set lives only in the human parameter docs — so a model given just the type "text" invents
158
+ # values like "month_to_date" that the function silently ignores, returning wrong data. The
159
+ # reference JSON (api_reference_docs/ARQ_SQL_FUNCTIONS.json) records the set in each param's
160
+ # description; we parse it, advertise it to the model as a JSON-schema `enum`, AND reject any
161
+ # out-of-set value at call time (below) so a bad value can never reach SQL.
162
+ DEFAULT_PARAM_DOC_REF = (
163
+ Path(__file__).resolve().parents[1] / "api_reference_docs" / "ARQ_SQL_FUNCTIONS.json"
164
+ )
165
+ _ENUM_TAIL = re.compile(r"\bone of\b(.*)", re.IGNORECASE | re.DOTALL)
166
+ _QUOTED = re.compile(r"'([^']*)'")
167
+ _param_doc_cache: dict[str, tuple[float, dict]] = {}
168
+
169
+
170
+ def parse_enum(desc: str) -> tuple[list[str] | None, bool]:
171
+ """Extract a closed value set from a parameter description. Two documented forms:
172
+
173
+ * prose — ``"one of 'MTD', 'QTD', 'YTD', 'TTM'."``
174
+ * pipe — ``"'month' | 'quarter' | 'year'"`` / ``"NULL | 'doc' | 'hyg'"``
175
+
176
+ → ``(["MTD","QTD","YTD","TTM"], case_sensitive)``. Returns ``(None, False)`` when the
177
+ description names no set. An unquoted ``NULL`` token means "omittable", not a literal value,
178
+ so it's excluded (a blank arg is already dropped upstream). ``case_sensitive`` is True only
179
+ when the text explicitly says so (e.g. category names) — otherwise a wrong-case value is
180
+ normalized to the canonical form rather than rejected.
181
+ """
182
+ if not desc:
183
+ return None, False
184
+ m = _ENUM_TAIL.search(desc)
185
+ if m:
186
+ vals = _QUOTED.findall(m.group(1))
187
+ elif "|" in desc: # pipe form has no lead-in; require the '|' so a stray quote isn't a set
188
+ vals = _QUOTED.findall(desc)
189
+ else:
190
+ return None, False
191
+ if len(vals) < 2: # a single quoted token isn't a meaningful choice
192
+ return None, False
193
+ low = desc.lower()
194
+ case_sensitive = "case-sensitive" in low and "case-insensitive" not in low
195
+ return vals, case_sensitive
196
+
197
+
198
+ def param_docs_from_reference(path: str | Path | None = None) -> dict[str, dict[str, str]]:
199
+ """``{proname: {param_name: description}}`` from a reference JSON (default: the bundled
200
+ ARQ_SQL_FUNCTIONS.json) — the authority on each parameter's allowed values. Best-effort
201
+ and cached by file mtime: returns ``{}`` if the file is missing or unreadable, so a
202
+ warehouse with no reference doc simply gets no enum constraints (unchanged behavior)."""
203
+ p = Path(path) if path else DEFAULT_PARAM_DOC_REF
204
+ try:
205
+ mtime = p.stat().st_mtime
206
+ except OSError:
207
+ return {}
208
+ cached = _param_doc_cache.get(str(p))
209
+ if cached and cached[0] == mtime:
210
+ return cached[1]
211
+ try:
212
+ data = json.loads(p.read_text(encoding="utf-8"))
213
+ except Exception: # malformed/partial file — behave as if absent
214
+ return {}
215
+ out: dict[str, dict[str, str]] = {}
216
+ for fn in data.get("functions") or []:
217
+ name = fn.get("function")
218
+ if not name:
219
+ continue
220
+ docs = {prm["name"]: (prm.get("description") or "")
221
+ for prm in fn.get("parameters") or [] if prm.get("name")}
222
+ if docs:
223
+ out[name] = docs
224
+ _backfill_enum_docs(out)
225
+ _param_doc_cache[str(p)] = (mtime, out)
226
+ return out
227
+
228
+
229
+ def _backfill_enum_docs(out: dict[str, dict[str, str]]) -> None:
230
+ """Within one reference, a parameter NAME maps to a single allowed set, yet some functions
231
+ document it (``p_period_type`` → ``'month' | 'quarter' | 'year'``) while others leave it
232
+ blank. Propagate the documented description to the blanks so every function that takes the
233
+ param is constrained — but only when the name's documented set is UNAMBIGUOUS (skip a name
234
+ that appears with two different sets, so we never guess). Mutates ``out`` in place."""
235
+ best: dict[str, str] = {}
236
+ conflict: set[str] = set()
237
+ for docs in out.values():
238
+ for pname, desc in docs.items():
239
+ vals, _ = parse_enum(desc)
240
+ if not vals:
241
+ continue
242
+ if pname in best and set(parse_enum(best[pname])[0]) != set(vals):
243
+ conflict.add(pname)
244
+ best.setdefault(pname, desc)
245
+ for pname in conflict:
246
+ best.pop(pname, None)
247
+ for docs in out.values():
248
+ for pname, desc in docs.items():
249
+ if pname in best and not parse_enum(desc)[0]:
250
+ docs[pname] = best[pname]
251
+
252
+
253
+ def _param_hint(aname: str, atype: str) -> str:
254
+ """A model-readable description for one argument: the humanized name (so `p_num_months`
255
+ reads as "num months") + its type, with a format hint for dates. The function COMMENT
256
+ (in the tool description) carries the real semantics; this makes the arg self-describing
257
+ even without one."""
258
+ human = re.sub(r"^p_|^_", "", aname).replace("_", " ").strip()
259
+ t = atype.strip().lower()
260
+ if t == "date":
261
+ return f"{human} (date, YYYY-MM-DD)"[:160]
262
+ return f"{human} ({atype.strip()})"[:160]
263
+
264
+
265
+ def _sanitize(name: str) -> str:
266
+ return (re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:64]) or "fn"
267
+
268
+
269
+ def _unique(name: str, seen: set[str]) -> str:
270
+ out, i = name, 2
271
+ while out in seen:
272
+ out = f"{name}_{i}"[:64]
273
+ i += 1
274
+ seen.add(out)
275
+ return out
276
+
277
+
278
+ def _dsn_label(dsn: str) -> str:
279
+ """host:port/db from a DSN, for `sql://<label>/…` sources (netloc = the agent's 'host')."""
280
+ try:
281
+ p = urlparse(dsn if "://" in dsn else "postgresql://" + dsn)
282
+ host = p.hostname or "warehouse"
283
+ port = f":{p.port}" if p.port else ""
284
+ db = (p.path or "").lstrip("/")
285
+ return f"{host}{port}" + (f"/{db}" if db else "")
286
+ except Exception:
287
+ return "warehouse"
288
+
289
+
290
+ def classify_pg_error(exc: Exception) -> tuple[str, str]:
291
+ """(kind, message) for a psycopg error — from SQLSTATE when present, else the message.
292
+
293
+ kinds match the agent's failure taxonomy: auth (bad credentials / no permission),
294
+ unreachable (can't connect), timeout (the statement ran past `statement_timeout` and was
295
+ cancelled), server_error (the query/function itself failed — the persistent-5xx detector
296
+ reads the message text, e.g. "function … does not exist").
297
+
298
+ `timeout` is separate from `server_error` because nothing is broken: the query is valid and
299
+ the data is there, it just did not finish. That is a different thing to tell the reader, and
300
+ a different thing for the agent to do about it (narrow the period, ask for fewer rows).
301
+ """
302
+ msg = str(exc).strip() or exc.__class__.__name__
303
+ state = getattr(exc, "sqlstate", None) or ""
304
+ if state == "57014": # query_canceled — statement_timeout fired
305
+ return "timeout", msg
306
+ if state.startswith("28"): # invalid_authorization_specification / invalid_password
307
+ return "auth", msg
308
+ if state == "42501": # insufficient_privilege
309
+ return "auth", msg
310
+ if state.startswith("08"): # connection_exception family
311
+ return "unreachable", msg
312
+ low = msg.lower()
313
+ if "canceling statement due to statement timeout" in low or "query_canceled" in low:
314
+ return "timeout", msg
315
+ if any(s in low for s in ("password authentication failed", "pg_hba.conf",
316
+ "permission denied", "role", "authentication")):
317
+ return "auth", msg
318
+ if any(s in low for s in ("connection refused", "could not connect", "timeout expired",
319
+ "could not translate host name", "connection timed out",
320
+ "server closed the connection", "is the server running")):
321
+ return "unreachable", msg
322
+ return "server_error", msg
323
+
324
+
325
+ def _jsonable(v):
326
+ """A DB value as something json.dumps handles (Decimal/date/UUID/bytes → primitives)."""
327
+ if v is None or isinstance(v, (str, int, float, bool)):
328
+ return v
329
+ if isinstance(v, (list, tuple)):
330
+ return [_jsonable(x) for x in v]
331
+ if isinstance(v, dict):
332
+ return {k: _jsonable(x) for k, x in v.items()}
333
+ if isinstance(v, bytes):
334
+ return f"<{len(v)} bytes>"
335
+ iso = getattr(v, "isoformat", None)
336
+ if callable(iso): # date / datetime / time
337
+ return iso()
338
+ try: # Decimal and other numerics
339
+ return float(v)
340
+ except Exception:
341
+ return str(v)
342
+
343
+
344
+ def _default_connect(dsn: str, timeout: int, statement_timeout_ms: int):
345
+ try:
346
+ import psycopg
347
+ except ImportError as e: # pragma: no cover
348
+ raise RuntimeError(
349
+ "psycopg is required for the SQL backend: pip install 'codi-api-agent[sql]'") from e
350
+ return psycopg.connect(
351
+ dsn,
352
+ connect_timeout=timeout,
353
+ autocommit=True, # each call is a single read-only SELECT; no txn bookkeeping needed
354
+ options=f"-c default_transaction_read_only=on -c statement_timeout={statement_timeout_ms}",
355
+ )
356
+
357
+
358
+ # --------------------------------------------------------------------------- #
359
+ # DSN -> Catalog
360
+ # --------------------------------------------------------------------------- #
361
+ def load_sql_catalog(dsn: str, schema: str = "gold", fn_prefix: str = "fn_",
362
+ max_functions: int = 1000, timeout: int = 20,
363
+ statement_timeout_ms: int = 30000, connect=None,
364
+ param_docs: dict[str, dict[str, str]] | None = None,
365
+ profile_rows: int = 100_000) -> Catalog:
366
+ """Introspect ``schema``'s ``<fn_prefix>*`` functions and build a read-only Catalog.
367
+
368
+ ``connect(dsn, timeout, statement_timeout_ms)`` is injectable for tests; the default
369
+ opens a psycopg connection with the read-only + timeout session options.
370
+
371
+ ``param_docs`` (``{proname: {param: description}}``) supplies allowed-value sets the DB
372
+ catalog can't express; defaults to the bundled ARQ reference (harmless — only functions
373
+ present there are affected). Matching params gain a JSON-schema ``enum`` and are validated
374
+ at call time.
375
+ """
376
+ connect = connect or _default_connect
377
+ if param_docs is None:
378
+ param_docs = param_docs_from_reference()
379
+ label = _dsn_label(dsn) # host:port/db only — the DSN (with credentials) is never logged
380
+ _LOG.info("introspect: %s schema=%s prefix=%s*", label, schema, fn_prefix)
381
+ with connect(dsn, timeout, statement_timeout_ms) as conn:
382
+ cur = conn.cursor()
383
+ cur.execute(_INTROSPECT_SQL, {"schema": schema, "pattern": fn_prefix + "%"})
384
+ rows = cur.fetchall()
385
+ # Location vocabulary for the p_location_names guard — loaded ONCE, and only if some
386
+ # function actually takes that parameter (r[1] is the identity-args text). Fail-open.
387
+ loc_index = (_load_location_index(conn)
388
+ if any("location_names" in (r[1] or "") for r in rows) else None)
389
+ location_vocab: set[str] = loc_index.values if loc_index else set()
390
+ _LOG.info("introspect: %s → %d function(s)%s", label, len(rows),
391
+ f"; {len(location_vocab)} known locations" if location_vocab else "")
392
+
393
+ tools: list[Tool] = []
394
+ reference: list[dict] = []
395
+ seen: set[str] = set()
396
+ # CROSS-FUNCTION PARAMETER VOCABULARY: {arg_name: every value documented for it ANYWHERE}.
397
+ # Only 19 of 62 functions taking `p_comparison` document its allowed set, so 43 of them had
398
+ # NO guard — and Postgres does not reject an unknown value, it silently falls through to the
399
+ # default branch. Measured: p_comparison='previous' and 'garbage_value' returned byte-identical
400
+ # rows to 'yoy', so the agent asked for a month-over-month comparison, got year-over-year data
401
+ # and narrated it as month-over-month. This union ({budget, mom, qoq, yoy}) is a SAFETY NET
402
+ # beneath the per-function enum: it can only reject a value that is valid for NO function, so
403
+ # it never blocks a legitimate call, while catching invented values like those two.
404
+ param_vocab: dict[str, set] = {}
405
+ for proname, args_text, comment, num_defaults, result_type in rows[:max_functions]:
406
+ args = parse_identity_args(args_text or "")
407
+ # pronargdefaults counts TRAILING args with a DEFAULT — those are optional.
408
+ first_optional = len(args) - int(num_defaults or 0)
409
+ # Overloads (same name, different signature) each load as their own tool — the
410
+ # richest signature keeps the clean name (see the introspection ORDER BY), the
411
+ # rest get a numeric suffix. Silently skipping them loses real capabilities.
412
+ name = _unique(_sanitize(f"{schema}_{proname}"), seen)
413
+ human = _humanize(proname, fn_prefix)
414
+ # The COMMENT is the function's documentation — often the ONLY place semantics
415
+ # like a date-window contract live ("p_start_date if set, else p_num_months back").
416
+ # Keep it near-whole: the router tokenizes all of it, and only the few routed
417
+ # tools' descriptions ever reach the executor's context.
418
+ desc = (comment or "").strip() or f"Warehouse data: {human}."
419
+ description = (f"{desc} (read-only SQL function {schema}.{proname}; "
420
+ f"returns {(result_type or 'rows').strip()[:160]})")[:1500]
421
+
422
+ docs = param_docs.get(proname, {})
423
+ properties: dict = {}
424
+ required: list[str] = []
425
+ enums: dict[str, tuple[list[str], bool]] = {}
426
+ for i, (aname, atype) in enumerate(args):
427
+ prop = {"type": _json_type(atype), "description": _param_hint(aname, atype)}
428
+ # Match the doc by the exact arg name or its p_-stripped core (the reference and
429
+ # the DB both use p_ names, but stay robust to either form).
430
+ doc = docs.get(aname) or docs.get(re.sub(r"^p_|^_", "", aname))
431
+ allowed, case_sensitive = parse_enum(doc or "")
432
+ if allowed:
433
+ prop["enum"] = allowed
434
+ prop["description"] = f"{_param_hint(aname, atype)} — {doc.strip()}"[:200]
435
+ enums[aname] = (allowed, case_sensitive)
436
+ # Feed the cross-function VOCABULARY (see param_vocab below). Populated as the
437
+ # loop runs and read lazily at call time, so every executor sees the full union.
438
+ param_vocab.setdefault(aname, set()).update(allowed)
439
+ properties[aname] = prop
440
+ if i < first_optional:
441
+ required.append(aname)
442
+ parameters = {"type": "object", "properties": properties, "required": required}
443
+
444
+ tools.append(Tool(
445
+ name=name,
446
+ description=description,
447
+ parameters=parameters,
448
+ data_type="structured",
449
+ backend="sql",
450
+ cache_key=label, # results from different DSNs must never cross-serve
451
+ fn=_make_executor(dsn, label, schema, proname, args, connect,
452
+ statement_timeout_ms, required=set(required), enums=enums,
453
+ profile_rows=profile_rows, vocab=param_vocab,
454
+ locations=location_vocab),
455
+ ))
456
+ reference.append({
457
+ "name": name, "method": "SQL", "path": f"{schema}.{proname}",
458
+ "summary": (comment or "").strip().splitlines()[0] if comment else human,
459
+ "description": (comment or "").strip()[:1200],
460
+ "params": [{"name": a, "in": "arg", "required": i < first_optional,
461
+ "type": t, "example": None} for i, (a, t) in enumerate(args)],
462
+ "body": [],
463
+ })
464
+ # ---- BACKFILL THE ALLOWED VALUES the model was never shown ------------------------------
465
+ # `param_vocab` is the union of every closed value set the schema documents, keyed by
466
+ # parameter name. The CALL-TIME guard already enforces it: a value valid for no function in
467
+ # the catalog is rejected as an invention. But it was only ever used to REJECT, never to
468
+ # INFORM — so for the 42 of 62 operations that expose `p_comparison` without documenting its
469
+ # values, the model had nothing to go on and guessed. Measured, twice in one query:
470
+ # `p_comparison='current'` rejected on both calls, each costing a round-trip to recover.
471
+ #
472
+ # Advertising the set cannot be more restrictive than what is already enforced, which is what
473
+ # makes this safe: any value that would have been accepted still is. It only stops the model
474
+ # inventing one. Done as a post-pass because the union is not complete until every function
475
+ # has been read.
476
+ _backfill_enums(tools, param_vocab)
477
+ cat = Catalog(tools, reference=reference)
478
+ # Ride the resolver along on the catalog: the AGENT owns argument binding (it is the only
479
+ # layer that sees the user's question), while the index can only be built here, where the
480
+ # warehouse connection is.
481
+ cat.locations = loc_index
482
+ return cat
483
+
484
+
485
+
486
+ # A closed set this large is a vocabulary, not an enum — advertising it would bloat every tool
487
+ # definition and it is not what the model is getting wrong.
488
+ _MAX_BACKFILL_ENUM = 12
489
+
490
+
491
+ def _backfill_enums(tools: "list[Tool]", param_vocab: "dict[str, set]") -> int:
492
+ """Advertise a parameter's allowed values when the schema documents them ELSEWHERE.
493
+
494
+ Returns the number of parameters filled in. Only touches string parameters that have no enum
495
+ of their own; a function that documents its own set always keeps it.
496
+ """
497
+ filled = 0
498
+ for tool in tools:
499
+ props = (tool.parameters or {}).get("properties") or {}
500
+ for aname, prop in props.items():
501
+ if prop.get("enum") or prop.get("type") != "string":
502
+ continue
503
+ known = param_vocab.get(aname) or param_vocab.get(re.sub(r"^p_|^_", "", aname))
504
+ if not known or len(known) > _MAX_BACKFILL_ENUM:
505
+ continue
506
+ allowed = sorted(known)
507
+ prop["enum"] = allowed
508
+ prop["description"] = (
509
+ f"{prop.get('description', aname)} — allowed values (documented on other "
510
+ f"operations taking this parameter): {', '.join(repr(a) for a in allowed)}")[:300]
511
+ filled += 1
512
+ if filled:
513
+ _LOG.info("enum backfill: advertised allowed values for %d parameter(s) whose own "
514
+ "function documents none", filled)
515
+ return filled
516
+
517
+
518
+ PROFILE_MAX_COLS = 16 # caps.md: ARQ finance tables exceed 12 cols; +4 ≈ 120 tok
519
+ PROFILE_PERCENTILES = [0.05, 0.25, 0.5, 0.75, 0.95]
520
+ _PROFILE_TOP_VALUES = 3 # most-frequent values per categorical column
521
+
522
+
523
+ PROFILE_ROW_FIELDS = 12 # fields kept from an extreme row (bounds prompt size on wide tables)
524
+
525
+
526
+ _YEAR_ARGS = ("p_year", "year", "p_ref_year", "ref_year")
527
+ _MONTH_ARGS = ("p_month", "month", "p_ref_month", "ref_month")
528
+
529
+
530
+ def incomplete_period_note(bound: dict, today=None) -> str:
531
+ """A warning when the requested period is the CURRENT (still-running) month or a future one.
532
+
533
+ Not an empty-window check — the far more dangerous case is a PARTIAL one. Measured: asked
534
+ for July 2026 (day 21 of 31, partial ingestion) the warehouse returned 7,980 against June's
535
+ 506,202, and 60 procedures against 2,913. Every figure is real and grounded, so no
536
+ fabrication or emptiness check fires, and the agent reported a "97.9% decline" that is
537
+ purely an artifact of comparing 21 days to a full month.
538
+
539
+ Uses the calendar only — the current month is incomplete by definition — so it needs no
540
+ warehouse-specific knowledge and holds for any schema. Informational, never blocking: a
541
+ user may legitimately ask how the current month is tracking."""
542
+ import datetime
543
+ today = today or datetime.date.today()
544
+ year = next((bound[k] for k in _YEAR_ARGS if isinstance(bound.get(k), int)), None)
545
+ month = next((bound[k] for k in _MONTH_ARGS if isinstance(bound.get(k), int)), None)
546
+ if year is None or month is None or not (1 <= month <= 12):
547
+ return ""
548
+ if (year, month) < (today.year, today.month):
549
+ return "" # a past month is complete
550
+ which = "the CURRENT, still-running month" if (year, month) == (today.year, today.month) \
551
+ else "a FUTURE month"
552
+ last_y, last_m = (today.year, today.month - 1) if today.month > 1 else (today.year - 1, 12)
553
+ return (f"NOTE: you requested {year}-{month:02d}, which is {which} (today is "
554
+ f"{today.isoformat()}). These figures cover only part of the period, so they are "
555
+ f"NOT comparable with a full month and any drop against a prior month is an "
556
+ f"artifact of the shorter window, NOT a business decline. Say this explicitly, or "
557
+ f"re-run for the last COMPLETE month ({last_y}-{last_m:02d}).\n")
558
+
559
+
560
+ def _extreme_row(row: dict) -> dict:
561
+ """The WHOLE row holding an extreme (trimmed for width), not just a label.
562
+
563
+ A single-field label loses the rest of the identity — `max_row="Patel"` cannot tell you
564
+ *which* Patel or *which month*, so an answer built from it mis-attributes the figure. The
565
+ full row lets both the model and the deterministic corrector name the extreme completely
566
+ (`Patel, Chirag — 2023-08-01`). Rows are already PII-redacted before profiling."""
567
+ return dict(list(row.items())[:PROFILE_ROW_FIELDS])
568
+
569
+
570
+ def row_label(row: dict | None) -> str:
571
+ """A readable attribution built from an extreme ROW: its identifying (non-numeric) fields,
572
+ e.g. `Patel, Chirag, 2023-08-01`. Falls back to the first usable field."""
573
+ if not isinstance(row, dict) or not row:
574
+ return ""
575
+ parts = [str(v).strip() for v in row.values()
576
+ if isinstance(v, str) and str(v).strip()]
577
+ if parts:
578
+ return ", ".join(parts[:3])[:80]
579
+ for k, v in row.items():
580
+ if v is not None and not isinstance(v, (dict, list)):
581
+ return f"{k}={v}"
582
+ return ""
583
+
584
+
585
+ def _num(v):
586
+ """A plain JSON-safe Python number (pandas/numpy scalars are neither)."""
587
+ try:
588
+ f = float(v)
589
+ except (TypeError, ValueError):
590
+ return None
591
+ if f != f or f in (float("inf"), float("-inf")): # NaN / inf → omit
592
+ return None
593
+ return round(f, 4)
594
+
595
+
596
+ _TERM_RE = re.compile(r"[a-z0-9][a-z0-9.'-]*")
597
+
598
+
599
+ def _term_score(row: dict, terms: "frozenset[str]") -> int:
600
+ """How many of the question's own words this row NAMES, counted over text cells only."""
601
+ if not terms:
602
+ return 0
603
+ hit = set()
604
+ for v in row.values():
605
+ if isinstance(v, str):
606
+ hit |= terms & set(_TERM_RE.findall(v.lower()))
607
+ return len(hit)
608
+
609
+
610
+ def _keep(ordered: list[dict], n: int, period_col: str,
611
+ terms: "frozenset[str]") -> list[dict]:
612
+ """The `n` rows to keep from a period-ordered set: the ones the question NAMES first, then the
613
+ most recent of the rest — re-sorted ascending so a trend still reads left-to-right."""
614
+ if len(ordered) <= n:
615
+ return ordered
616
+ if not terms:
617
+ return ordered[-n:]
618
+ from .metrics import _as_period
619
+ scored = [(-_term_score(r, terms), r) for r in ordered]
620
+ if not any(s for s, _ in scored): # nothing matched — recency stands
621
+ return ordered[-n:]
622
+ # Stable within a score band, and `ordered` is already oldest-first, so reversing before the
623
+ # sort keeps the MOST RECENT members of each band — recency still breaks ties.
624
+ kept = [r for _, r in sorted(reversed(scored), key=lambda sr: sr[0])[:n]]
625
+ return sorted(kept, key=lambda r: _as_period(r.get(period_col)))
626
+
627
+
628
+ def _visible_slice(rows: list[dict], n: int,
629
+ prefer_period: "tuple | None" = None,
630
+ prefer_terms: "frozenset[str] | None" = None) -> list[dict]:
631
+ """The `n` rows to actually show. For a TIME-INDEXED result that means the most RECENT `n`,
632
+ not the first `n` in storage order.
633
+
634
+ Storage order buries the present. Measured live: `fn_dentrix_provider_monthly_production`
635
+ returns 1,295 rows spanning 2017-01 to 2026-07, and the first 150 stop at **2025-01** — so
636
+ asked for June 2026 the agent replied "not available … the latest data shown is for July
637
+ 2024", when June 2026 holds 11 rows totalling $506,202.02. The same slice made an Eaglesoft
638
+ answer report $74,478 against a real $421,448. The rows existed; they were simply never shown.
639
+
640
+ Business questions are overwhelmingly about recent periods, so recency is the right default
641
+ when something must be dropped. Rows are re-sorted back into ascending period order for
642
+ display, so a trend still reads left-to-right. Non-temporal results keep their original order,
643
+ which is often meaningful (many functions pre-sort by `ABS(delta) DESC`).
644
+
645
+ `prefer_terms` are the question's own words, and they outrank recency. Many of these functions
646
+ take NO ARGUMENTS AT ALL — `fn_curve_provider_monthly_production()` returns 3,227 rows, every
647
+ provider and every month from 2023 to 2026 — so a filter the question states can never be
648
+ pushed into the call and must survive the cap instead. Measured: "monthly production for the
649
+ Curve provider Temp for the year 2025" period-scoped correctly to 2025, but 2025 alone holds
650
+ 665 rows across all providers, so the most-recent-150 window landed on October–December and
651
+ the answer reported three months and a total of 100,910.7. Temp has twelve months in 2025
652
+ totalling 45,126.55, all of them scanned and none of them shown.
653
+
654
+ This PRIORITISES rather than filters: matching rows go first, the remainder fills what is
655
+ left. A spurious match therefore costs nothing but ordering, and the result can never be
656
+ narrowed to nothing — which a filter, applied to a term that matched by accident, could do.
657
+ """
658
+ if len(rows) <= n:
659
+ return rows
660
+ from .metrics import _as_period
661
+ terms = prefer_terms or frozenset()
662
+ period_col = next(
663
+ (c for c in rows[0]
664
+ if all(_as_period(r.get(c)) is not None for r in rows)), None) if rows else None
665
+ if not period_col:
666
+ # No time axis — original order is often meaningful (many functions pre-sort by
667
+ # ABS(delta) DESC), so only a term match may disturb it, and stably.
668
+ return sorted(rows, key=lambda r: -_term_score(r, terms))[:n] if terms else rows[:n]
669
+ ordered = sorted(rows, key=lambda r: _as_period(r.get(period_col)))
670
+ # A HISTORICAL question overrides recency. Asked for 2018 or 2024 production the agent replied
671
+ # "the evidence does not include any data for 2024" — true of the rows it was SHOWN and false
672
+ # of the table, which holds them. When the question names a period, the window is centred on
673
+ # it: rows AT that period first, then the ones leading up to it (so a trend still has context).
674
+ if prefer_period:
675
+ want_y, want_m = (list(prefer_period) + [None])[:2]
676
+ at = [r for r in ordered if _as_period(r.get(period_col))[0] == want_y
677
+ and (want_m is None or _as_period(r.get(period_col))[1] == want_m)]
678
+ if at:
679
+ # ONLY the requested period. Padding with the months leading up to it gives a trend
680
+ # context nobody asked for and makes the answer wrong in a way that is hard to see:
681
+ # asked for "Dentrix production for the 2024 calendar year" the answer included 2023
682
+ # rows and reported them as 2024. A question that names a period is answered from that
683
+ # period; if the reader wants the run-up they ask for it.
684
+ return _keep(at, n, period_col, terms)
685
+ return _keep(ordered, n, period_col, terms)
686
+
687
+
688
+ def describe_rows(rows: list[dict], *, scanned: int | None = None,
689
+ complete: bool = True) -> dict | None:
690
+ """A `DataFrame.describe()`-style statistical profile of a result set — the AUTHORITATIVE
691
+ aggregate handed to the model in place of a huge table.
692
+
693
+ Computed over EVERY row FETCHED, not the page the model is shown. That distinction is the
694
+ whole point: the agent once reported a $172,994 "maximum" and a $24,693 "average" from the
695
+ 50 rows it could see, when the true 5,830-row values were $228,588 and $23,484. Both stated
696
+ figures existed in the data, so the grounding check passed — a real number attached to a
697
+ false claim. Real aggregates let the model state the extreme CORRECTLY rather than be told
698
+ not to mention one, and they cost ~10 lines of prompt instead of thousands of rows.
699
+
700
+ Percentiles (p5/p25/median/p75/p95) come along because spread and outlier questions need
701
+ them — "is this month unusual?" is not answerable from min/max/mean alone.
702
+
703
+ Uses pandas when importable and falls back to stdlib statistics otherwise (same pattern as
704
+ the optional embedding model). Returns None when there is nothing worth profiling.
705
+ """
706
+ dict_rows = [r for r in rows or [] if isinstance(r, dict)]
707
+ if not dict_rows:
708
+ return None
709
+ cols = list(dict_rows[0].keys())[:PROFILE_MAX_COLS]
710
+ numeric, categorical = _describe_pandas(dict_rows, cols)
711
+ if numeric is None: # pandas unavailable
712
+ numeric, categorical = _describe_stdlib(dict_rows, cols)
713
+ if not numeric and not categorical:
714
+ return None
715
+ return {"rows_scanned": scanned if scanned is not None else len(dict_rows),
716
+ # False when the scan cap was hit — even the profile is then a (large) sample, and
717
+ # the answer must not claim a definitive overall extreme.
718
+ "complete": complete,
719
+ "numeric": numeric, "categorical": categorical}
720
+
721
+
722
+ def _describe_pandas(dict_rows: list[dict], cols: list[str]):
723
+ """(numeric, categorical) via pandas, or (None, None) when pandas isn't installed."""
724
+ try:
725
+ import pandas as pd
726
+ except Exception:
727
+ return None, None
728
+ df = pd.DataFrame(dict_rows, columns=cols)
729
+ numeric: dict[str, dict] = {}
730
+ categorical: dict[str, dict] = {}
731
+ for col in cols:
732
+ s = pd.to_numeric(df[col], errors="coerce")
733
+ if s.notna().sum() >= 2:
734
+ d = s.describe(percentiles=PROFILE_PERCENTILES)
735
+ stats = {"count": int(s.notna().sum()), "nulls": int(df[col].isna().sum())}
736
+ for key, out in (("mean", "mean"), ("std", "stdev"), ("min", "min"), ("max", "max"),
737
+ ("5%", "p5"), ("25%", "p25"), ("50%", "median"),
738
+ ("75%", "p75"), ("95%", "p95")):
739
+ if key in d and (val := _num(d[key])) is not None:
740
+ stats[out] = val
741
+ stats["sum"] = _num(s.sum())
742
+ # which ROW holds the extreme — describe() can't say, and it is what users ask for
743
+ stats["min_row"] = _extreme_row(dict_rows[int(s.idxmin())])
744
+ stats["max_row"] = _extreme_row(dict_rows[int(s.idxmax())])
745
+ numeric[col] = stats
746
+ else:
747
+ texts = df[col].dropna().astype(str)
748
+ if not texts.empty:
749
+ top = texts.value_counts().head(_PROFILE_TOP_VALUES)
750
+ categorical[col] = {
751
+ "count": int(texts.size), "nulls": int(df[col].isna().sum()),
752
+ "unique": int(texts.nunique()),
753
+ "top": [{"value": str(v), "freq": int(n)} for v, n in top.items()]}
754
+ return numeric, categorical
755
+
756
+
757
+ def _describe_stdlib(dict_rows: list[dict], cols: list[str]):
758
+ """Same shape as _describe_pandas, using only the standard library."""
759
+ numeric: dict[str, dict] = {}
760
+ categorical: dict[str, dict] = {}
761
+ for col in cols:
762
+ vals = [r.get(col) for r in dict_rows]
763
+ nulls = sum(1 for v in vals if v is None)
764
+ # Coerce like pandas' to_numeric rather than isinstance-testing: Postgres hands back
765
+ # Decimal for numeric columns, which is neither int nor float, so a strict type check
766
+ # silently skipped every money column and the profile came back empty.
767
+ pairs = [(f, r) for v, r in zip(vals, dict_rows)
768
+ if (f := _num(v)) is not None and not isinstance(v, bool)]
769
+ if len(pairs) >= 2:
770
+ nums = sorted(v for v, _ in pairs)
771
+ lo, lo_row = min(pairs, key=lambda p: p[0])
772
+ hi, hi_row = max(pairs, key=lambda p: p[0])
773
+ lo_row, hi_row = _extreme_row(lo_row), _extreme_row(hi_row)
774
+ stats = {"count": len(nums), "nulls": nulls,
775
+ "min": _num(lo), "min_row": lo_row,
776
+ "max": _num(hi), "max_row": hi_row,
777
+ "mean": _num(statistics.fmean(nums)),
778
+ "median": _num(statistics.median(nums)),
779
+ # SAMPLE stdev (ddof=1) to match pandas' describe(), so the two paths
780
+ # never disagree on the same data.
781
+ "stdev": _num(statistics.stdev(nums)) if len(nums) > 1 else 0.0,
782
+ "sum": _num(sum(nums))}
783
+ for q, name in zip(PROFILE_PERCENTILES, ("p5", "p25", "median", "p75", "p95")):
784
+ stats[name] = _num(_quantile(nums, q))
785
+ numeric[col] = stats
786
+ else:
787
+ texts = [str(v) for v in vals if v is not None and not isinstance(v, (dict, list))]
788
+ if texts:
789
+ top = Counter(texts).most_common(_PROFILE_TOP_VALUES)
790
+ categorical[col] = {"count": len(texts), "nulls": nulls,
791
+ "unique": len(set(texts)),
792
+ "top": [{"value": v, "freq": n} for v, n in top]}
793
+ return numeric, categorical
794
+
795
+
796
+ def _quantile(sorted_vals: list[float], q: float) -> float:
797
+ """Linear-interpolation quantile (pandas' default), so both paths agree."""
798
+ if not sorted_vals:
799
+ return 0.0
800
+ if len(sorted_vals) == 1:
801
+ return sorted_vals[0]
802
+ pos = q * (len(sorted_vals) - 1)
803
+ lo = int(pos)
804
+ hi = min(lo + 1, len(sorted_vals) - 1)
805
+ return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (pos - lo)
806
+
807
+
808
+
809
+ # Warehouse location names carry an internal prefix — '001-BDD-Indianapolis IN' — that no user
810
+ # types. Stripping it is what turns the stored value into something a question can be matched against.
811
+ _LOC_PREFIX = re.compile(r"^\s*\d+\s*-\s*[A-Za-z&]+\s*-\s*")
812
+ # A trailing US state code: 'Indianapolis IN' -> 'Indianapolis'.
813
+ _LOC_STATE = re.compile(r"\s+[A-Z]{2}\b\s*$")
814
+ # A parenthetical qualifier distinguishing sites in one city: 'Lansing MI (Lake)'.
815
+ _LOC_QUALIFIER = re.compile(r"\s*\(([^)]+)\)\s*$")
816
+ # "location 004", "site #12", "office 7" — the numeric code, which IS how staff refer to sites.
817
+ _LOC_CODE = re.compile(r"\b(?:location|site|office|clinic)\s*#?\s*(\d{1,3})\b", re.IGNORECASE)
818
+ # Below this an alias is too generic to match on: state codes ('IN', 'IL'), 'the', and any
819
+ # two/three-letter fragment that would fire on half the questions ever asked.
820
+ _LOC_MIN_ALIAS = 4
821
+
822
+
823
+ class LocationIndex:
824
+ """Resolves the location a QUESTION names to the exact values `p_location_names` accepts.
825
+
826
+ THE DEFECT THIS EXISTS FOR. The warehouse stores '001-BDD-Indianapolis IN'; a user writes
827
+ "Indianapolis". Until now there was a *validator* and no *resolver* — `p_location_names` was
828
+ checked by exact lowercase match, so the model would have had to reproduce that string
829
+ verbatim. It cannot, and a wrong value is rejected outright, so the only move that always
830
+ "works" is to OMIT the parameter. Measured: "hygiene revenue per patient for Indianapolis"
831
+ called `fn_rca_hyg_rev_per_patient_trend(p_year=2026, p_month=4)` with no location at all and
832
+ then narrated enterprise-wide numbers as if they were one site's.
833
+
834
+ Resolution is by ALIAS, longest match wins, and ties are all returned:
835
+
836
+ - "Indianapolis" -> ['001-BDD-Indianapolis IN']
837
+ - "Lansing" -> all three Lansing sites (Lake, Cavanaugh, Oakhill) — a user naming
838
+ a city with three clinics means the city, not one clinic
839
+ - "Lansing (Cavanaugh)" -> just that one, because it matched a LONGER alias
840
+ - "Dr. Sammons" -> [] -> NULL -> all locations, because a provider name is not in this
841
+ index at all. That is the guarantee asked for: only values present
842
+ in `silver.dim_location` can ever reach the parameter.
843
+ """
844
+
845
+ def __init__(self, canonical: dict[str, set[str]], vocab: "set[str] | None" = None,
846
+ pms: "dict[str, str] | None" = None, keys: "dict[str, int] | None" = None):
847
+ # {canonical stored value: {alias, …}} — aliases lowercased, canonical left exactly as
848
+ # stored because that is what the SQL parameter compares against.
849
+ self.canonical = canonical
850
+ # The VALIDATOR's vocabulary, kept deliberately wider than the canonical set: every raw
851
+ # name/label column of `dim_location` (NetSuite, Paycor, Denticon, Curve, Kolla). Narrowing
852
+ # it to the 53 canonical values would newly reject 30 spellings the guard accepts today —
853
+ # a regression, and the guard is a backstop for paths that never reach the resolver.
854
+ self._vocab = {v.lower() for v in (vocab or set())} | {v.lower() for v in canonical}
855
+ # {canonical location: pms_of_record}. A clinic runs ONE practice-management system, so a
856
+ # question about that clinic can only be answered from that system's functions. Measured:
857
+ # "Why is Lansing MI (Lake) down 138,029?" routed to CURVE procedure trends; Lansing runs
858
+ # OPENDENTAL, Curve holds nothing for it, every figure came back zero and the answer
859
+ # abstained. The column was there the whole time.
860
+ self.pms = {k: v for k, v in (pms or {}).items() if v}
861
+ # {canonical location: location_key}. Four functions scope by KEY rather than by name
862
+ # (`p_location_keys bigint[]`), and without the mapping the binder could not touch them:
863
+ # measured, "How much appointment time was booked during June 2026?" reached
864
+ # `fn_denticon_booked_time_window(p_location_keys='all')` — the model's word for "every
865
+ # location" in a bigint[] — Postgres rejected it, and the agent abstained on a question
866
+ # whose answer NULL returns immediately.
867
+ self.keys = {k: v for k, v in (keys or {}).items() if v is not None}
868
+ self._by_alias: dict[str, set[str]] = {}
869
+ for name, aliases in canonical.items():
870
+ for a in aliases:
871
+ self._by_alias.setdefault(a, set()).add(name)
872
+
873
+ def __len__(self) -> int:
874
+ return len(self.canonical)
875
+
876
+ @property
877
+ def values(self) -> set[str]:
878
+ """Every accepted value, lowercased — the validator's vocabulary."""
879
+ return self._vocab
880
+
881
+ def pms_for(self, names: "list[str]") -> "set[str]":
882
+ """The practice-management systems serving these locations, lowercased.
883
+
884
+ Empty when unknown — 14 of 53 rows have no `pms_of_record`, and an unknown source must
885
+ never be used to EXCLUDE anything.
886
+ """
887
+ return {self.pms[n].strip().lower() for n in names if self.pms.get(n)}
888
+
889
+ def resolve(self, text: str) -> list[str]:
890
+ """Canonical location values named in `text`, or [] when none are.
891
+
892
+ Longest match wins PER MENTION, not across the whole question. Getting that wrong collapsed
893
+ multi-location questions to a single place: "expense ratios for Indianapolis, Fort Wayne
894
+ and Clarksville" resolved to the two Indianapolis sites only, because `indianapolis` is a
895
+ longer alias than `fort wayne` and the rule was applied globally. Each mention in the text
896
+ is its own contest — overlapping matches compete, separate ones are unioned.
897
+ """
898
+ low = f" {(text or '').lower()} "
899
+ # (start, end, alias_len, {canonical names}) for every alias occurrence in the text.
900
+ spans: list[tuple[int, int, int, set[str]]] = []
901
+ for alias, names in self._by_alias.items():
902
+ if len(alias) < _LOC_MIN_ALIAS:
903
+ continue
904
+ for m in re.finditer(rf"(?<![\w]){re.escape(alias)}(?![\w])", low):
905
+ spans.append((m.start(), m.end(), len(alias), set(names)))
906
+ for code in _LOC_CODE.findall(text or ""):
907
+ for name in self.canonical:
908
+ if re.match(rf"^0*{int(code)}\s*-", name.strip()):
909
+ spans.append((-1, -1, _LOC_MIN_ALIAS, {name}))
910
+ if not spans:
911
+ return []
912
+ # Cluster OVERLAPPING spans — each cluster is one mention. "Lansing MI (Cavanaugh)" puts
913
+ # `lansing`, `lansing mi` and the full alias in one cluster, where the longest wins;
914
+ # "Indianapolis … Clarksville" yields two clusters, and both are kept.
915
+ spans.sort()
916
+ clusters: list[list[tuple[int, int, int, set[str]]]] = []
917
+ for sp in spans:
918
+ if sp[0] >= 0 and clusters and clusters[-1][-1][0] >= 0 and sp[0] < clusters[-1][-1][1]:
919
+ clusters[-1].append(sp)
920
+ else:
921
+ clusters.append([sp])
922
+ out: set[str] = set()
923
+ for cluster in clusters:
924
+ best = max(c[2] for c in cluster)
925
+ for c in cluster:
926
+ if c[2] == best:
927
+ out |= c[3]
928
+ return sorted(out)
929
+
930
+
931
+ def _location_aliases(canonical: str, extra: "set[str]") -> set[str]:
932
+ """Every spelling a user might reasonably use for one stored location value."""
933
+ out: set[str] = set()
934
+ for raw in {canonical, *extra}:
935
+ s = (raw or "").strip()
936
+ if not s:
937
+ continue
938
+ out.add(s.lower())
939
+ human = _LOC_PREFIX.sub("", s).strip() # '001-BDD-Lansing MI (Lake)' -> 'Lansing MI (Lake)'
940
+ out.add(human.lower())
941
+ qual = _LOC_QUALIFIER.search(human)
942
+ bare = _LOC_QUALIFIER.sub("", human).strip() # -> 'Lansing MI'
943
+ out.add(bare.lower())
944
+ city = _LOC_STATE.sub("", bare).strip() # -> 'Lansing'
945
+ out.add(city.lower())
946
+ if qual:
947
+ # 'Lansing (Lake)' — city + qualifier without the state, which is how staff write it.
948
+ out.add(f"{city} ({qual.group(1)})".lower())
949
+ return {a for a in out if len(a) >= _LOC_MIN_ALIAS}
950
+
951
+
952
+ def _table_columns(cur, schema: str, table: str) -> list[str]:
953
+ """Column names of one table — used to detect optional columns before selecting them."""
954
+ cur.execute("select column_name from information_schema.columns "
955
+ "where table_schema = %s and table_name = %s", (schema, table))
956
+ return [r[0] for r in cur.fetchall()]
957
+
958
+
959
+ def _load_location_index(conn) -> "LocationIndex | None":
960
+ """Build the resolver from ``silver.dim_location``.
961
+
962
+ CANONICAL values come from ``location_name`` — that is what `p_location_names` compares
963
+ against. ALIASES additionally include every other name column (NetSuite, Paycor, Denticon,
964
+ Curve, Kolla labels), so a user typing the name they see in one source system still resolves to
965
+ the value the warehouse parameter wants.
966
+
967
+ Fail-OPEN, exactly as the vocabulary loader it replaces: any error -> None -> the resolver is
968
+ inactive and nothing is bound, which is the behaviour we have today.
969
+ """
970
+ try:
971
+ cur = conn.cursor()
972
+ cur.execute(
973
+ "select column_name from information_schema.columns "
974
+ "where table_schema = 'silver' and table_name = 'dim_location' "
975
+ "and data_type in ('text', 'character varying') "
976
+ "and (column_name like %s or column_name like %s)",
977
+ ("%name%", "%label%"))
978
+ name_cols = [r[0] for r in cur.fetchall()]
979
+ if "location_name" not in name_cols:
980
+ return None
981
+ others = [c for c in name_cols if c != "location_name"]
982
+ cols = ", ".join(f'd."{c}"::text' for c in ["location_name", *others])
983
+ dim_cols = {c for c in _table_columns(cur, "silver", "dim_location")}
984
+ has_pms = "pms_of_record" in dim_cols
985
+ has_key = "location_key" in dim_cols
986
+ pms_col = ', d."pms_of_record"::text' if has_pms else ""
987
+ key_col = ', d."location_key"' if has_key else ""
988
+ cur.execute(f"select {cols}{pms_col}{key_col} from silver.dim_location d "
989
+ f'where d."location_name" is not null and btrim(d."location_name") <> \'\'')
990
+ canonical: dict[str, set[str]] = {}
991
+ vocab: set[str] = set()
992
+ pms: dict[str, str] = {}
993
+ keys: dict[str, int] = {}
994
+ ncols = 1 + len(others)
995
+ for row in cur.fetchall():
996
+ name, extra = row[0].strip(), {x.strip() for x in row[1:ncols] if x and x.strip()}
997
+ if name:
998
+ canonical[name] = _location_aliases(name, extra)
999
+ vocab |= {name, *extra}
1000
+ if has_pms and row[ncols]:
1001
+ pms[name] = row[ncols]
1002
+ if has_key and row[ncols + (1 if has_pms else 0)] is not None:
1003
+ keys[name] = int(row[ncols + (1 if has_pms else 0)])
1004
+ return LocationIndex(canonical, vocab, pms, keys) if canonical else None
1005
+ except Exception as e: # noqa: BLE001 — fail-open is the whole point
1006
+ _LOG.info("location index unavailable — location binding inactive (%s)", e)
1007
+ return None
1008
+
1009
+
1010
+ def _load_location_vocab(conn) -> set[str]:
1011
+ """Distinct location NAMES from ``silver.dim_location`` (every *name*/*label* column),
1012
+ lowercased — the vocabulary the ``p_location_names`` guard validates against.
1013
+
1014
+ A PMS/source name ('Opendental', 'Dentrix') lives in ``pms_of_record``, NOT in any name
1015
+ column, so a source name passed as a location is not in this set and gets rejected. The
1016
+ columns are discovered dynamically so a renamed/added name column is picked up automatically.
1017
+
1018
+ Fail-OPEN: any error (table absent in this deployment, no SELECT grant, unexpected shape) →
1019
+ empty set → the guard is simply inactive, so a legitimate call is NEVER blocked by this."""
1020
+ try:
1021
+ cur = conn.cursor()
1022
+ cur.execute(
1023
+ "select column_name from information_schema.columns "
1024
+ "where table_schema = 'silver' and table_name = 'dim_location' "
1025
+ "and data_type in ('text', 'character varying') "
1026
+ "and (column_name like %s or column_name like %s)",
1027
+ ("%name%", "%label%"))
1028
+ name_cols = [r[0] for r in cur.fetchall()]
1029
+ if not name_cols:
1030
+ return set()
1031
+ arr = ", ".join(f'd."{c}"::text' for c in name_cols)
1032
+ cur.execute(f"select distinct lower(btrim(v)) from silver.dim_location d, "
1033
+ f"unnest(array[{arr}]) as v where v is not null and btrim(v) <> ''")
1034
+ return {r[0] for r in cur.fetchall() if r and r[0]}
1035
+ except Exception as e: # noqa: BLE001 — fail-open is the whole point
1036
+ _LOG.info("location vocab unavailable — p_location_names guard inactive (%s)", e)
1037
+ return set()
1038
+
1039
+
1040
+ def _make_executor(dsn: str, label: str, schema: str, proname: str,
1041
+ args_spec: list[tuple[str, str]], connect, statement_timeout_ms: int,
1042
+ required: set[str] | None = None,
1043
+ enums: dict[str, tuple[list[str], bool]] | None = None,
1044
+ profile_rows: int = 100_000, vocab: dict | None = None,
1045
+ locations: set[str] | None = None):
1046
+ """The per-function transport: SELECT * FROM schema.fn(name := %(name)s::type) LIMIT n.
1047
+
1048
+ A fresh connection per call keeps the executor stateless (the agent runs calls in
1049
+ parallel threads) — connect cost is negligible next to the LLM round-trips. The model
1050
+ may drop a ``p_`` prefix or change case on an arg name; a normalized map recovers it.
1051
+
1052
+ ``enums`` (``{arg: (allowed, case_sensitive)}``) is enforced deterministically: a value
1053
+ outside the set is rejected with a message naming the allowed values (the model then
1054
+ self-corrects), so an invented value like ``month_to_date`` never silently returns the
1055
+ wrong rows.
1056
+ """
1057
+ types = dict(args_spec)
1058
+ required = required or set()
1059
+ enums = enums or {}
1060
+ locations = locations or set()
1061
+
1062
+ def _core(a: str) -> str: # drop a "p_"/"_" NAME PREFIX (not lstrip — that eats chars)
1063
+ return re.sub(r"^p_|^_", "", a.lower())
1064
+
1065
+ def _coerce_array(v, sql_type: str):
1066
+ """Normalize a STRINGIFIED array the model emitted for an ``xxx[]`` parameter.
1067
+
1068
+ A tool call is JSON, so a model asked for ``text[]`` often sends the *string* ``"[]"``
1069
+ rather than an empty list — and the reference docs advertise ``"example": []``, which
1070
+ invites exactly that. Postgres array literals use braces, so ``'[]'::text[]`` raises
1071
+ `malformed array literal: "[]"`. Measured in a live 30-case run: 8 of 10 tool failures
1072
+ came from this one value, and one case failed EVERY call and abstained outright.
1073
+ A real empty list and ``''`` already worked, so only the string form is repaired:
1074
+
1075
+ "[]" / "{}" / "" → "" (blank; the caller then omits it or binds NULL)
1076
+ '["a","b"]' → ["a", "b"] (JSON list → real list, psycopg adapts it)
1077
+ "Main St Dental" → ["Main St Dental"] (a BARE scalar → one-element array)
1078
+ '[oops' / '{a,b}' → unchanged (a broken/pg-literal form — leave it to Postgres)
1079
+ """
1080
+ if not sql_type.endswith("[]") or not isinstance(v, str):
1081
+ return v
1082
+ s = v.strip()
1083
+ if s in ("[]", "{}", ""):
1084
+ return "" # falls into the blank branch below → DEFAULT applies (or NULL if required)
1085
+ if s.startswith("[") and s.endswith("]"):
1086
+ try:
1087
+ parsed = json.loads(s)
1088
+ except ValueError:
1089
+ return v
1090
+ if isinstance(parsed, list):
1091
+ return [x for x in parsed if x is not None] or ""
1092
+ return v
1093
+ if s.startswith(("[", "{")):
1094
+ return v # a bracket/brace form we didn't parse — let Postgres report it
1095
+ # A BARE scalar for an array parameter is malformed to Postgres ("malformed array literal:
1096
+ # 'X'"); the model means a single-element list. Measured: p_location_names='Opendental'
1097
+ # (a SOURCE name mistaken for a location) crashed the call outright. Wrap it → then the
1098
+ # p_location_names guard can validate the element and reject an invented location.
1099
+ return [s]
1100
+
1101
+ _norm = {_core(a): a for a, _ in args_spec}
1102
+ source = f"sql://{label}/{schema}.{proname}"
1103
+
1104
+ def fn(args: dict, timeout: int, list_limit: int | None = None,
1105
+ prefer_period: "tuple | None" = None,
1106
+ prefer_terms: "frozenset[str] | None" = None) -> dict:
1107
+ n = max(1, list_limit or 50)
1108
+ scan = max(n, profile_rows) # rows fetched for PROFILING; only `n` are ever shown
1109
+ bound: dict = {}
1110
+ for k, v in (args or {}).items():
1111
+ real = k if k in types else _norm.get(_core(str(k)))
1112
+ if not real:
1113
+ continue # unknown arg — drop it
1114
+ v = _coerce_array(v, types[real])
1115
+ if v is None or v == "" or (isinstance(v, (list, tuple)) and not v):
1116
+ # Blank OPTIONAL arg → omit it (the function's DEFAULT applies). "Blank" also
1117
+ # covers a GENUINE empty list: the model routinely emits `p_location_names=[]`
1118
+ # (the reference docs advertise `"example": []`), which is NOT the same as NULL.
1119
+ # An empty array binds `{}::text[]`, and a `location = ANY(p_location_names)`
1120
+ # filter then matches NOTHING (a silent all-zero/NULL answer), whereas the DEFAULT
1121
+ # (NULL) means "all locations". `_coerce_array` already folds the STRING spellings
1122
+ # ("[]"/"{}") to ""; this catches the real empty list/tuple that bypasses it.
1123
+ # (Only empty SEQUENCES — never a falsy scalar like 0/False, which stay bound.)
1124
+ # A REQUIRED arg has no default — omitting it makes the call match NO signature
1125
+ # ("function … does not exist", live finding) — so bind an explicit NULL.
1126
+ if real in required:
1127
+ bound[real] = None
1128
+ continue
1129
+ if real in enums:
1130
+ allowed, case_sensitive = enums[real]
1131
+ sval = str(v).strip().strip("'\"")
1132
+ if sval in allowed:
1133
+ v = sval
1134
+ elif not case_sensitive and (hit := next(
1135
+ (a for a in allowed if a.lower() == sval.lower()), None)):
1136
+ v = hit # normalize a case-only mismatch to the canonical value
1137
+ else:
1138
+ return {"ok": False, "kind": "bad_argument", "content": "",
1139
+ "source": source, "bad_param": real,
1140
+ "error": (f"invalid value {v!r} for {real}: allowed values are "
1141
+ + ", ".join(repr(a) for a in allowed))}
1142
+ elif (known := (vocab or {}).get(real)) and isinstance(v, str):
1143
+ # VOCABULARY SAFETY NET — this function documents no allowed set, but the same
1144
+ # parameter is documented elsewhere in the schema. A value that is valid for NO
1145
+ # function in the catalog is an invention, and Postgres would accept it silently
1146
+ # and return default-branch data (measured: p_comparison='previous' returned
1147
+ # byte-identical rows to 'yoy'). Rejecting is strictly safe: any value that is
1148
+ # legitimate somewhere passes, so a real call can never be blocked here.
1149
+ sval = str(v).strip().strip("'\"")
1150
+ if (hit := next((a for a in known if a.lower() == sval.lower()), None)):
1151
+ v = hit
1152
+ else:
1153
+ return {"ok": False, "kind": "bad_argument", "content": "", "source": source,
1154
+ "bad_param": real,
1155
+ "error": (f"invalid value {v!r} for {real}: this function does not "
1156
+ f"document its allowed values, but across the schema "
1157
+ f"{real} only ever takes "
1158
+ + ", ".join(repr(a) for a in sorted(known))
1159
+ + ". Use one of those, or omit the parameter.")}
1160
+ elif locations and _core(real) == "location_names" and isinstance(v, (list, tuple)):
1161
+ # LOCATION GUARD — reject a value that is not a real location. The recurring defect:
1162
+ # the model passes a PMS/SOURCE name ('Opendental', 'Dentrix') as a location, when
1163
+ # the source is ALREADY fixed by the function name (fn_rca_OPENDENTAL_*). Postgres
1164
+ # would return the function's shape filled with NULL/0 — a silent wrong answer.
1165
+ # Tagged bad_argument+bad_param so the executor's recovery retries with the param
1166
+ # DROPPED → NULL → the correct all-locations result. Fail-open when the vocab is
1167
+ # empty; only a value that is a location NOWHERE is blocked (never a real one).
1168
+ unknown = [x for x in v
1169
+ if not (isinstance(x, str)
1170
+ and x.strip().strip("'\"").lower() in locations)]
1171
+ if unknown:
1172
+ return {"ok": False, "kind": "bad_argument", "content": "", "source": source,
1173
+ "bad_param": real,
1174
+ "error": (f"unknown location{'s' if len(unknown) > 1 else ''} "
1175
+ + ", ".join(repr(x) for x in unknown)
1176
+ + f" for {real}: not a location in the warehouse. A PMS/"
1177
+ "source name (e.g. 'Opendental', 'Dentrix') is NOT a "
1178
+ "location — the source is already fixed by the function. "
1179
+ f"Use a real location name, or omit {real} for all locations.")}
1180
+ elif _json_type(types[real]) == "integer" and isinstance(v, str) and v.strip().lstrip("-").isdigit():
1181
+ v = int(v.strip()) # belt: the ::cast below is the real guarantee
1182
+ bound[real] = v
1183
+ for a in required: # required args the model omitted entirely → explicit NULL
1184
+ bound.setdefault(a, None)
1185
+ arglist = ", ".join(f'"{a}" := %({a})s::{types[a]}' for a in bound)
1186
+ query = f'SELECT * FROM "{schema}"."{proname}"({arglist}) LIMIT {scan + 1}'
1187
+ _LOG.debug("sql exec: %s params=%s", query, trunc(bound, 300))
1188
+ try:
1189
+ with connect(dsn, timeout, statement_timeout_ms) as conn:
1190
+ cur = conn.cursor()
1191
+ cur.execute(query, bound)
1192
+ cols = [d[0] for d in (cur.description or [])]
1193
+ # Scan WIDER than we display. The rows beyond the display cap are never sent to
1194
+ # the model — they exist only so the statistical profile below describes the
1195
+ # REAL result set. Without this, "the maximum" was whatever topped the first
1196
+ # page (a $172,994 max over 50 of 5,830 rows; the true max was $228,588).
1197
+ fetched = cur.fetchmany(scan + 1)
1198
+ except Exception as e:
1199
+ kind, msg = classify_pg_error(e)
1200
+ return {"ok": False, "error": msg[:800], "kind": kind, "content": "",
1201
+ "source": source}
1202
+ raw_rows = [{c: _jsonable(v) for c, v in zip(cols, r)} for r in fetched[:scan]]
1203
+ # LOG_RAW_ROWS=1 — log what the database returned BEFORE redaction. Deliberately opt-in and
1204
+ # log-only: the redaction below still runs, so the model and the answer are unaffected and
1205
+ # the PII guarantee holds. Without this the "unredacted" payload does not exist anywhere to
1206
+ # log, because redaction happens here, at fetch, not at log time. Writes personal/contact
1207
+ # data to the console (and to LOG_FILE if set) — enable only where that is acceptable.
1208
+ if os.getenv("LOG_RAW_ROWS", "").strip() not in ("", "0", "false", "False"):
1209
+ _LOG.debug("sql raw rows (PRE-redaction) %s.%s: %s", schema, proname, trunc(raw_rows))
1210
+ scanned_rows = [_redact_row(r) for r in raw_rows]
1211
+ rows = _visible_slice(scanned_rows, n, prefer_period, prefer_terms) # user/model sees
1212
+ profile = describe_rows(scanned_rows, scanned=len(scanned_rows),
1213
+ complete=len(fetched) <= scan)
1214
+ payload: dict | list = rows
1215
+ if len(fetched) > n:
1216
+ payload = {"rows": rows,
1217
+ "note": f"more rows exist beyond the first {n} (LIMIT applied)"}
1218
+ # EMPTY-FILTER DETECTION: a filter that matches nothing does not error and does not
1219
+ # return zero rows — the function still emits its shape, filled with zeros. Measured:
1220
+ # the model invented p_location_names=['location1','location2'] and got 12 rows summing
1221
+ # to 0, against 4,718,610.27 unfiltered, then analysed the zeros as real business data.
1222
+ # Rows present + every numeric column identically zero + a filter supplied is not a
1223
+ # legitimate result, so it is reported as a bad argument the agent can self-correct.
1224
+ filters = {k: v for k, v in bound.items()
1225
+ if isinstance(v, (list, tuple)) and v}
1226
+ empty_filter = ""
1227
+ if filters and profile and profile.get("numeric") and scanned_rows:
1228
+ nums = profile["numeric"].values()
1229
+ if nums and all(s.get("min") == 0 and s.get("max") == 0 for s in nums):
1230
+ names = ", ".join(f"{k}={v!r}" for k, v in filters.items())
1231
+ # NOT an error: a REAL location with no activity for this system returns zeros
1232
+ # too (verified — '001-BDD-Indianapolis IN' has no Dentrix production), so
1233
+ # rejecting would block a legitimate "that location has none" answer. The value
1234
+ # of the signal is that the zeros are not silently narrated as analysis, so it
1235
+ # is surfaced in the evidence the model reads.
1236
+ empty_filter = (f"NOTE: the filter {names} matched NO DATA — every value below "
1237
+ f"is zero. Either those values are not real, or they have no "
1238
+ f"activity here. Say so explicitly; do not analyse the zeros as "
1239
+ f"if they were results.\n")
1240
+ _LOG.warning("sql: filter matched no data: %s (%s)", names, proname)
1241
+ period_note = incomplete_period_note(bound)
1242
+ if period_note:
1243
+ _LOG.warning("sql: %s requested an incomplete period: %s", proname,
1244
+ period_note.split("(today")[0].strip())
1245
+ content = period_note + empty_filter + _compact_json(payload, max_items=n) # PII as HTTP
1246
+ src = source + ("?" + json.dumps(bound, sort_keys=True, default=str) if bound else "")
1247
+ # `rows` = the COMPLETE result (every column — no compaction field cap), so the agent
1248
+ # can render tables deterministically and ground figures against the full width.
1249
+ return {"ok": True, "content": content[: max(8000, n * 600)], "source": src,
1250
+ "rows": rows, "rows_truncated": len(fetched) > n,
1251
+ # Aggregates over `scanned_rows` (up to profile_rows), NOT over `rows`.
1252
+ "profile": profile, "rows_scanned": len(scanned_rows)}
1253
+
1254
+ return fn