@event4u/agent-config 2.12.0 → 2.14.0
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.
- package/.agent-src/commands/council/analysis.md +142 -0
- package/.agent-src/commands/council/debate.md +129 -0
- package/.agent-src/commands/council/default.md +8 -0
- package/.agent-src/commands/council/design.md +16 -12
- package/.agent-src/commands/council/optimize.md +16 -15
- package/.agent-src/commands/council/pr.md +12 -12
- package/.agent-src/commands/council.md +48 -2
- package/.agent-src/commands/memory/learn-low-impact.md +143 -0
- package/.agent-src/personas/advisors/contrarian.md +95 -0
- package/.agent-src/personas/advisors/executor.md +99 -0
- package/.agent-src/personas/advisors/expansionist.md +98 -0
- package/.agent-src/personas/advisors/first-principles.md +98 -0
- package/.agent-src/personas/advisors/outsider.md +102 -0
- package/.agent-src/rules/ask-when-uncertain.md +10 -6
- package/.agent-src/rules/copilot-routing.md +19 -0
- package/.agent-src/rules/devcontainer-routing.md +20 -0
- package/.agent-src/rules/external-reference-deep-dive.md +1 -1
- package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
- package/.agent-src/rules/laravel-routing.md +20 -0
- package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
- package/.agent-src/rules/symfony-routing.md +20 -0
- package/.agent-src/skills/ai-council/SKILL.md +388 -10
- package/.agent-src/skills/copilot-config/SKILL.md +1 -1
- package/.agent-src/skills/devcontainer/SKILL.md +1 -1
- package/.agent-src/skills/laravel/SKILL.md +1 -1
- package/.agent-src/skills/project-analysis-core/SKILL.md +1 -1
- package/.agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/.agent-src/skills/symfony-workflow/SKILL.md +1 -1
- package/.agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.claude-plugin/marketplace.json +4 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +346 -124
- package/CONTRIBUTING.md +5 -0
- package/README.md +6 -6
- package/config/agent-settings.template.yml +5 -93
- package/config/gitignore-block.txt +6 -0
- package/docs/architecture/multi-tool-projection.md +53 -0
- package/docs/architecture/{compression.md → source-projection.md} +21 -3
- package/docs/architecture.md +15 -15
- package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
- package/docs/catalog.md +25 -12
- package/docs/contracts/adr-architectural-consensus-mechanism.md +68 -0
- package/docs/contracts/adr-level-6-productization.md +7 -9
- package/docs/contracts/ai-council-config.md +658 -0
- package/docs/contracts/command-clusters.md +58 -2
- package/docs/contracts/command-surface-tiers.md +3 -2
- package/docs/contracts/cost-profile-defaults.md +5 -0
- package/docs/contracts/decision-engine-gates.md +5 -0
- package/docs/contracts/decision-trace-v1.md +2 -2
- package/docs/contracts/file-ownership-matrix.json +1735 -72
- package/docs/contracts/installed-tools-lockfile.md +2 -1
- package/docs/contracts/low-impact-corpus-format.md +95 -0
- package/docs/contracts/mcp-beta-criteria.md +6 -5
- package/docs/contracts/mcp-cloud-scope.md +5 -4
- package/docs/contracts/multi-tool-projection-fidelity.md +115 -0
- package/docs/contracts/release-trunk-sync.md +4 -3
- package/docs/contracts/tier-3-contrib-plugin.md +5 -6
- package/docs/getting-started.md +2 -2
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
- package/docs/installation.md +32 -0
- package/package.json +1 -1
- package/scripts/_archive/README.md +59 -0
- package/scripts/_cli/cmd_doctor.py +134 -0
- package/scripts/ai_council/_default_prices.py +10 -1
- package/scripts/ai_council/advisors.py +148 -0
- package/scripts/ai_council/airgap.py +165 -0
- package/scripts/ai_council/cli_hints.py +123 -0
- package/scripts/ai_council/clients.py +959 -5
- package/scripts/ai_council/compile_corpus.py +178 -0
- package/scripts/ai_council/confidence_gate.py +156 -0
- package/scripts/ai_council/config.py +1364 -0
- package/scripts/ai_council/consensus.py +329 -0
- package/scripts/ai_council/events_log.py +137 -0
- package/scripts/ai_council/learn_low_impact_preview.py +252 -0
- package/scripts/ai_council/low_impact.py +714 -0
- package/scripts/ai_council/low_impact_corpus.py +466 -0
- package/scripts/ai_council/low_impact_intake.py +163 -0
- package/scripts/ai_council/modes.py +6 -1
- package/scripts/ai_council/necessity.py +782 -0
- package/scripts/ai_council/orchestrator.py +872 -20
- package/scripts/ai_council/probation_gate.py +152 -0
- package/scripts/ai_council/prompts.py +335 -0
- package/scripts/ai_council/redact_low_impact_entry.py +155 -0
- package/scripts/ai_council/replay.py +155 -0
- package/scripts/ai_council/session.py +19 -1
- package/scripts/ai_council/shadow_dispatch.py +235 -0
- package/scripts/ai_council/solo_dispatch.py +226 -0
- package/scripts/audit_cloud_compatibility.py +74 -0
- package/scripts/audit_command_surface.py +363 -0
- package/scripts/check_compressed_paths.py +6 -1
- package/scripts/check_council_layout.py +11 -0
- package/scripts/ci_time_ratio.py +168 -0
- package/scripts/council_cli.py +2005 -30
- package/scripts/install.sh +12 -0
- package/scripts/measure_projection_bytes.py +159 -0
- package/scripts/measure_roadmap_trajectory.py +112 -0
- package/scripts/probe_projection_fidelity.py +202 -0
- package/scripts/score_skill_selection.py +198 -0
- package/scripts/skill_collision_clusters.py +162 -0
- /package/scripts/{_backfill_skill_domains.py → _archive/_backfill_skill_domains.py} +0 -0
- /package/scripts/{_bootstrap_tier_frontmatter.py → _archive/_bootstrap_tier_frontmatter.py} +0 -0
- /package/scripts/{_p43_bodies.py → _archive/_p43_bodies.py} +0 -0
- /package/scripts/{_p43_compress.py → _archive/_p43_compress.py} +0 -0
- /package/scripts/{_p4_migrate.py → _archive/_p4_migrate.py} +0 -0
- /package/scripts/{_phase2_shim_helper.py → _archive/_phase2_shim_helper.py} +0 -0
- /package/scripts/{_pilot_council_question.py → _archive/_pilot_council_question.py} +0 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
"""Lightweight-QA fast-path resolver (Phase 11).
|
|
2
|
+
|
|
3
|
+
When ``decision_resolution.classes.low_impact.mode = council`` fires,
|
|
4
|
+
this module narrows the standard council fan-out to the opted-in
|
|
5
|
+
members, caps the spend at the ``decision_resolution.fast_path``
|
|
6
|
+
budget, and stamps a transparency marker on the result so the host
|
|
7
|
+
agent can surface that the answer came from the lightweight path.
|
|
8
|
+
|
|
9
|
+
The fast-path is a strict subset of the standard ``consult()`` flow:
|
|
10
|
+
|
|
11
|
+
- members filtered to ``participate_low_impact = True`` (and ``enabled``);
|
|
12
|
+
- list truncated to ``LowImpactFastPathConfig.max_members`` (1 or 2);
|
|
13
|
+
- ``CostBudget.max_calls = max_members``;
|
|
14
|
+
- ``CostBudget.max_total_usd = max_cost_usd``;
|
|
15
|
+
- token caps tightened to ``max_tokens`` (split 60 / 40 in / out);
|
|
16
|
+
- ``rounds`` locked to 1 — multi-round debate defeats the purpose.
|
|
17
|
+
|
|
18
|
+
Iron Law (Phase 10) is unaffected: ``high_impact`` and ``user_required``
|
|
19
|
+
never reach this module — they route to ``user`` at the config layer.
|
|
20
|
+
This module is only consulted for the ``low_impact`` class.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from typing import Callable, Literal
|
|
29
|
+
|
|
30
|
+
from scripts.ai_council.clients import CouncilResponse, ExternalAIClient
|
|
31
|
+
from scripts.ai_council.config import (
|
|
32
|
+
LowImpactFastPathConfig,
|
|
33
|
+
MemberConfig,
|
|
34
|
+
)
|
|
35
|
+
from scripts.ai_council.orchestrator import CostBudget
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: Token split ratio between input prompt and output budget when the
|
|
39
|
+
#: fast-path caps the total. 60 / 40 mirrors the empirical mix observed
|
|
40
|
+
#: for short Q&A — Q is long-ish, A is terse. Tunable; kept private so
|
|
41
|
+
#: the contract surface stays at ``max_tokens``.
|
|
42
|
+
_INPUT_RATIO = 0.6
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class FastPathPlan:
|
|
47
|
+
"""Resolved fast-path execution plan (Phase 11).
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
members: Ordered list of member configs to invoke. Empty when
|
|
51
|
+
no member opted in — the caller must fall back to the
|
|
52
|
+
standard council path or escalate.
|
|
53
|
+
budget: ``CostBudget`` pre-sized to the fast-path caps. Safe
|
|
54
|
+
to pass directly to :func:`orchestrator.consult`.
|
|
55
|
+
marker: One-line transparency string for the rendered output
|
|
56
|
+
(e.g. ``"[fast-path: 2 members · cap $0.05]"``). Surface
|
|
57
|
+
it to the user so fast-path resolutions are distinguishable
|
|
58
|
+
from standard council runs.
|
|
59
|
+
reason: Diagnostic string explaining the plan shape — used by
|
|
60
|
+
the CLI and tests. Empty when ``members`` is non-empty.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
members: tuple[MemberConfig, ...]
|
|
64
|
+
budget: CostBudget
|
|
65
|
+
marker: str
|
|
66
|
+
reason: str = ""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_resolvable(self) -> bool:
|
|
70
|
+
"""True when at least one opted-in member is available."""
|
|
71
|
+
return bool(self.members)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def select_fast_path_members(
|
|
75
|
+
members: dict[str, MemberConfig],
|
|
76
|
+
cfg: LowImpactFastPathConfig,
|
|
77
|
+
) -> tuple[MemberConfig, ...]:
|
|
78
|
+
"""Filter and order opted-in members for the fast-path.
|
|
79
|
+
|
|
80
|
+
Selection rules:
|
|
81
|
+
|
|
82
|
+
- member must be ``enabled``;
|
|
83
|
+
- member must have ``participate_low_impact = True``;
|
|
84
|
+
- alphabetical by provider name → deterministic, easy to test,
|
|
85
|
+
no hidden cost-rank heuristic to debug;
|
|
86
|
+
- truncate to ``cfg.max_members`` (1 or 2 per schema).
|
|
87
|
+
|
|
88
|
+
No price-table lookup here — the standard council path already
|
|
89
|
+
runs the full cost-disclosure flow and the per-call cap in
|
|
90
|
+
``CostBudget`` is the structural backstop.
|
|
91
|
+
"""
|
|
92
|
+
candidates = [
|
|
93
|
+
m for m in members.values()
|
|
94
|
+
if m.enabled and m.participate_low_impact
|
|
95
|
+
]
|
|
96
|
+
candidates.sort(key=lambda m: m.name)
|
|
97
|
+
return tuple(candidates[: cfg.max_members])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_fast_path_budget(cfg: LowImpactFastPathConfig) -> CostBudget:
|
|
101
|
+
"""Translate the ``fast_path`` config into a runnable ``CostBudget``.
|
|
102
|
+
|
|
103
|
+
The 60 / 40 input / output split is a heuristic — callers that
|
|
104
|
+
need an exact ceiling can override the returned ``CostBudget``
|
|
105
|
+
fields. ``max_calls`` matches ``max_members`` so the orchestrator
|
|
106
|
+
short-circuits as soon as the fast-path quota is exhausted.
|
|
107
|
+
"""
|
|
108
|
+
max_in = max(1, int(cfg.max_tokens * _INPUT_RATIO))
|
|
109
|
+
max_out = max(1, cfg.max_tokens - max_in)
|
|
110
|
+
return CostBudget(
|
|
111
|
+
max_input_tokens=max_in,
|
|
112
|
+
max_output_tokens=max_out,
|
|
113
|
+
max_calls=cfg.max_members,
|
|
114
|
+
max_total_usd=cfg.max_cost_usd,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def plan_fast_path(
|
|
119
|
+
members: dict[str, MemberConfig],
|
|
120
|
+
cfg: LowImpactFastPathConfig,
|
|
121
|
+
) -> FastPathPlan:
|
|
122
|
+
"""Build the full execution plan for a ``low_impact`` resolution.
|
|
123
|
+
|
|
124
|
+
Returns a :class:`FastPathPlan`. When no member opted in, the
|
|
125
|
+
plan's ``members`` tuple is empty and ``reason`` explains why —
|
|
126
|
+
the caller must fall back (standard council) or escalate (user).
|
|
127
|
+
"""
|
|
128
|
+
selected = select_fast_path_members(members, cfg)
|
|
129
|
+
if not selected:
|
|
130
|
+
return FastPathPlan(
|
|
131
|
+
members=(),
|
|
132
|
+
budget=build_fast_path_budget(cfg),
|
|
133
|
+
marker="",
|
|
134
|
+
reason=(
|
|
135
|
+
"no member has `participate_low_impact: true` — "
|
|
136
|
+
"fast-path unavailable, fall back to standard council "
|
|
137
|
+
"or escalate to user."
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
names = ", ".join(m.name for m in selected)
|
|
141
|
+
marker = (
|
|
142
|
+
f"[fast-path: {len(selected)} member"
|
|
143
|
+
f"{'s' if len(selected) > 1 else ''} ({names}) · "
|
|
144
|
+
f"cap ${cfg.max_cost_usd:.2f} · {cfg.max_tokens} tokens]"
|
|
145
|
+
)
|
|
146
|
+
return FastPathPlan(
|
|
147
|
+
members=selected,
|
|
148
|
+
budget=build_fast_path_budget(cfg),
|
|
149
|
+
marker=marker,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- Phase 11 Step 2-3: fast-path executor + transparency marker ----------
|
|
154
|
+
|
|
155
|
+
#: Status of a :class:`FastPathResolution`. ``resolved`` = one or
|
|
156
|
+
#: matching answers, returned to caller; ``split`` = members disagreed,
|
|
157
|
+
#: caller must escalate to user with both opinions; ``aborted`` =
|
|
158
|
+
#: hard cap hit or all members failed, caller must escalate;
|
|
159
|
+
#: ``unavailable`` = plan had no opted-in members (caller never even
|
|
160
|
+
#: called the executor — included so the status enum is exhaustive).
|
|
161
|
+
FastPathStatus = Literal["resolved", "split", "aborted", "unavailable"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
#: System prompt for fast-path members. Deliberately terse — the
|
|
165
|
+
#: standard advisor + Karpathy peer-review machinery is bypassed by
|
|
166
|
+
#: design (Phase 11 contract). One sentence of rationale is asked
|
|
167
|
+
#: explicitly so the user-visible marker can surface a "why" without
|
|
168
|
+
#: a second round.
|
|
169
|
+
_FAST_PATH_SYSTEM = (
|
|
170
|
+
"You are a fast-path council member answering a low-impact "
|
|
171
|
+
"development question. Reply with: (1) a short, direct answer; "
|
|
172
|
+
"(2) one sentence of rationale. No preamble, no caveats, no "
|
|
173
|
+
"alternative options — just answer + rationale."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class MemberAnswer:
|
|
179
|
+
"""One fast-path member's normalised answer.
|
|
180
|
+
|
|
181
|
+
Attributes:
|
|
182
|
+
member: Member name (e.g. ``"anthropic"``).
|
|
183
|
+
text: Raw response text. ``""`` when the call errored.
|
|
184
|
+
normalized: Lowercase + punctuation-stripped form used for
|
|
185
|
+
agreement detection. ``""`` mirrors ``text``.
|
|
186
|
+
cost_usd: Estimated spend in USD for this single call. ``0.0``
|
|
187
|
+
for non-billable transports (manual / vendor-CLI).
|
|
188
|
+
error: Provider-side error string, ``None`` on success.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
member: str
|
|
192
|
+
text: str
|
|
193
|
+
normalized: str
|
|
194
|
+
cost_usd: float = 0.0
|
|
195
|
+
error: str | None = None
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def ok(self) -> bool:
|
|
199
|
+
return self.error is None and bool(self.text.strip())
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class FastPathResolution:
|
|
204
|
+
"""End-to-end outcome of a low-impact fast-path resolution.
|
|
205
|
+
|
|
206
|
+
Attributes:
|
|
207
|
+
status: One of :data:`FastPathStatus`.
|
|
208
|
+
answer: Final user-visible answer text. Empty when ``status``
|
|
209
|
+
is ``split``, ``aborted``, or ``unavailable``.
|
|
210
|
+
marker: Transparency marker line — either the plan marker
|
|
211
|
+
(resolved) or a status-specific escalation marker.
|
|
212
|
+
answers: Per-member normalised answers, in call order.
|
|
213
|
+
total_cost_usd: Sum of per-call costs.
|
|
214
|
+
session_log_line: One-line append for the session artefact
|
|
215
|
+
under ``low-impact-resolutions.md``. Empty when status
|
|
216
|
+
is ``unavailable`` (no call happened).
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
status: FastPathStatus
|
|
220
|
+
answer: str
|
|
221
|
+
marker: str
|
|
222
|
+
answers: tuple[MemberAnswer, ...] = ()
|
|
223
|
+
total_cost_usd: float = 0.0
|
|
224
|
+
session_log_line: str = ""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
_PUNCT_RE = re.compile(r"[^\w\s]+")
|
|
228
|
+
_WS_RE = re.compile(r"\s+")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _normalize(text: str) -> str:
|
|
232
|
+
"""Lowercase, strip punctuation, collapse whitespace.
|
|
233
|
+
|
|
234
|
+
Used to detect agreement between two fast-path answers without
|
|
235
|
+
fuzzy / embedding match — keeps the agreement test auditable.
|
|
236
|
+
"""
|
|
237
|
+
lowered = (text or "").lower()
|
|
238
|
+
stripped = _PUNCT_RE.sub(" ", lowered)
|
|
239
|
+
return _WS_RE.sub(" ", stripped).strip()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _build_user_prompt(question_text: str) -> str:
|
|
243
|
+
return (
|
|
244
|
+
f"Question: {question_text.strip()}\n\n"
|
|
245
|
+
"Reply with: answer on line 1, one sentence rationale on line 2."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _aborted_marker(reason: str, members: tuple[MemberConfig, ...]) -> str:
|
|
250
|
+
"""Build the normative ``aborted`` marker.
|
|
251
|
+
|
|
252
|
+
Wording is fixed by ``fast-path-marker-visibility.md`` Iron Law:
|
|
253
|
+
``> Low-impact council aborted (<reason>) — escalating to user:``.
|
|
254
|
+
The members-tried list trails on the same prefix so downstream
|
|
255
|
+
pattern matchers can still extract who was called.
|
|
256
|
+
"""
|
|
257
|
+
names = ", ".join(m.name for m in members) if members else "no members"
|
|
258
|
+
return (
|
|
259
|
+
f"> Low-impact council aborted ({reason}) — escalating to user: "
|
|
260
|
+
f"members tried: {names}."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _split_marker(answers: tuple[MemberAnswer, ...]) -> str:
|
|
265
|
+
"""Build the normative ``split`` marker.
|
|
266
|
+
|
|
267
|
+
Wording is fixed by ``fast-path-marker-visibility.md`` Iron Law:
|
|
268
|
+
``> Low-impact council split — escalating to user (<m1>: X / <m2>: Y):``.
|
|
269
|
+
"""
|
|
270
|
+
parts = " / ".join(
|
|
271
|
+
f"{a.member}: {a.text.splitlines()[0].strip()[:80]}"
|
|
272
|
+
for a in answers if a.ok
|
|
273
|
+
)
|
|
274
|
+
return f"> Low-impact council split — escalating to user ({parts}):"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _unavailable_marker() -> str:
|
|
278
|
+
"""Build the normative ``unavailable`` marker.
|
|
279
|
+
|
|
280
|
+
Wording is fixed by ``fast-path-marker-visibility.md`` Iron Law:
|
|
281
|
+
``> Low-impact council unavailable (no opted-in members) — escalating to user.``.
|
|
282
|
+
"""
|
|
283
|
+
return (
|
|
284
|
+
"> Low-impact council unavailable (no opted-in members) — "
|
|
285
|
+
"escalating to user."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _resolved_marker(ok_answers: tuple[MemberAnswer, ...]) -> str:
|
|
290
|
+
"""Build the normative ``resolved`` marker.
|
|
291
|
+
|
|
292
|
+
Wording is fixed by ``fast-path-marker-visibility.md`` Iron Law:
|
|
293
|
+
``> Resolved via low-impact council fast-path: <verdict>.``. The
|
|
294
|
+
verdict short-string distinguishes single-member from 2-member
|
|
295
|
+
consensus so the host agent can preserve provenance without
|
|
296
|
+
paraphrasing the answer body.
|
|
297
|
+
"""
|
|
298
|
+
if len(ok_answers) <= 1:
|
|
299
|
+
verdict = "single-member answer"
|
|
300
|
+
else:
|
|
301
|
+
verdict = f"{len(ok_answers)}-member consensus"
|
|
302
|
+
return f"> Resolved via low-impact council fast-path: {verdict}."
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _answer_line(text: str) -> str:
|
|
306
|
+
"""Extract the answer portion (line 1) from a fast-path response."""
|
|
307
|
+
for line in text.splitlines():
|
|
308
|
+
stripped = line.strip()
|
|
309
|
+
if stripped:
|
|
310
|
+
return stripped
|
|
311
|
+
return ""
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _build_member_answer(
|
|
315
|
+
member: str,
|
|
316
|
+
response: CouncilResponse,
|
|
317
|
+
price_table: "object | None",
|
|
318
|
+
) -> MemberAnswer:
|
|
319
|
+
"""Normalise one provider call into a :class:`MemberAnswer`."""
|
|
320
|
+
if response.error:
|
|
321
|
+
return MemberAnswer(
|
|
322
|
+
member=member, text="", normalized="",
|
|
323
|
+
cost_usd=0.0, error=response.error,
|
|
324
|
+
)
|
|
325
|
+
text = (response.text or "").strip()
|
|
326
|
+
if not text:
|
|
327
|
+
return MemberAnswer(
|
|
328
|
+
member=member, text="", normalized="",
|
|
329
|
+
cost_usd=0.0, error="empty response",
|
|
330
|
+
)
|
|
331
|
+
cost = _compute_cost(response, price_table)
|
|
332
|
+
return MemberAnswer(
|
|
333
|
+
member=member,
|
|
334
|
+
text=text,
|
|
335
|
+
normalized=_normalize(_answer_line(text)),
|
|
336
|
+
cost_usd=cost,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _compute_cost(
|
|
341
|
+
response: CouncilResponse,
|
|
342
|
+
price_table: "object | None",
|
|
343
|
+
) -> float:
|
|
344
|
+
"""Estimate USD cost for one response.
|
|
345
|
+
|
|
346
|
+
Uses the injected ``price_table`` when available; falls back to
|
|
347
|
+
``0.0`` for non-billable transports (manual / vendor-CLI) and for
|
|
348
|
+
unknown models. Never raises — cost is an observability signal,
|
|
349
|
+
not a gate (the budget check is structural).
|
|
350
|
+
"""
|
|
351
|
+
if price_table is None:
|
|
352
|
+
return 0.0
|
|
353
|
+
lookup = getattr(price_table, "lookup", None)
|
|
354
|
+
if lookup is None:
|
|
355
|
+
return 0.0
|
|
356
|
+
price = lookup(response.provider, response.model)
|
|
357
|
+
if price is None:
|
|
358
|
+
return 0.0
|
|
359
|
+
in_usd = (response.input_tokens / 1_000_000) * price.input_per_1m_usd
|
|
360
|
+
out_usd = (response.output_tokens / 1_000_000) * price.output_per_1m_usd
|
|
361
|
+
return in_usd + out_usd
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _session_log_line(
|
|
365
|
+
question_text: str,
|
|
366
|
+
status: FastPathStatus,
|
|
367
|
+
answers: tuple[MemberAnswer, ...],
|
|
368
|
+
total_cost: float,
|
|
369
|
+
now: "datetime | None" = None,
|
|
370
|
+
) -> str:
|
|
371
|
+
"""Build a one-line append for the session artefact.
|
|
372
|
+
|
|
373
|
+
Format: ``ISO8601 | status | members(ok/total) | $cost | Q…``
|
|
374
|
+
Question is truncated to 120 chars so the log stays scannable.
|
|
375
|
+
"""
|
|
376
|
+
ts = (now or datetime.now(timezone.utc)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
377
|
+
ok = sum(1 for a in answers if a.ok)
|
|
378
|
+
total = len(answers)
|
|
379
|
+
q = question_text.strip().replace("\n", " ")
|
|
380
|
+
if len(q) > 120:
|
|
381
|
+
q = q[:117] + "..."
|
|
382
|
+
names = ", ".join(a.member for a in answers)
|
|
383
|
+
members_tag = f" members({names})" if names else ""
|
|
384
|
+
return (
|
|
385
|
+
f"{ts} | {status} | members={ok}/{total} |{members_tag} "
|
|
386
|
+
f"cost=${total_cost:.4f} | Q={q}"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def resolve_low_impact(
|
|
391
|
+
question_text: str,
|
|
392
|
+
plan: FastPathPlan,
|
|
393
|
+
clients: dict[str, ExternalAIClient],
|
|
394
|
+
price_table: "object | None" = None,
|
|
395
|
+
now: "Callable[[], datetime] | None" = None,
|
|
396
|
+
) -> FastPathResolution:
|
|
397
|
+
"""Execute the fast-path plan and return a :class:`FastPathResolution`.
|
|
398
|
+
|
|
399
|
+
Contract:
|
|
400
|
+
|
|
401
|
+
- One round only — each opted-in member is called exactly once.
|
|
402
|
+
- Per-call ``max_tokens`` is taken from ``plan.budget.max_output_tokens``.
|
|
403
|
+
- The USD cap (``plan.budget.max_total_usd``) is a hard stop — when
|
|
404
|
+
the running total would exceed it after a call, the executor
|
|
405
|
+
aborts and escalates to the user (never silently truncates).
|
|
406
|
+
- Provider failures never block — a failed member is recorded and
|
|
407
|
+
the executor continues with the remaining member (if any).
|
|
408
|
+
- Consensus rule (2 members): normalised answer-line equality. No
|
|
409
|
+
embedding match, no LLM-judge — keeps the agreement test
|
|
410
|
+
auditable. Disagreement → ``status = "split"``, caller escalates.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
question_text: The low-impact question being routed.
|
|
414
|
+
plan: Output of :func:`plan_fast_path`.
|
|
415
|
+
clients: Provider name → instantiated client. Missing entries
|
|
416
|
+
are treated as a member-side failure (error recorded,
|
|
417
|
+
other members proceed).
|
|
418
|
+
price_table: Optional pricing table for cost estimation. When
|
|
419
|
+
``None``, ``cost_usd`` fields stay at ``0.0`` (the structural
|
|
420
|
+
budget check still fires on token counts).
|
|
421
|
+
now: Optional clock injector for deterministic tests.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
:class:`FastPathResolution` with status, answer, marker, and
|
|
425
|
+
session log line. Caller renders the marker, surfaces the
|
|
426
|
+
answer (if any), and appends ``session_log_line`` to the
|
|
427
|
+
session artefact.
|
|
428
|
+
"""
|
|
429
|
+
if not plan.is_resolvable:
|
|
430
|
+
return FastPathResolution(
|
|
431
|
+
status="unavailable",
|
|
432
|
+
answer="",
|
|
433
|
+
marker=_unavailable_marker(),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
user_prompt = _build_user_prompt(question_text)
|
|
437
|
+
answers: list[MemberAnswer] = []
|
|
438
|
+
total_cost = 0.0
|
|
439
|
+
|
|
440
|
+
for member in plan.members:
|
|
441
|
+
client = clients.get(member.name)
|
|
442
|
+
if client is None:
|
|
443
|
+
answers.append(MemberAnswer(
|
|
444
|
+
member=member.name, text="", normalized="",
|
|
445
|
+
cost_usd=0.0, error="no client instantiated",
|
|
446
|
+
))
|
|
447
|
+
continue
|
|
448
|
+
try:
|
|
449
|
+
response = client.ask(
|
|
450
|
+
_FAST_PATH_SYSTEM,
|
|
451
|
+
user_prompt,
|
|
452
|
+
max_tokens=plan.budget.max_output_tokens,
|
|
453
|
+
)
|
|
454
|
+
except Exception as exc: # noqa: BLE001 — surface as member error
|
|
455
|
+
answers.append(MemberAnswer(
|
|
456
|
+
member=member.name, text="", normalized="",
|
|
457
|
+
cost_usd=0.0, error=f"client raised: {exc!r}",
|
|
458
|
+
))
|
|
459
|
+
continue
|
|
460
|
+
ans = _build_member_answer(member.name, response, price_table)
|
|
461
|
+
# Hard cap — refuse to add an over-budget answer to the result.
|
|
462
|
+
projected = total_cost + ans.cost_usd
|
|
463
|
+
if projected > plan.budget.max_total_usd and ans.ok:
|
|
464
|
+
answers.append(MemberAnswer(
|
|
465
|
+
member=member.name, text="", normalized="",
|
|
466
|
+
cost_usd=ans.cost_usd,
|
|
467
|
+
error=(
|
|
468
|
+
f"would exceed fast-path cap "
|
|
469
|
+
f"${plan.budget.max_total_usd:.2f} "
|
|
470
|
+
f"(projected ${projected:.4f})"
|
|
471
|
+
),
|
|
472
|
+
))
|
|
473
|
+
break
|
|
474
|
+
answers.append(ans)
|
|
475
|
+
total_cost = projected
|
|
476
|
+
|
|
477
|
+
answers_t = tuple(answers)
|
|
478
|
+
ok_answers = tuple(a for a in answers_t if a.ok)
|
|
479
|
+
|
|
480
|
+
if not ok_answers:
|
|
481
|
+
marker = _aborted_marker("all members failed", plan.members)
|
|
482
|
+
return FastPathResolution(
|
|
483
|
+
status="aborted", answer="", marker=marker,
|
|
484
|
+
answers=answers_t, total_cost_usd=total_cost,
|
|
485
|
+
session_log_line=_session_log_line(
|
|
486
|
+
question_text, "aborted", answers_t, total_cost,
|
|
487
|
+
now=now() if now else None,
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if len(ok_answers) == 1:
|
|
492
|
+
return FastPathResolution(
|
|
493
|
+
status="resolved",
|
|
494
|
+
answer=ok_answers[0].text,
|
|
495
|
+
marker=_resolved_marker(ok_answers),
|
|
496
|
+
answers=answers_t,
|
|
497
|
+
total_cost_usd=total_cost,
|
|
498
|
+
session_log_line=_session_log_line(
|
|
499
|
+
question_text, "resolved", answers_t, total_cost,
|
|
500
|
+
now=now() if now else None,
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# Two members → quick consensus on normalised answer line.
|
|
505
|
+
if ok_answers[0].normalized == ok_answers[1].normalized:
|
|
506
|
+
return FastPathResolution(
|
|
507
|
+
status="resolved",
|
|
508
|
+
answer=ok_answers[0].text,
|
|
509
|
+
marker=_resolved_marker(ok_answers),
|
|
510
|
+
answers=answers_t,
|
|
511
|
+
total_cost_usd=total_cost,
|
|
512
|
+
session_log_line=_session_log_line(
|
|
513
|
+
question_text, "resolved", answers_t, total_cost,
|
|
514
|
+
now=now() if now else None,
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
return FastPathResolution(
|
|
519
|
+
status="split",
|
|
520
|
+
answer="",
|
|
521
|
+
marker=_split_marker(ok_answers),
|
|
522
|
+
answers=answers_t,
|
|
523
|
+
total_cost_usd=total_cost,
|
|
524
|
+
session_log_line=_session_log_line(
|
|
525
|
+
question_text, "split", answers_t, total_cost,
|
|
526
|
+
now=now() if now else None,
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# --- Phase 11 Step 5: low-impact stats over session log -------------------
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@dataclass(frozen=True)
|
|
535
|
+
class LowImpactStats:
|
|
536
|
+
"""Aggregate summary of one session's low-impact resolutions.
|
|
537
|
+
|
|
538
|
+
Attributes:
|
|
539
|
+
total: Total number of fast-path attempts in the session.
|
|
540
|
+
by_status: Count per status (``resolved``/``split``/``aborted``).
|
|
541
|
+
by_member: Count per member name (sum across all attempts;
|
|
542
|
+
a 2-member call increments both entries).
|
|
543
|
+
total_cost_usd: Sum of per-attempt cost across the session.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
total: int
|
|
547
|
+
by_status: dict[str, int]
|
|
548
|
+
by_member: dict[str, int]
|
|
549
|
+
total_cost_usd: float
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
_LOG_LINE_RE = re.compile(
|
|
553
|
+
r"^(?P<ts>\S+)\s*\|\s*(?P<status>\w+)\s*\|\s*members=(?P<ok>\d+)/"
|
|
554
|
+
r"(?P<tot>\d+)\s*\|.*?cost=\$(?P<cost>[\d.]+)\s*\|\s*Q=",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def parse_low_impact_log(text: str) -> LowImpactStats:
|
|
559
|
+
"""Parse a ``low-impact-resolutions.md`` body into stats.
|
|
560
|
+
|
|
561
|
+
Lines that do not match the canonical ``_session_log_line`` shape
|
|
562
|
+
are skipped silently — keeps the parser tolerant of free-form
|
|
563
|
+
section headers the artefact may grow over time. Returns a
|
|
564
|
+
:class:`LowImpactStats` with the aggregated counts.
|
|
565
|
+
"""
|
|
566
|
+
by_status: dict[str, int] = {}
|
|
567
|
+
by_member: dict[str, int] = {}
|
|
568
|
+
total = 0
|
|
569
|
+
total_cost = 0.0
|
|
570
|
+
member_section_re = re.compile(r"members\((?P<names>[^)]+)\)")
|
|
571
|
+
for raw in text.splitlines():
|
|
572
|
+
m = _LOG_LINE_RE.match(raw.strip())
|
|
573
|
+
if not m:
|
|
574
|
+
continue
|
|
575
|
+
total += 1
|
|
576
|
+
status = m.group("status")
|
|
577
|
+
by_status[status] = by_status.get(status, 0) + 1
|
|
578
|
+
try:
|
|
579
|
+
total_cost += float(m.group("cost"))
|
|
580
|
+
except ValueError:
|
|
581
|
+
pass
|
|
582
|
+
# Optional ``members(name, name)`` tag emitted by the renderer.
|
|
583
|
+
names_m = member_section_re.search(raw)
|
|
584
|
+
if names_m:
|
|
585
|
+
for name in names_m.group("names").split(","):
|
|
586
|
+
name = name.strip()
|
|
587
|
+
if name:
|
|
588
|
+
by_member[name] = by_member.get(name, 0) + 1
|
|
589
|
+
return LowImpactStats(
|
|
590
|
+
total=total,
|
|
591
|
+
by_status=dict(sorted(by_status.items())),
|
|
592
|
+
by_member=dict(sorted(by_member.items())),
|
|
593
|
+
total_cost_usd=round(total_cost, 4),
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def render_low_impact_stats(stats: LowImpactStats) -> str:
|
|
598
|
+
"""Render :class:`LowImpactStats` as a short stdout summary block."""
|
|
599
|
+
lines = ["# Low-impact fast-path · session summary", ""]
|
|
600
|
+
lines.append(f"- attempts: {stats.total}")
|
|
601
|
+
if stats.by_status:
|
|
602
|
+
parts = " · ".join(
|
|
603
|
+
f"{k}={v}" for k, v in stats.by_status.items()
|
|
604
|
+
)
|
|
605
|
+
lines.append(f"- status: {parts}")
|
|
606
|
+
else:
|
|
607
|
+
lines.append("- status: (none)")
|
|
608
|
+
if stats.by_member:
|
|
609
|
+
parts = " · ".join(
|
|
610
|
+
f"{k}={v}" for k, v in stats.by_member.items()
|
|
611
|
+
)
|
|
612
|
+
lines.append(f"- members: {parts}")
|
|
613
|
+
lines.append(f"- total cost: ${stats.total_cost_usd:.4f}")
|
|
614
|
+
return "\n".join(lines) + "\n"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# --- step-9 P5: fuzzy corpus match with safety vetoes -------------------
|
|
618
|
+
|
|
619
|
+
def classify_impact_with_corpus_fuzzy(
|
|
620
|
+
question_text: str,
|
|
621
|
+
corpus_paths: "tuple[object, ...] | None" = None,
|
|
622
|
+
*,
|
|
623
|
+
threshold: float = 0.92,
|
|
624
|
+
):
|
|
625
|
+
"""Fuzzy variant of :func:`necessity.classify_impact_with_corpus`.
|
|
626
|
+
|
|
627
|
+
Uses :class:`difflib.SequenceMatcher` to match near-paraphrases of
|
|
628
|
+
``Validated`` corpus entries while preserving the Iron Law:
|
|
629
|
+
|
|
630
|
+
- **Iron Law (precedence)**: the base verdict from
|
|
631
|
+
:func:`classify_impact` runs first. If the base class is in
|
|
632
|
+
``LOCKED_IMPACT_CLASSES`` (``high_impact`` / ``user_required``),
|
|
633
|
+
the fuzzy lookup is skipped entirely.
|
|
634
|
+
- **High-impact-veto**: any whole-word token from
|
|
635
|
+
:data:`IMPACT_TRIGGERS["high_impact"]` in the (lowered) query
|
|
636
|
+
short-circuits to the base verdict regardless of similarity.
|
|
637
|
+
Catches paraphrases that escaped the trigger-bucket vote.
|
|
638
|
+
- **Anti-example-veto**: if the maximum similarity to any
|
|
639
|
+
``Anti-Examples`` phrase is ``>=`` the maximum similarity to any
|
|
640
|
+
``Validated`` phrase, the fuzzy match is rejected. Prevents
|
|
641
|
+
ratio-driven drift onto bullets the corpus has explicitly
|
|
642
|
+
flagged as user-required.
|
|
643
|
+
|
|
644
|
+
Returns the base verdict on every reject path so the caller gets
|
|
645
|
+
consistent semantics with the exact-match classifier.
|
|
646
|
+
"""
|
|
647
|
+
import difflib
|
|
648
|
+
import re as _re
|
|
649
|
+
|
|
650
|
+
from scripts.ai_council.low_impact_corpus import (
|
|
651
|
+
load_anti_example_phrases,
|
|
652
|
+
load_validated_phrases,
|
|
653
|
+
)
|
|
654
|
+
from scripts.ai_council.necessity import (
|
|
655
|
+
IMPACT_TRIGGERS,
|
|
656
|
+
ImpactVerdict,
|
|
657
|
+
LOCKED_IMPACT_CLASSES,
|
|
658
|
+
classify_impact,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
base = classify_impact(question_text)
|
|
662
|
+
if base.impact_class in LOCKED_IMPACT_CLASSES:
|
|
663
|
+
return base
|
|
664
|
+
if not corpus_paths or not (0.0 < threshold <= 1.0):
|
|
665
|
+
return base
|
|
666
|
+
|
|
667
|
+
norm_q = _re.sub(r"[^\w\s]", " ", (question_text or "").lower())
|
|
668
|
+
norm_q = _re.sub(r"\s+", " ", norm_q).strip()
|
|
669
|
+
if not norm_q:
|
|
670
|
+
return base
|
|
671
|
+
|
|
672
|
+
# High-impact-veto: a paraphrase carrying a security-class trigger
|
|
673
|
+
# wins the Iron Law regardless of corpus similarity. Whole-word
|
|
674
|
+
# match against the lowered query, mirroring `_count_matches`.
|
|
675
|
+
high_triggers = IMPACT_TRIGGERS.get("high_impact", ())
|
|
676
|
+
lowered_q = (question_text or "").lower()
|
|
677
|
+
for trig in high_triggers:
|
|
678
|
+
pattern = r"\b" + _re.escape(trig.lower()) + r"\b"
|
|
679
|
+
if _re.search(pattern, lowered_q):
|
|
680
|
+
return base
|
|
681
|
+
|
|
682
|
+
validated: list[str] = []
|
|
683
|
+
anti: list[str] = []
|
|
684
|
+
for path in corpus_paths:
|
|
685
|
+
validated.extend(load_validated_phrases(path))
|
|
686
|
+
anti.extend(load_anti_example_phrases(path))
|
|
687
|
+
|
|
688
|
+
if not validated:
|
|
689
|
+
return base
|
|
690
|
+
|
|
691
|
+
def _ratio(a: str, b: str) -> float:
|
|
692
|
+
return difflib.SequenceMatcher(a=a, b=b).ratio()
|
|
693
|
+
|
|
694
|
+
best_validated = max((_ratio(norm_q, p) for p in validated), default=0.0)
|
|
695
|
+
if best_validated < threshold:
|
|
696
|
+
return base
|
|
697
|
+
|
|
698
|
+
best_anti = max((_ratio(norm_q, p) for p in anti), default=0.0)
|
|
699
|
+
# Anti-example-veto: if the query is at least as close to an
|
|
700
|
+
# anti-example as to a validated phrase, the corpus has actively
|
|
701
|
+
# flagged this shape — don't shortcut.
|
|
702
|
+
if anti and best_anti >= best_validated:
|
|
703
|
+
return base
|
|
704
|
+
|
|
705
|
+
return ImpactVerdict(
|
|
706
|
+
impact_class="low_impact",
|
|
707
|
+
confidence=round(min(0.9, best_validated), 4),
|
|
708
|
+
rationale=(
|
|
709
|
+
f"Fuzzy match against Validated corpus "
|
|
710
|
+
f"(ratio={best_validated:.3f} ≥ {threshold:.2f}) — routing "
|
|
711
|
+
"as `low_impact` (step-9 P5)."
|
|
712
|
+
),
|
|
713
|
+
category="corpus_validated_fuzzy",
|
|
714
|
+
)
|