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/catalog.py ADDED
@@ -0,0 +1,147 @@
1
+ """The operation catalog the router/executor select from.
2
+
3
+ A `Catalog` is a set of `Tool`s — each one an OpenAPI operation produced by
4
+ `openapi_loader.build_catalog(spec)`. A `Tool` carries an NL description (used for
5
+ routing), a JSON-schema for its arguments, and a `fn` that performs the HTTP call
6
+ and returns ``{"ok", "content", "source", "error"}``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Callable
15
+
16
+ from .log import get_logger, trunc
17
+
18
+ _LOG = get_logger("api_agent.catalog")
19
+
20
+ # Leading noise a model tends to add/drop on a tool name (a resource verb, or an `fn_`/`gold_`
21
+ # wrapper prefix) — stripped before matching so a mangled call can still resolve to its tool.
22
+ _NAME_PREFIX = re.compile(r"^(?:gold|fn|get|post|put|patch|delete|head|op)+")
23
+
24
+
25
+ def _name_core(name: str) -> str:
26
+ """Alphanumeric-lowercased tool name with leading verb/wrapper prefixes removed."""
27
+ return _NAME_PREFIX.sub("", re.sub(r"[^a-z0-9]", "", (name or "").lower()))
28
+
29
+
30
+ @dataclass
31
+ class Tool:
32
+ name: str
33
+ description: str
34
+ parameters: dict # JSON schema for the arguments
35
+ data_type: str # "structured"
36
+ # (args, timeout, list_limit=None) -> {"ok","content","source","error"[,"kind"]}.
37
+ # `kind` is the OPTIONAL normalized failure taxonomy — "auth" | "unreachable" |
38
+ # "server_error" — so the agent's failure bookkeeping doesn't have to sniff
39
+ # backend-specific error strings (HTTP status text vs SQLSTATE). When absent, the
40
+ # agent falls back to the HTTP string heuristics.
41
+ fn: Callable[..., dict]
42
+ backend: str = "http" # "http" | "sql" — which transport family produced this tool
43
+ # Scopes the shared result cache: tools with the same name+args but a DIFFERENT data
44
+ # source (e.g. two warehouse DSNs, dev vs prod) must never serve each other's results.
45
+ # Blank (the HTTP loader) keeps today's name-keyed behavior.
46
+ cache_key: str = ""
47
+
48
+ def openai_spec(self) -> dict:
49
+ return {
50
+ "type": "function",
51
+ "function": {
52
+ "name": self.name,
53
+ "description": self.description,
54
+ "parameters": self.parameters,
55
+ },
56
+ }
57
+
58
+
59
+ # Simple in-process result cache (TTL) so repeated calls don't re-hit the API.
60
+ _RESULT_CACHE: dict[str, tuple[float, dict]] = {}
61
+ _CACHE_TTL = 600.0 # seconds
62
+
63
+
64
+ class Catalog:
65
+ def __init__(self, tools: list[Tool], reference: list[dict] | None = None):
66
+ self.tools = {t.name: t for t in tools}
67
+ # Documentation records for ALL operations (incl. non-callable writes), so the agent
68
+ # can describe an operation's parameters without ever calling it. See openapi_loader.
69
+ self.reference = reference or []
70
+ # Resolves a location NAMED IN A QUESTION to the exact values a `*_location_names`
71
+ # parameter accepts (`sql_loader.LocationIndex`). Set by the SQL loader, absent for HTTP
72
+ # catalogs — the agent treats None as "no binding", so nothing depends on it existing.
73
+ self.locations = None
74
+
75
+ def specs(self, enabled: list[str] | None = None) -> list[dict]:
76
+ return [
77
+ t.openai_spec()
78
+ for name, t in self.tools.items()
79
+ if enabled is None or name in enabled
80
+ ]
81
+
82
+ def resolve(self, name: str) -> str | None:
83
+ """The real tool name for what the model asked to call. Exact match wins; otherwise recover a
84
+ mangled name (a small model that dropped a `gold_<source>_` prefix or used the underlying
85
+ `fn_...` function name) — but ONLY when it maps to a SINGLE tool, so we never guess wrong."""
86
+ if name in self.tools:
87
+ return name
88
+ low = name.lower()
89
+ for n in self.tools:
90
+ if n.lower() == low:
91
+ return n
92
+ want = _name_core(name)
93
+ if len(want) < 5: # too short to match safely
94
+ return None
95
+ cands = list({n for n in self.tools
96
+ if (c := _name_core(n)) and (c.endswith(want) or want.endswith(c))})
97
+ return cands[0] if len(cands) == 1 else None
98
+
99
+ def call(self, name: str, args: dict, timeout: int = 20, list_limit: int | None = None,
100
+ prefer_period: "tuple | None" = None,
101
+ prefer_terms: "frozenset[str] | None" = None) -> dict:
102
+ """`prefer_period` is the (year, month) the QUESTION is about, when it names one.
103
+
104
+ A result larger than the display cap has to drop rows, and recency is the right default —
105
+ but it is wrong for a historical question. Measured: three eval cases asking for 2018,
106
+ 2024 and 2025 provider production were answered "the evidence does not include any data
107
+ for 2024", because the most-recent-150 slice handed the model 2025-2026 rows from a table
108
+ that genuinely holds 2024. Backends that cannot use the hint ignore it.
109
+ """
110
+ resolved = self.resolve(name)
111
+ tool = self.tools.get(resolved) if resolved else None
112
+ if not tool:
113
+ _LOG.warning("tool call: '%s' is not a known tool (args=%s)", name, trunc(args, 200))
114
+ return {"ok": False, "error": f"Unknown tool '{name}'", "content": "", "source": ""}
115
+ if resolved != name:
116
+ _LOG.info("tool call: recovered mangled name '%s' → '%s'", name, resolved)
117
+ name = resolved
118
+ key = (f"{tool.backend}:{tool.cache_key}:{name}:"
119
+ f"{json.dumps(args, sort_keys=True, default=str)}:{list_limit}:{prefer_period}:"
120
+ f"{sorted(prefer_terms) if prefer_terms else None}")
121
+ cached = _RESULT_CACHE.get(key)
122
+ now = time.time()
123
+ if cached and now - cached[0] < _CACHE_TTL:
124
+ _LOG.info("tool call: %s args=%s → result-cache HIT (%d chars)",
125
+ name, trunc(args, 300), len(str(cached[1].get("content", ""))))
126
+ return cached[1]
127
+ _LOG.info("tool call: %s args=%s limit=%s", name, trunc(args, 300), list_limit)
128
+ t0 = time.time()
129
+ try:
130
+ try:
131
+ result = tool.fn(args, timeout, list_limit, prefer_period=prefer_period,
132
+ prefer_terms=prefer_terms)
133
+ except TypeError: # an executor that predates the hint (the HTTP backend)
134
+ result = tool.fn(args, timeout, list_limit)
135
+ except Exception as e: # network/parse errors degrade gracefully
136
+ _LOG.warning("tool call: %s raised %s: %s", name, e.__class__.__name__, trunc(str(e), 300))
137
+ return {"ok": False, "error": str(e), "content": "", "source": ""}
138
+ if result.get("ok"):
139
+ _RESULT_CACHE[key] = (now, result)
140
+ _LOG.info("tool call: %s ← ok (%d chars, %.2fs) source=%s",
141
+ name, len(str(result.get("content", ""))), time.time() - t0,
142
+ trunc(result.get("source", ""), 200))
143
+ _LOG.debug("tool call: %s content: %s", name, trunc(result.get("content", "")))
144
+ else:
145
+ _LOG.warning("tool call: %s ← FAILED kind=%s error=%s", name,
146
+ result.get("kind", "-"), trunc(str(result.get("error", "")), 300))
147
+ return result
api_agent/chart.py ADDED
@@ -0,0 +1,144 @@
1
+ """When a result is worth drawing, and what to draw — decided in code, never by the model.
2
+
3
+ A trend is the one shape prose is genuinely bad at. Asked for "the Dentrix production trend for the
4
+ last 12 months through June 2026", the agent produced an accurate paragraph naming the first month,
5
+ the last month, the low, the high, the total and the year-over-year change — six numbers standing in
6
+ for twelve, and the reader still cannot see the shape. Twelve points on one line say it at a glance.
7
+
8
+ The whole design question is *when*, because a chart of the wrong thing is worse than no chart: it
9
+ is read faster than prose and doubted less. So this module answers with a spec or with None, and the
10
+ conditions are deliberately narrow — every one of them exists to stop a specific way a chart lies:
11
+
12
+ * TRUNCATED ROWS ARE NEVER CHARTED. A line drawn over 150 of 665 rows shows a shape the data does
13
+ not have, and unlike a table it carries no visible sign that anything is missing. This is the
14
+ strictest rule here and the one most likely to be relaxed by accident.
15
+ * A GENUINE TIME SERIES ONLY (`metrics.period_column`: every row carries a period, all distinct,
16
+ already in order). A panel with repeating periods — twelve months x forty providers — would draw
17
+ a zigzag that means nothing.
18
+ * SMALL. Above ~36 points the picture stops being "a year at a glance"; below 3 there is no shape
19
+ to see and a stat tile is the honest form.
20
+ * ONE SCALE. Series are kept only while they share the primary's order of magnitude, so a dollar
21
+ line and a percentage line can never share an axis. A dual-scale chart is the most common
22
+ charting mistake there is, and the only reliable defence is to refuse to build one.
23
+ * AT MOST THREE SERIES, in a fixed hue order — the count that stays distinguishable under
24
+ colour-vision deficiency for every pair, so a reader who cannot separate two lines by hue is
25
+ never left guessing.
26
+
27
+ Pure and dependency-free so it is testable without Streamlit or a browser; `ui.py` owns the drawing.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from dataclasses import dataclass, field
33
+
34
+ from .metrics import _as_period, _num, period_column
35
+
36
+ # A year of months at a glance is the case this exists for; three years still reads. Past that the
37
+ # eye is doing a job a table does better, and the points crowd below the 8px marker minimum.
38
+ MAX_POINTS = 36
39
+ MIN_POINTS = 3
40
+ # Three hues clear every colour-vision-deficiency pair test in BOTH light and dark; a fourth puts
41
+ # yellow and orange on screen together and stops clearing them. A result with more measures than
42
+ # this keeps them — in the table view, which is always available beside the chart.
43
+ MAX_SERIES = 3
44
+
45
+ # Columns that are never a measure worth plotting: identifiers and ordering keys. Matched as a
46
+ # whole word so `sort_key` and `card_id` go, because that is what these are — plumbing, not data.
47
+ _NOT_A_MEASURE = re.compile(r"(?:^|_)(?:id|key|sort|idx|rank|code)(?:_|$)", re.I)
48
+ # A BARE calendar column (`year`, `month`) holds an integer that would otherwise plot as a measure.
49
+ # It must be a WHOLE-name match: `prior_year` and `last_year_val` are money, and an affix match on
50
+ # `year` silently dropped the comparison series from every trend chart — the second line, the one
51
+ # the whole chart exists to compare against.
52
+ _CALENDAR_NAME = re.compile(r"(?:year|month|day|qtr|quarter|week|period|date)s?", re.I)
53
+ # A share, a rate or a count lives on a different scale from money and must never share its axis.
54
+ _RATIO = re.compile(r"pct|percent|rate|ratio|share|margin|per_", re.I)
55
+
56
+
57
+ @dataclass
58
+ class ChartSpec:
59
+ """Everything the renderer needs, and nothing about how it looks."""
60
+ period_column: str
61
+ series: list[str] # measure columns, primary first
62
+ points: list[dict] = field(default_factory=list) # [{period, series, value}]
63
+ label: str = "" # what the reader is looking at
64
+
65
+
66
+ def _measure_columns(rows: list[dict], period_col: str) -> list[str]:
67
+ """Numeric columns that hold a measure, most-populated first.
68
+
69
+ Order matters: the first one becomes the primary and sets the scale every other series has to
70
+ match, so it must be the column the reader is most likely to have asked about. Completeness is
71
+ the best available proxy — a column the warehouse fills for every row is the one the function
72
+ exists to return, and a mostly-null `budget` should never define the axis.
73
+ """
74
+ out: list[tuple[int, int, str]] = []
75
+ for i, col in enumerate(rows[0]):
76
+ if col == period_col or _NOT_A_MEASURE.search(col) or _CALENDAR_NAME.fullmatch(col):
77
+ continue
78
+ vals = [v for r in rows if (v := _num(r.get(col))) is not None]
79
+ if len(vals) < len(rows) * 0.5: # mostly empty — not this result's subject
80
+ continue
81
+ out.append((-len(vals), i, col)) # ties keep the warehouse's own column order
82
+ return [c for _, _, c in sorted(out)]
83
+
84
+
85
+ def _same_scale(rows: list[dict], primary: str, other: str) -> bool:
86
+ """Can `other` share `primary`'s axis?
87
+
88
+ Two guards, because either alone lets a lie through. The NAME guard stops a percentage sharing
89
+ an axis with dollars even when a small month happens to make the magnitudes look close. The
90
+ MAGNITUDE guard stops a count sharing an axis with dollars even though neither name says so —
91
+ plotting 3,437 procedures against $421,448 makes the procedure line a flat stripe on the floor
92
+ and invites the reader to conclude volume never moved.
93
+ """
94
+ if bool(_RATIO.search(primary)) != bool(_RATIO.search(other)):
95
+ return False
96
+ def typical(col: str) -> float:
97
+ vals = sorted(abs(v) for r in rows if (v := _num(r.get(col))) not in (None, 0))
98
+ return vals[len(vals) // 2] if vals else 0.0
99
+ a, b = typical(primary), typical(other)
100
+ if not a or not b:
101
+ return False
102
+ return max(a, b) / min(a, b) <= 20.0
103
+
104
+
105
+ def chart_spec(rows: list[dict] | None, *, truncated: bool = False,
106
+ label: str = "") -> ChartSpec | None:
107
+ """A spec for a chart worth drawing, or None. See the module docstring for why each gate exists.
108
+
109
+ Returning None is the DEFAULT and the safe answer; every condition below has to hold.
110
+ """
111
+ if truncated or not rows or not isinstance(rows[0], dict):
112
+ return None
113
+ if not (MIN_POINTS <= len(rows) <= MAX_POINTS):
114
+ return None
115
+ period_col = period_column(rows) # distinct AND ordered — a panel is rejected here
116
+ if not period_col:
117
+ return None
118
+ measures = _measure_columns(rows, period_col)
119
+ if not measures:
120
+ return None
121
+ primary = measures[0]
122
+ series = [primary] + [c for c in measures[1:] if _same_scale(rows, primary, c)]
123
+ series = series[:MAX_SERIES]
124
+ points = [{"period": _period_label(r.get(period_col)), "series": c, "value": v}
125
+ for r in rows for c in series if (v := _num(r.get(c))) is not None]
126
+ if len(points) < MIN_POINTS:
127
+ return None
128
+ return ChartSpec(period_column=period_col, series=series, points=points, label=label)
129
+
130
+
131
+ _MONTHS = ("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec").split()
132
+
133
+
134
+ def _period_label(v) -> str:
135
+ """'2026-06-01' -> 'Jun 2026'. Axis ticks are read, not parsed."""
136
+ p = _as_period(v)
137
+ if not p:
138
+ return str(v)
139
+ return f"{_MONTHS[p[1] - 1]} {p[0]}"
140
+
141
+
142
+ def humanize(col: str) -> str:
143
+ """`prior_year` -> `Prior Year`. The legend is prose, not a schema."""
144
+ return re.sub(r"[_\s]+", " ", col or "").strip().title()
api_agent/cli.py ADDED
@@ -0,0 +1,31 @@
1
+ """Console entry point. After ``pip install "codi-api-agent[ui]"``:
2
+
3
+ api-agent # launch the Streamlit UI
4
+ api-agent --help # (streamlit flags are passed through, e.g. --server.port 8600)
5
+
6
+ The DISTRIBUTION is `codi-api-agent` (the generic `api-agent` was already taken on PyPI); the
7
+ import name and this console script stay `api_agent` / `api-agent`. Any install hint printed to a
8
+ user has to name the distribution, or it sends them to a package that cannot be installed.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+
16
+ def main() -> None:
17
+ ui = Path(__file__).with_name("ui.py")
18
+ try:
19
+ from streamlit.web import cli as stcli
20
+ except ModuleNotFoundError:
21
+ sys.exit(
22
+ 'Streamlit is not installed. Install the UI extra:\n\n'
23
+ ' pip install "codi-api-agent[ui]"\n\n'
24
+ "(or use the agent programmatically: from api_agent import Agent, Config, load_catalog)"
25
+ )
26
+ sys.argv = ["streamlit", "run", str(ui), *sys.argv[1:]]
27
+ sys.exit(stcli.main())
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
api_agent/config.py ADDED
@@ -0,0 +1,296 @@
1
+ """Runtime configuration.
2
+
3
+ Everything is environment-driven so the LLM provider and the per-component
4
+ models stay swappable without touching code. The Streamlit UI can also override
5
+ any field at runtime via `Config.override(...)`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from dataclasses import dataclass, field, replace
12
+
13
+ try: # .env is convenient in dev but optional
14
+ from dotenv import load_dotenv
15
+
16
+ load_dotenv()
17
+ except Exception: # pragma: no cover - dotenv is optional
18
+ pass
19
+
20
+
21
+ # Indicative USD pricing per 1,000,000 tokens as (input, output). Prices change and vary by
22
+ # provider — treat these as defaults and OVERRIDE via the MODEL_PRICING env var (JSON, e.g.
23
+ # '{"gpt-4o": [2.5, 10]}') or Config.model_pricing. A model not in the table (e.g. a free Groq
24
+ # model) is treated as unpriced and no $ cost is shown.
25
+ DEFAULT_MODEL_PRICING: dict[str, tuple[float, float]] = {
26
+ "gpt-4o": (2.50, 10.00),
27
+ "gpt-4o-mini": (0.15, 0.60),
28
+ "gpt-4.1": (2.00, 8.00),
29
+ "gpt-4.1-mini": (0.40, 1.60),
30
+ "gpt-4.1-nano": (0.10, 0.40),
31
+ "o3": (2.00, 8.00),
32
+ "o4-mini": (1.10, 4.40),
33
+ "gpt-4-turbo": (10.00, 30.00),
34
+ "gpt-3.5-turbo": (0.50, 1.50),
35
+ }
36
+
37
+
38
+ def _csv_env(name: str) -> list[str]:
39
+ """Parse a comma-separated env var into a stripped, non-empty list (e.g. LLM_API_KEYS)."""
40
+ return [x.strip() for x in os.getenv(name, "").split(",") if x.strip()]
41
+
42
+
43
+ def _json_list_env(name: str) -> list:
44
+ """Parse a JSON-array env var (e.g. LLM_POOL) into a list; [] on absent/invalid."""
45
+ raw = os.getenv(name, "").strip()
46
+ if not raw:
47
+ return []
48
+ try:
49
+ val = json.loads(raw)
50
+ return val if isinstance(val, list) else []
51
+ except Exception:
52
+ return []
53
+
54
+
55
+ def _pricing_from_env(base: dict) -> dict:
56
+ """Merge a MODEL_PRICING JSON override (e.g. '{"gpt-4o":[2.5,10]}') over the defaults."""
57
+ raw = os.getenv("MODEL_PRICING", "").strip()
58
+ merged = dict(base)
59
+ if raw:
60
+ try:
61
+ for k, v in json.loads(raw).items():
62
+ merged[k] = (float(v[0]), float(v[1]))
63
+ except Exception:
64
+ pass
65
+ return merged
66
+
67
+
68
+ @dataclass
69
+ class Config:
70
+ # OpenAI-compatible provider
71
+ base_url: str = "https://api.groq.com/openai/v1"
72
+ api_key: str = ""
73
+ # Rotate over SEVERAL keys on the same base_url (e.g. multiple Groq free accounts) so each key's
74
+ # daily/TPM limit is spread. From LLM_API_KEYS (comma-separated); falls back to [api_key].
75
+ api_keys: list[str] = field(default_factory=list)
76
+ # Advanced: a cross-provider backend pool (Groq + HuggingFace + …). JSON list of
77
+ # {"base_url","api_key","model"} via LLM_POOL — when set, the client ROTATES over these full
78
+ # backends for every call (spreading each provider's limit), ignoring the per-role model below.
79
+ llm_pool: list[dict] = field(default_factory=list)
80
+
81
+ # Per-component model selection (both must support tool calling). Each MAY be a comma-separated
82
+ # POOL (e.g. "openai/gpt-oss-120b,llama-3.3-70b-versatile") — the client rotates across the pool
83
+ # per query so no single model hits its rate limit, with automatic failover on a 429.
84
+ generator_model: str = "llama-3.3-70b-versatile"
85
+ judge_model: str = "llama-3.3-70b-versatile"
86
+ router_model: str = "" # blank -> reuse the generator model
87
+ # MASTER SWITCH for LLM-BASED JUDGING / SCORING (JUDGE_ENABLED=0 to turn it off for a session).
88
+ # The judges cost real money — on a paid provider they are the grounding check, the
89
+ # responsiveness check and the presentation check, i.e. up to three extra calls per answered
90
+ # query, and the grounding one re-sends the whole evidence block. Off:
91
+ # * `_validate` (grounding), `_assess_answered` (responsiveness) and `_assess_communication`
92
+ # (presentation) return their neutral no-op verdicts WITHOUT calling the provider;
93
+ # * `AgentResult.faithfulness.judged` is False (the UI shows "not judged" and says why);
94
+ # * `AgentResult.rubric` is None — a scorecard whose groundedness dimension was never graded
95
+ # would read as 0/5 "figures not verified", which is a lie about an unjudged answer;
96
+ # * the self-correction retry cannot fire, because nothing flags a claim to fix.
97
+ # What does NOT change: every DETERMINISTIC guarantee. The figure-match against the fetched data
98
+ # (`_numbers_grounded`), the total / attribution / derived-figure / superlative repairs, the
99
+ # confidentiality scrub, the PII scan and the data-quality disclosure all still run — they are
100
+ # code, not judges. So the answer is still checked; it just isn't SCORED.
101
+ judge_enabled: bool = True
102
+ # The USER-FACING WRITING stages (synthesis, self-review, resynthesis) — the only ones whose
103
+ # output the user actually reads, and the only ones where a stronger model changes perceived
104
+ # quality. Measured on the 2026-07-25 eval run they are ~42% of input tokens (synthesis 9,896
105
+ # + self-review 7,224 + resynthesis 142 in/query), so upgrading ONLY these buys most of the
106
+ # quality for a fraction of a blanket upgrade: gpt-4.1-mini here + gpt-4o-mini everywhere else
107
+ # costs 1.7x total ($0.0071 -> $0.0123/query) vs 6.4x if every stage moved.
108
+ # Blank -> reuse the generator model (previous behaviour, so this is backwards-compatible).
109
+ synthesis_model: str = ""
110
+
111
+ # Routing (narrow a large spec's operations to the relevant few per query)
112
+ router_enabled: bool = True
113
+ router_top_k: int = 8 # caps.md: recall@5 stuck at 60% across 4 runs — give the executor 8 ops
114
+ router_min_tools: int = 6 # specs with at/below this many ops skip routing
115
+ router_llm_window: int = 80 # how many lexically-ranked ops the LLM picks from
116
+
117
+ # Embedding-based routing (semantic recall, fused with lexical via RRF = "hybrid"). Set to a
118
+ # local sentence-transformers model by default (downloaded once, ~80MB, runs offline after).
119
+ # Blank disables it (lexical only, no deps). If embedding_base_url is set it's a hosted
120
+ # OpenAI-compatible /embeddings endpoint instead of a local model.
121
+ embedding_model: str = "all-MiniLM-L6-v2" # local; or "text-embedding-3-small" w/ a base_url
122
+ embedding_base_url: str = "" # set -> hosted; blank -> local sentence-transformers
123
+ embedding_api_key: str = "" # hosted key; blank -> reuse api_key
124
+
125
+ # Behaviour
126
+ max_tool_iterations: int = 8 # caps.md: ARQ analytical cases legitimately use 6–10 calls
127
+ # When the faithfulness judge flags specific unsupported claims, hand them back to the
128
+ # synthesizer to fix and re-check. 0 disables; each retry is 1 synthesis + 1 judge call, and a
129
+ # retry is kept only if it scores higher — so it can't make the answer worse.
130
+ max_synthesis_retries: int = 1
131
+ # Attach a five-dimension quality scorecard (correctness / completeness / groundedness /
132
+ # safety / communication) to every substantive answer. Groundedness reuses the existing
133
+ # (isolated) faithfulness judge and safety/correctness/completeness are DERIVED from signals
134
+ # already computed — so the ONLY added cost is one narrow presentation-only "communication"
135
+ # judge call per answered/partial query. Set RUBRIC_REPORT=0 to disable (no extra call, and
136
+ # AgentResult.rubric is None).
137
+ rubric_report: bool = True
138
+ # DATA-QUALITY DISCLOSURE. On: a load fault found in the fetched rows is surfaced to the reader
139
+ # — a trailing "Data quality" caveat, and for a fault in a figure the answer actually SHOWS, a
140
+ # leading banner plus a downgrade to `partial`. Set DATA_QUALITY_NOTES=0 to hide all of it from
141
+ # the answer.
142
+ #
143
+ # Hiding it does NOT make the answer safer: the flags fire on incomplete loads (a column that
144
+ # collapses to near zero, reverses sign, or is empty), and a decline computed against those
145
+ # values reflects the missing data rather than the business. With this off the reader gets the
146
+ # figures with nothing to warn them. Detection still runs and every flag is still logged at
147
+ # WARNING, so the fault stays visible in the trail and to the eval — only the reader stops
148
+ # seeing it.
149
+ data_quality_notes: bool = True
150
+ # DEBUG PANELS — "Sources" and "How this answer was produced". They carry the real operation
151
+ # names, their exact arguments and the source URIs, because that is what makes an answer
152
+ # debuggable. That is also exactly what must never reach a customer, so set DEBUG_PANELS=0 in
153
+ # production. The customer-visible live step trail is unaffected either way: it names only the
154
+ # business category an operation belongs to.
155
+ debug_panels: bool = True
156
+ # Cache: for a reworded repeat that isn't token-identical, the FAST router LLM decides whether it
157
+ # wants the SAME answer as a recent cached query. This ONLY runs on candidates that already passed
158
+ # the deterministic VALUE GUARD (same signature, count, numbers AND month names), so the earlier
159
+ # false-match class (a March finance-and-payroll snapshot served an April NetSuite report) is
160
+ # fenced out before the LLM is consulted — it can only judge same-vs-different REQUEST within one
161
+ # period. On by default; set CACHE_SEMANTIC=0 to require an exact normalized-token repeat instead.
162
+ cache_semantic: bool = True
163
+ http_timeout: int = 20
164
+ # SQL (warehouse) backend — used when a Postgres DSN is loaded as a source. The DSN
165
+ # should point at a SELECT-only role; the loader additionally forces read-only
166
+ # transactions + a statement timeout on every connection (see sql_loader.py).
167
+ sql_dsn: str = "" # e.g. postgresql://agent_ro:***@localhost:7432/arq-dev
168
+ sql_schema: str = "gold" # schema whose functions become tools
169
+ sql_fn_prefix: str = "fn_" # only functions matching this prefix are exposed
170
+ sql_timeout: int = 30 # per-statement timeout, seconds
171
+ evidence_char_limit: int = 12000 # caps.md: floor on each result to SYNTHESIS (ev_cap scales with list_limit)
172
+ # How many items of a LIST response to show by default; the user can override per query ("show
173
+ # 100", "list all"). `list_max` caps "all" so a huge list can't blow the token budget. The
174
+ # per-result char budget scales with the requested count so the extra items actually fit.
175
+ list_default: int = 150 # caps.md: input is cheap (~15% of window); triples rows the model can filter from
176
+ list_max: int = 400 # caps.md: opt-in ceiling ("show all"); beyond this, aggregate instead of paging
177
+ # Append the code-rendered EVIDENCE TABLE beneath the narrative. Off by default: a wall of rows
178
+ # is not an answer, and the table has repeatedly been mistaken for one — a 54-figure dump
179
+ # outscored a correct sentence on the parity scorer. The figures a reader needs must come from
180
+ # the narrative and the derived facts, not from being handed the raw result to add up.
181
+ # NOTE: `_total_guard` computes its total from THIS table, so with tables off it cannot fire.
182
+ # Any total must then come from the derived facts (see the Phase 3 work in
183
+ # docs/hali_replacement_plan_v2.md).
184
+ render_evidence_table: bool = False
185
+ # SHARED CACHEABLE PREFIX across the three full-evidence stages (synthesis, self-review,
186
+ # validator). They each re-send the same evidence — measured at 453,543 input tokens over 62
187
+ # calls in one 20-case run, 60% of all input — and the provider's prefix cache cannot help,
188
+ # because each opens with a DIFFERENT system message so the token prefix diverges at position
189
+ # zero. With this on, every one of them leads with a byte-identical context block (question +
190
+ # evidence + code-computed facts) and puts its own instruction after it, so calls 2 and 3 read
191
+ # from cache. OFF by default: it moves each stage's task instruction behind a large block of
192
+ # data, which can weaken instruction adherence, and that trade has to be MEASURED on the eval
193
+ # set rather than assumed. Run the suite with it off and on and compare.
194
+ #
195
+ # The flag toggles LAYOUT ONLY, and making that true required one change in BOTH arms: the
196
+ # validator used to render evidence as `[id] content`, dropping the source label and skipping
197
+ # failed calls, while the other two stages used `[id] (source: …) content`. They now share one
198
+ # renderer (`_evidence_block`) — measured at +452 characters on a 62,055-character block, and
199
+ # it gains the judge the failed-call entries the writers already saw. Without that the flag
200
+ # would have been changing two things at once and the A/B would isolate nothing.
201
+ shared_prompt_prefix: bool = False
202
+ # Rows SCANNED (not shown) to build the statistical profile handed to the model in place of
203
+ # a huge table. The display cap stays `list_default`; this only widens what the DESCRIBE
204
+ # covers, so "the maximum" is the real maximum instead of the top of the first page — the
205
+ # measured failure was a $172,994 max computed from 50 of 5,830 rows (true: $228,588).
206
+ # Rows are discarded after profiling, so the PROMPT cost is ~10 lines regardless of this
207
+ # number; the only cost is fetch + PII-redaction time, measured at ~1s per 100k rows.
208
+ # Set high enough that real tables are profiled COMPLETELY (the largest ARQ function
209
+ # returns 5,830 rows, so this is ~17x headroom) — an incomplete scan makes the agent hedge
210
+ # ("large SAMPLE, not definitive") and blocks the deterministic superlative repair, which
211
+ # only fires on a complete profile. A table beyond this is still handled honestly rather
212
+ # than wrongly: `profile.complete` goes false and no extreme is asserted.
213
+ profile_rows: int = 100_000
214
+ # How much of each result is echoed BACK into the executor's working context per turn. Smaller
215
+ # than evidence_char_limit so the tool-loop stays lean and fast on multi-call (fan-out /
216
+ # pagination) queries — the executor only needs enough to decide the next call, while synthesis
217
+ # still gets the full result above. Raise if chaining needs more of each response visible.
218
+ executor_context_chars: int = 4000 # caps.md: modest bump for chaining; below the accumulation cliff
219
+ max_operations: int = 1000 # cap on ops loaded from a spec (router narrows per query)
220
+
221
+ # Spending guardrails (for paid, per-token providers)
222
+ # Stop a SINGLE response once its cumulative tokens reach this ceiling — the executor stops
223
+ # fanning out / retrying and answers with what it has (marked partial). 0 = unlimited.
224
+ max_response_tokens: int = 150_000 # caps.md: runaway ceiling (~3x a normal query, ~$0.03); was 0 = no cap
225
+ # Per-model USD pricing {model: (input_per_1M, output_per_1M)} used to estimate $ per response.
226
+ model_pricing: dict = field(default_factory=lambda: dict(DEFAULT_MODEL_PRICING))
227
+
228
+ @classmethod
229
+ def from_env(cls) -> "Config":
230
+ d = cls()
231
+ return cls(
232
+ base_url=os.getenv("LLM_BASE_URL", d.base_url),
233
+ api_key=os.getenv("LLM_API_KEY", d.api_key),
234
+ api_keys=_csv_env("LLM_API_KEYS"),
235
+ llm_pool=_json_list_env("LLM_POOL"),
236
+ generator_model=os.getenv("GENERATOR_MODEL", d.generator_model),
237
+ synthesis_model=os.getenv("SYNTHESIS_MODEL", d.synthesis_model),
238
+ judge_model=os.getenv("JUDGE_MODEL", d.judge_model),
239
+ judge_enabled=os.getenv("JUDGE_ENABLED", str(d.judge_enabled)).lower()
240
+ not in ("0", "false", "no", "off"),
241
+ router_model=os.getenv("ROUTER_MODEL", d.router_model),
242
+ router_enabled=os.getenv("ROUTER_ENABLED", str(d.router_enabled)).lower()
243
+ not in ("0", "false", "no", "off"),
244
+ router_top_k=int(os.getenv("ROUTER_TOP_K", d.router_top_k)),
245
+ router_min_tools=int(os.getenv("ROUTER_MIN_TOOLS", d.router_min_tools)),
246
+ router_llm_window=int(os.getenv("ROUTER_LLM_WINDOW", d.router_llm_window)),
247
+ max_tool_iterations=int(os.getenv("MAX_TOOL_ITERATIONS", d.max_tool_iterations)),
248
+ max_synthesis_retries=int(os.getenv("MAX_SYNTHESIS_RETRIES", d.max_synthesis_retries)),
249
+ rubric_report=os.getenv("RUBRIC_REPORT", str(d.rubric_report)).lower()
250
+ not in ("0", "false", "no", "off"),
251
+ data_quality_notes=os.getenv("DATA_QUALITY_NOTES", str(d.data_quality_notes)).lower()
252
+ not in ("0", "false", "no", "off"),
253
+ debug_panels=os.getenv("DEBUG_PANELS", str(d.debug_panels)).lower()
254
+ not in ("0", "false", "no", "off"),
255
+ cache_semantic=os.getenv("CACHE_SEMANTIC", str(d.cache_semantic)).lower()
256
+ not in ("0", "false", "no", "off"),
257
+ http_timeout=int(os.getenv("HTTP_TIMEOUT", d.http_timeout)),
258
+ sql_dsn=os.getenv("SQL_DSN", d.sql_dsn),
259
+ sql_schema=os.getenv("SQL_SCHEMA", d.sql_schema),
260
+ sql_fn_prefix=os.getenv("SQL_FN_PREFIX", d.sql_fn_prefix),
261
+ sql_timeout=int(os.getenv("SQL_TIMEOUT", d.sql_timeout)),
262
+ evidence_char_limit=int(os.getenv("EVIDENCE_CHAR_LIMIT", d.evidence_char_limit)),
263
+ executor_context_chars=int(os.getenv("EXECUTOR_CONTEXT_CHARS", d.executor_context_chars)),
264
+ list_default=int(os.getenv("LIST_DEFAULT", d.list_default)),
265
+ list_max=int(os.getenv("LIST_MAX", d.list_max)),
266
+ render_evidence_table=os.getenv("RENDER_EVIDENCE_TABLE",
267
+ str(d.render_evidence_table)).lower()
268
+ in ("1", "true", "yes", "on"),
269
+ shared_prompt_prefix=os.getenv("SHARED_PROMPT_PREFIX",
270
+ str(d.shared_prompt_prefix)).lower()
271
+ in ("1", "true", "yes", "on"),
272
+ profile_rows=int(os.getenv("PROFILE_ROWS", d.profile_rows)),
273
+ max_operations=int(os.getenv("MAX_OPERATIONS", d.max_operations)),
274
+ max_response_tokens=int(os.getenv("MAX_RESPONSE_TOKENS", d.max_response_tokens)),
275
+ model_pricing=_pricing_from_env(d.model_pricing),
276
+ embedding_model=os.getenv("EMBEDDING_MODEL", d.embedding_model),
277
+ embedding_base_url=os.getenv("EMBEDDING_BASE_URL", d.embedding_base_url),
278
+ embedding_api_key=os.getenv("EMBEDDING_API_KEY", d.embedding_api_key),
279
+ )
280
+
281
+ def override(self, **kwargs) -> "Config":
282
+ """Return a copy with non-empty overrides applied (used by the UI)."""
283
+ clean = {k: v for k, v in kwargs.items() if v is not None and v != ""}
284
+ return replace(self, **clean)
285
+
286
+ def effective_keys(self) -> list[str]:
287
+ """The API key(s) to rotate over: the LLM_API_KEYS pool if given, else the single api_key."""
288
+ return self.api_keys or [self.api_key]
289
+
290
+ def problems(self) -> list[str]:
291
+ issues: list[str] = []
292
+ if not self.api_key:
293
+ issues.append("LLM_API_KEY is not set — add your provider key.")
294
+ if not self.base_url:
295
+ issues.append("LLM_BASE_URL is not set.")
296
+ return issues