althing 1.7.0__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.
- althing/__init__.py +46 -0
- althing/__main__.py +9 -0
- althing/__version__.py +1 -0
- althing/_compat_env.py +27 -0
- althing/_runners.py +953 -0
- althing/agent_assets/__init__.py +3 -0
- althing/agent_assets/commands/althing-poll.md +75 -0
- althing/agent_assets/skills/concept-test/SKILL.md +72 -0
- althing/agent_assets/skills/focus-group/SKILL.md +77 -0
- althing/agent_assets/skills/name-test/SKILL.md +62 -0
- althing/agent_assets/skills/pricing-probe/SKILL.md +75 -0
- althing/agent_assets/skills/survey-prescreen/SKILL.md +76 -0
- althing/aggregation.py +240 -0
- althing/analysis/__init__.py +46 -0
- althing/analysis/distribution.py +323 -0
- althing/analysis/inspect.py +586 -0
- althing/analysis/subgroup.py +168 -0
- althing/analysis/subgroup_cli.py +598 -0
- althing/analyze.py +839 -0
- althing/attachments/__init__.py +77 -0
- althing/attachments/filter.py +67 -0
- althing/attachments/models.py +50 -0
- althing/attachments/pdf.py +537 -0
- althing/attachments/store.py +111 -0
- althing/calibration.py +239 -0
- althing/checkpoint.py +763 -0
- althing/cli/__init__.py +1 -0
- althing/cli/commands.py +8080 -0
- althing/cli/mcp_install.py +487 -0
- althing/cli/output.py +106 -0
- althing/cli/parser.py +1706 -0
- althing/cli/progress.py +93 -0
- althing/cli/repl.py +122 -0
- althing/cli/slash.py +197 -0
- althing/conditions.py +261 -0
- althing/convergence.py +1091 -0
- althing/cost.py +950 -0
- althing/cost_summary.py +452 -0
- althing/credentials.py +311 -0
- althing/data/__init__.py +8 -0
- althing/data/synthbench-snapshot.json +120 -0
- althing/diff.py +331 -0
- althing/domains.py +284 -0
- althing/ensemble.py +922 -0
- althing/fetch/__init__.py +38 -0
- althing/fetch/cache.py +360 -0
- althing/fetch/ladder.py +437 -0
- althing/fetch/lower.py +199 -0
- althing/fetch/perimeter.py +494 -0
- althing/instrument.py +751 -0
- althing/integrations/__init__.py +21 -0
- althing/integrations/composio.py +345 -0
- althing/llm/__init__.py +32 -0
- althing/llm/aliases.py +138 -0
- althing/llm/capabilities.py +86 -0
- althing/llm/client.py +267 -0
- althing/llm/errors.py +162 -0
- althing/llm/fast_default.py +71 -0
- althing/llm/models.py +340 -0
- althing/llm/providers/__init__.py +18 -0
- althing/llm/providers/_anthropic_format.py +281 -0
- althing/llm/providers/_openai_format.py +381 -0
- althing/llm/providers/anthropic.py +172 -0
- althing/llm/providers/base.py +60 -0
- althing/llm/providers/gemini.py +107 -0
- althing/llm/providers/openai_compat.py +102 -0
- althing/llm/providers/openrouter.py +357 -0
- althing/llm/providers/xai.py +98 -0
- althing/llm/retry.py +113 -0
- althing/logging_config.py +80 -0
- althing/main.py +308 -0
- althing/mcp/__init__.py +7 -0
- althing/mcp/compat.py +110 -0
- althing/mcp/data.py +700 -0
- althing/mcp/sampling.py +380 -0
- althing/mcp/server.py +3371 -0
- althing/metadata.py +214 -0
- althing/metadata_migrations.py +70 -0
- althing/orchestrator.py +2119 -0
- althing/pack_diff.py +271 -0
- althing/packs/__init__.py +3 -0
- althing/packs/ai-eval-buyers.yaml +247 -0
- althing/packs/broad-professionals.yaml +251 -0
- althing/packs/developer.yaml +271 -0
- althing/packs/enterprise-ai-buyers.yaml +245 -0
- althing/packs/enterprise-buyer.yaml +252 -0
- althing/packs/general-consumer.yaml +271 -0
- althing/packs/healthcare-patient.yaml +271 -0
- althing/packs/instruments/__init__.py +1 -0
- althing/packs/instruments/churn-diagnosis.yaml +73 -0
- althing/packs/instruments/feature-prioritization.yaml +75 -0
- althing/packs/instruments/general-survey.yaml +46 -0
- althing/packs/instruments/happiness-probe.yaml +41 -0
- althing/packs/instruments/landing-page-comprehension.yaml +75 -0
- althing/packs/instruments/market-research.yaml +50 -0
- althing/packs/instruments/name-test.yaml +74 -0
- althing/packs/instruments/pricing-discovery.yaml +80 -0
- althing/packs/instruments/product-feedback.yaml +46 -0
- althing/packs/job-seekers.yaml +188 -0
- althing/packs/market-research-critics.yaml +217 -0
- althing/packs/product-research.yaml +248 -0
- althing/packs/recruiters-talent.yaml +264 -0
- althing/packs/skeptical-executives.yaml +227 -0
- althing/packs/startup-founder.yaml +242 -0
- althing/packs/students.yaml +352 -0
- althing/persistence.py +510 -0
- althing/perturbation.py +333 -0
- althing/plugins/__init__.py +34 -0
- althing/plugins/hooks.py +159 -0
- althing/plugins/lint.py +352 -0
- althing/plugins/manager.py +176 -0
- althing/plugins/manifest.py +100 -0
- althing/plugins/registry.py +66 -0
- althing/poll_summary.py +1195 -0
- althing/preflight.py +155 -0
- althing/profiles.py +264 -0
- althing/prompts.py +311 -0
- althing/py.typed +0 -0
- althing/question_budget.py +140 -0
- althing/registry/__init__.py +178 -0
- althing/registry/cache.py +223 -0
- althing/registry/fetch.py +104 -0
- althing/registry/github.py +212 -0
- althing/reporting/__init__.py +17 -0
- althing/reporting/loader.py +69 -0
- althing/reporting/markdown.py +450 -0
- althing/response_coercion.py +161 -0
- althing/routing.py +132 -0
- althing/runtime.py +552 -0
- althing/schemas/__init__.py +53 -0
- althing/schemas/v1.0.0.json +161 -0
- althing/sdk.py +1458 -0
- althing/stats.py +1522 -0
- althing/structured/__init__.py +5 -0
- althing/structured/models.py +149 -0
- althing/structured/output.py +376 -0
- althing/structured/retry.py +170 -0
- althing/structured/schemas.py +127 -0
- althing/structured/validate.py +303 -0
- althing/structured/verdict.py +146 -0
- althing/synthbench.py +768 -0
- althing/synthbench_submit.py +582 -0
- althing/synthesis.py +1529 -0
- althing/synthesis_recovery.py +467 -0
- althing/templates.py +179 -0
- althing/text_width.py +126 -0
- althing-1.7.0.dist-info/METADATA +1473 -0
- althing-1.7.0.dist-info/RECORD +156 -0
- althing-1.7.0.dist-info/WHEEL +5 -0
- althing-1.7.0.dist-info/entry_points.txt +3 -0
- althing-1.7.0.dist-info/licenses/LICENSE +21 -0
- althing-1.7.0.dist-info/top_level.txt +3 -0
- synth_panel/__init__.py +60 -0
- synth_panel/__main__.py +17 -0
- synthpanel/__init__.py +60 -0
- synthpanel/__main__.py +17 -0
althing/_runners.py
ADDED
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
"""Shared synchronous panel runners.
|
|
2
|
+
|
|
3
|
+
Factored out of ``althing.mcp.server`` so that both the MCP server
|
|
4
|
+
handlers and the public Python SDK (``althing.sdk``) can drive the
|
|
5
|
+
same underlying logic without one depending on the other — the MCP
|
|
6
|
+
server requires the optional ``mcp`` extra, whereas the SDK has to
|
|
7
|
+
work in a plain ``pip install althing`` environment.
|
|
8
|
+
|
|
9
|
+
Nothing here imports the ``mcp`` library.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from althing.cost import ZERO_USAGE, CostGate, resolve_cost
|
|
21
|
+
from althing.cost import TokenUsage as CostTokenUsage
|
|
22
|
+
from althing.instrument import Instrument
|
|
23
|
+
from althing.llm.client import LLMClient
|
|
24
|
+
from althing.orchestrator import (
|
|
25
|
+
MultiRoundResult,
|
|
26
|
+
PanelistResult,
|
|
27
|
+
run_multi_round_panel,
|
|
28
|
+
run_panel_parallel,
|
|
29
|
+
)
|
|
30
|
+
from althing.perturbation import generate_panel_variants
|
|
31
|
+
from althing.prompts import build_question_prompt, persona_system_prompt
|
|
32
|
+
from althing.stats import robustness_score
|
|
33
|
+
from althing.structured.models import MODEL_REGISTRY
|
|
34
|
+
from althing.structured.schemas import _REGISTRY as _BUNDLED_SCHEMA_REGISTRY
|
|
35
|
+
from althing.synthesis import synthesize_panel
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# sp-efip: max number of per-persona error samples to surface in the
|
|
40
|
+
# diagnostic output when a run fails wholesale. Keeps CLI banners and
|
|
41
|
+
# MCP error envelopes bounded while still naming real errors.
|
|
42
|
+
_MAX_SAMPLE_ERRORS = 3
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PanelTotalFailureError(RuntimeError):
|
|
46
|
+
"""Every panelist failed — no usable Q/A data was produced.
|
|
47
|
+
|
|
48
|
+
Raised by ``run_panel_sync`` (and equivalents) when the orchestrator
|
|
49
|
+
returns results where every panelist either errored wholesale or
|
|
50
|
+
produced zero tokens with only errored responses. Callers should
|
|
51
|
+
surface the message verbatim so operators see the failing model(s)
|
|
52
|
+
and upstream provider error (e.g. "OpenRouter API error 400: …").
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, *, diagnostic: dict[str, Any]) -> None:
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.diagnostic = diagnostic
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _first_error_message(pr: Any) -> str | None:
|
|
61
|
+
"""Return a representative error string for *pr*, or None if clean."""
|
|
62
|
+
err = getattr(pr, "error", None)
|
|
63
|
+
if err:
|
|
64
|
+
return str(err)
|
|
65
|
+
for resp in getattr(pr, "responses", []) or []:
|
|
66
|
+
if isinstance(resp, dict) and resp.get("error"):
|
|
67
|
+
text = resp.get("response")
|
|
68
|
+
if isinstance(text, str) and text:
|
|
69
|
+
return text
|
|
70
|
+
return "unspecified per-question error"
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def detect_total_failure(panelist_results: list[Any]) -> dict[str, Any] | None:
|
|
75
|
+
"""Return a diagnostic dict when every panelist failed, else None.
|
|
76
|
+
|
|
77
|
+
Total failure means that *no* panelist produced a usable Q/A pair.
|
|
78
|
+
A panelist counts as failed when any of the following hold:
|
|
79
|
+
|
|
80
|
+
* ``pr.error`` is set (wholesale panelist exception), OR
|
|
81
|
+
* every response in ``pr.responses`` is flagged ``error: True``
|
|
82
|
+
(per-question exceptions caught inside ``_run_panelist``), OR
|
|
83
|
+
* the panelist produced zero tokens *and* recorded no primary
|
|
84
|
+
non-errored response (the 400-on-every-call scenario from
|
|
85
|
+
sp-efip, where tokens stayed at 0 because every request was
|
|
86
|
+
rejected upstream).
|
|
87
|
+
|
|
88
|
+
The returned dict carries the models exercised and a few sample
|
|
89
|
+
per-persona error strings so downstream banners / MCP errors can
|
|
90
|
+
name the upstream failure (e.g. the bad model name and HTTP 400
|
|
91
|
+
the bead explicitly calls out).
|
|
92
|
+
"""
|
|
93
|
+
if not panelist_results:
|
|
94
|
+
return {
|
|
95
|
+
"panelists": 0,
|
|
96
|
+
"models": [],
|
|
97
|
+
"sample_errors": [],
|
|
98
|
+
"summary": "orchestrator returned no panelist results",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
models: set[str] = set()
|
|
102
|
+
sample_errors: list[tuple[str, str]] = []
|
|
103
|
+
all_failed = True
|
|
104
|
+
|
|
105
|
+
for pr in panelist_results:
|
|
106
|
+
model = getattr(pr, "model", None)
|
|
107
|
+
if model:
|
|
108
|
+
models.add(str(model))
|
|
109
|
+
|
|
110
|
+
pr_error = getattr(pr, "error", None)
|
|
111
|
+
responses = getattr(pr, "responses", []) or []
|
|
112
|
+
primary = [r for r in responses if isinstance(r, dict) and not r.get("follow_up")]
|
|
113
|
+
has_clean_primary = any(isinstance(r, dict) and not r.get("error") and r.get("response") for r in primary)
|
|
114
|
+
|
|
115
|
+
# A panelist is considered usable iff it produced at least one
|
|
116
|
+
# non-errored primary response. The bead explicitly calls out
|
|
117
|
+
# the "tokens=0 AND error set" short-circuit, which is already
|
|
118
|
+
# subsumed by "no clean primary response" — zero-token panelists
|
|
119
|
+
# whose only responses are error=True rows all fall here.
|
|
120
|
+
panelist_failed = bool(pr_error) or not has_clean_primary
|
|
121
|
+
if not panelist_failed:
|
|
122
|
+
all_failed = False
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
err_msg = _first_error_message(pr)
|
|
126
|
+
if err_msg and len(sample_errors) < _MAX_SAMPLE_ERRORS:
|
|
127
|
+
persona = getattr(pr, "persona_name", "unknown")
|
|
128
|
+
sample_errors.append((str(persona), err_msg))
|
|
129
|
+
|
|
130
|
+
if not all_failed:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"panelists": len(panelist_results),
|
|
135
|
+
"models": sorted(models),
|
|
136
|
+
"sample_errors": sample_errors,
|
|
137
|
+
"summary": (f"{len(panelist_results)} panelist(s) produced no usable Q/A pairs"),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def format_total_failure_message(diagnostic: dict[str, Any]) -> str:
|
|
142
|
+
"""Render a single-line message naming failing models + sample error."""
|
|
143
|
+
models = diagnostic.get("models") or []
|
|
144
|
+
sample_errors = diagnostic.get("sample_errors") or []
|
|
145
|
+
parts = [f"Panel run produced no usable results: {diagnostic.get('summary', 'every panelist failed')}"]
|
|
146
|
+
if models:
|
|
147
|
+
parts.append(f"models: {', '.join(models)}")
|
|
148
|
+
if sample_errors:
|
|
149
|
+
persona, err = sample_errors[0]
|
|
150
|
+
parts.append(f"first error ({persona}): {err}")
|
|
151
|
+
return ". ".join(parts)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# sp-avmm: context-window table for synthesis pre-flight check.
|
|
155
|
+
#
|
|
156
|
+
# Keyed by the canonical model ID (after alias resolution) OR a prefix
|
|
157
|
+
# match. Values are window sizes in tokens. When a model does not match,
|
|
158
|
+
# the caller falls back to ``_DEFAULT_CONTEXT_WINDOW`` and emits a WARN
|
|
159
|
+
# so operators know the limit they're running against is a guess.
|
|
160
|
+
_CONTEXT_WINDOWS: dict[str, int] = {
|
|
161
|
+
# Anthropic
|
|
162
|
+
"claude-haiku-4-5": 200_000,
|
|
163
|
+
"claude-haiku-4": 200_000,
|
|
164
|
+
"claude-haiku-3-5": 200_000,
|
|
165
|
+
"claude-sonnet-5": 200_000,
|
|
166
|
+
"claude-sonnet-4-6": 200_000,
|
|
167
|
+
"claude-sonnet-4": 200_000,
|
|
168
|
+
"claude-opus-4-8": 200_000,
|
|
169
|
+
"claude-opus-4-6": 200_000,
|
|
170
|
+
"claude-opus-4": 200_000,
|
|
171
|
+
"claude-3-5-sonnet": 200_000,
|
|
172
|
+
"claude-3-5-haiku": 200_000,
|
|
173
|
+
"claude-3-opus": 200_000,
|
|
174
|
+
# Google
|
|
175
|
+
"gemini-2.5-pro": 1_000_000,
|
|
176
|
+
"gemini-2.5-flash": 1_000_000,
|
|
177
|
+
"gemini-2.5-flash-lite": 1_000_000,
|
|
178
|
+
"gemini-2.0": 1_000_000,
|
|
179
|
+
"gemini-1.5-pro": 2_000_000,
|
|
180
|
+
"gemini-1.5-flash": 1_000_000,
|
|
181
|
+
# Open-weights on OpenRouter / local
|
|
182
|
+
"qwen3": 131_072,
|
|
183
|
+
"qwen-3": 131_072,
|
|
184
|
+
"qwen2.5": 131_072,
|
|
185
|
+
"deepseek-v3": 128_000,
|
|
186
|
+
"deepseek-chat": 128_000,
|
|
187
|
+
# OpenAI (approximate — most recent families)
|
|
188
|
+
"gpt-4o": 128_000,
|
|
189
|
+
"gpt-4-turbo": 128_000,
|
|
190
|
+
"gpt-4.1": 1_000_000,
|
|
191
|
+
"o1": 200_000,
|
|
192
|
+
"o3": 200_000,
|
|
193
|
+
# xAI
|
|
194
|
+
"grok-3": 131_072,
|
|
195
|
+
"grok-2": 131_072,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Conservative default for unknown models — 128k is the floor for most
|
|
199
|
+
# modern production models. The pre-flight check emits a WARN when it
|
|
200
|
+
# falls back to this, so operators can plumb in a real value.
|
|
201
|
+
_DEFAULT_CONTEXT_WINDOW: int = 128_000
|
|
202
|
+
|
|
203
|
+
# Headroom reserved below the declared window so the pre-flight check
|
|
204
|
+
# errs on the side of caution. The bead suggests 8k; this leaves room
|
|
205
|
+
# for the synthesis template, the tool-use schema scaffolding, and the
|
|
206
|
+
# model's own output budget (``_MAX_TOKENS`` = 4k).
|
|
207
|
+
_SYNTHESIS_HEADROOM_TOKENS: int = 8_000
|
|
208
|
+
|
|
209
|
+
# Rough chars-per-token ratio for English text. Synthesis mirrors the
|
|
210
|
+
# ratio already used by ``_print_cost_estimate`` in ``synthesis.py`` so
|
|
211
|
+
# the two estimates stay in sync.
|
|
212
|
+
_CHARS_PER_TOKEN: int = 4
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class SynthesisContextOverflowError(RuntimeError):
|
|
216
|
+
"""Estimated synthesis prompt exceeds the synthesis model's context window.
|
|
217
|
+
|
|
218
|
+
Raised by :func:`detect_synthesis_context_overflow` (via caller check)
|
|
219
|
+
when the pre-flight estimate is too large. Carries the diagnostic
|
|
220
|
+
fields needed to build both the structured error payload and the
|
|
221
|
+
human-readable banner without re-computing.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
message: str,
|
|
227
|
+
*,
|
|
228
|
+
diagnostic: dict[str, Any],
|
|
229
|
+
) -> None:
|
|
230
|
+
super().__init__(message)
|
|
231
|
+
self.diagnostic = diagnostic
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _resolve_context_window(model: str | None) -> tuple[int, bool]:
|
|
235
|
+
"""Return ``(window_tokens, is_default_fallback)`` for *model*.
|
|
236
|
+
|
|
237
|
+
Tries a direct match first, then a prefix match against known model
|
|
238
|
+
families. Returns ``(_DEFAULT_CONTEXT_WINDOW, True)`` when nothing
|
|
239
|
+
matches so callers can warn.
|
|
240
|
+
"""
|
|
241
|
+
if not model:
|
|
242
|
+
return _DEFAULT_CONTEXT_WINDOW, True
|
|
243
|
+
|
|
244
|
+
# Lazy import — aliases are a CLI-layer concern but the MCP runner
|
|
245
|
+
# needs the same resolution so a pre-flight check against
|
|
246
|
+
# "haiku" doesn't falsely pick the default window.
|
|
247
|
+
try:
|
|
248
|
+
from althing.llm.aliases import resolve_alias
|
|
249
|
+
|
|
250
|
+
resolved = resolve_alias(model)
|
|
251
|
+
except Exception:
|
|
252
|
+
resolved = model
|
|
253
|
+
|
|
254
|
+
candidates = (resolved, model)
|
|
255
|
+
for candidate in candidates:
|
|
256
|
+
if not candidate:
|
|
257
|
+
continue
|
|
258
|
+
lower = candidate.lower()
|
|
259
|
+
if lower in _CONTEXT_WINDOWS:
|
|
260
|
+
return _CONTEXT_WINDOWS[lower], False
|
|
261
|
+
# Strip any provider prefix ("openrouter/anthropic/claude-…")
|
|
262
|
+
trimmed = lower.split("/")[-1]
|
|
263
|
+
if trimmed in _CONTEXT_WINDOWS:
|
|
264
|
+
return _CONTEXT_WINDOWS[trimmed], False
|
|
265
|
+
for prefix, size in _CONTEXT_WINDOWS.items():
|
|
266
|
+
if trimmed.startswith(prefix) or lower.startswith(prefix):
|
|
267
|
+
return size, False
|
|
268
|
+
return _DEFAULT_CONTEXT_WINDOW, True
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _estimate_synthesis_prompt_tokens(
|
|
272
|
+
panelist_results: list[Any],
|
|
273
|
+
questions: list[dict[str, Any]],
|
|
274
|
+
*,
|
|
275
|
+
custom_prompt: str | None = None,
|
|
276
|
+
) -> int:
|
|
277
|
+
"""Estimate the synthesis call's prompt token count.
|
|
278
|
+
|
|
279
|
+
Builds the same user content that ``synthesize_panel`` would assemble
|
|
280
|
+
(template + question list + every panelist's Q/A bodies) and applies
|
|
281
|
+
the ~4-chars-per-token heuristic already used elsewhere in the module.
|
|
282
|
+
Kept intentionally pessimistic: over-estimating the prompt is safer
|
|
283
|
+
than under-estimating because the API will reject the real call.
|
|
284
|
+
"""
|
|
285
|
+
# Mirror synth.synthesis._format_panelist_data without importing it,
|
|
286
|
+
# to avoid a circular dependency at module load time.
|
|
287
|
+
chars = 0
|
|
288
|
+
if custom_prompt:
|
|
289
|
+
chars += len(custom_prompt)
|
|
290
|
+
else:
|
|
291
|
+
# Best-effort include of the default prompt template length.
|
|
292
|
+
try:
|
|
293
|
+
from althing.prompts import SYNTHESIS_PROMPT
|
|
294
|
+
|
|
295
|
+
chars += len(SYNTHESIS_PROMPT)
|
|
296
|
+
except Exception:
|
|
297
|
+
chars += 2_000 # conservative guess for the template
|
|
298
|
+
|
|
299
|
+
for q in questions or []:
|
|
300
|
+
text = q.get("text") if isinstance(q, dict) else str(q)
|
|
301
|
+
chars += len(text or "")
|
|
302
|
+
for pr in panelist_results or []:
|
|
303
|
+
chars += len(getattr(pr, "persona_name", "") or "")
|
|
304
|
+
for resp in getattr(pr, "responses", []) or []:
|
|
305
|
+
q_text = resp.get("question", "") if isinstance(resp, dict) else ""
|
|
306
|
+
answer = resp.get("response", "") if isinstance(resp, dict) else ""
|
|
307
|
+
if isinstance(answer, dict):
|
|
308
|
+
# Panelist returned structured output — it gets serialized
|
|
309
|
+
# with json.dumps(..., indent=2) before landing in the prompt.
|
|
310
|
+
import json as _json
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
answer = _json.dumps(answer, indent=2)
|
|
314
|
+
except Exception:
|
|
315
|
+
answer = str(answer)
|
|
316
|
+
chars += len(str(q_text)) + len(str(answer))
|
|
317
|
+
|
|
318
|
+
# Add fixed overhead for section headers ("## Questions Asked",
|
|
319
|
+
# "### <persona>", etc.) and the tool-use scaffolding the
|
|
320
|
+
# StructuredOutputEngine injects (~500 tokens).
|
|
321
|
+
overhead_chars = 2_000
|
|
322
|
+
return (chars + overhead_chars) // _CHARS_PER_TOKEN + 500
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def detect_synthesis_context_overflow(
|
|
326
|
+
panelist_results: list[Any],
|
|
327
|
+
questions: list[dict[str, Any]],
|
|
328
|
+
*,
|
|
329
|
+
synthesis_model: str | None,
|
|
330
|
+
custom_prompt: str | None = None,
|
|
331
|
+
headroom_tokens: int = _SYNTHESIS_HEADROOM_TOKENS,
|
|
332
|
+
) -> dict[str, Any] | None:
|
|
333
|
+
"""Return a diagnostic dict when the synthesis prompt will overflow.
|
|
334
|
+
|
|
335
|
+
Returns ``None`` when the estimated prompt fits inside the model's
|
|
336
|
+
context window (minus *headroom_tokens*). Returns a structured
|
|
337
|
+
diagnostic otherwise, with the fields needed to build both the
|
|
338
|
+
structured error payload (``synthesis_error``) and the actionable
|
|
339
|
+
human-readable message.
|
|
340
|
+
|
|
341
|
+
The diagnostic dict always carries:
|
|
342
|
+
|
|
343
|
+
* ``estimated_tokens`` — pessimistic pre-call token estimate.
|
|
344
|
+
* ``context_window`` — the model's documented context window.
|
|
345
|
+
* ``headroom_tokens`` — reserved below the window.
|
|
346
|
+
* ``effective_limit`` — ``context_window - headroom_tokens``.
|
|
347
|
+
* ``synthesis_model`` — the model we were about to call.
|
|
348
|
+
* ``is_default_window`` — True when we fell back to the default.
|
|
349
|
+
"""
|
|
350
|
+
window, is_default = _resolve_context_window(synthesis_model)
|
|
351
|
+
if is_default:
|
|
352
|
+
logger.warning(
|
|
353
|
+
"Synthesis model %r not in the context-window table — falling back to %dk tokens. "
|
|
354
|
+
"Add it to _CONTEXT_WINDOWS for a tighter pre-flight check.",
|
|
355
|
+
synthesis_model,
|
|
356
|
+
window // 1000,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
estimated = _estimate_synthesis_prompt_tokens(
|
|
360
|
+
panelist_results,
|
|
361
|
+
questions,
|
|
362
|
+
custom_prompt=custom_prompt,
|
|
363
|
+
)
|
|
364
|
+
effective_limit = max(0, window - headroom_tokens)
|
|
365
|
+
if estimated <= effective_limit:
|
|
366
|
+
return None
|
|
367
|
+
return {
|
|
368
|
+
"estimated_tokens": estimated,
|
|
369
|
+
"context_window": window,
|
|
370
|
+
"headroom_tokens": headroom_tokens,
|
|
371
|
+
"effective_limit": effective_limit,
|
|
372
|
+
"synthesis_model": synthesis_model,
|
|
373
|
+
"is_default_window": is_default,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def format_synthesis_overflow_message(diagnostic: dict[str, Any]) -> str:
|
|
378
|
+
"""Render the actionable pre-flight overflow message (sp-avmm).
|
|
379
|
+
|
|
380
|
+
The message names the estimated size, the model limit, and suggests
|
|
381
|
+
large-context alternatives and a rerun strategy. Sized to be usable
|
|
382
|
+
as both a CLI stderr line and a structured ``message`` field.
|
|
383
|
+
"""
|
|
384
|
+
model = diagnostic.get("synthesis_model") or "synthesis model"
|
|
385
|
+
estimated = int(diagnostic.get("estimated_tokens", 0))
|
|
386
|
+
window = int(diagnostic.get("context_window", 0))
|
|
387
|
+
headroom = int(diagnostic.get("headroom_tokens", _SYNTHESIS_HEADROOM_TOKENS))
|
|
388
|
+
|
|
389
|
+
def _k(n: int) -> str:
|
|
390
|
+
if n >= 1_000_000 and n % 1_000_000 == 0:
|
|
391
|
+
return f"{n // 1_000_000}M"
|
|
392
|
+
return f"{n // 1000}k"
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
f"synthesis input ~{_k(estimated)} tokens exceeds {model}'s {_k(window)}"
|
|
396
|
+
f" context (with {_k(headroom)} headroom). Try --synthesis-model"
|
|
397
|
+
f" gemini-flash-lite (1M ctx) or gemini-2.5-pro (1M ctx), or reduce"
|
|
398
|
+
f" panel size. Rerun with the same --personas + --instrument to"
|
|
399
|
+
f" produce identical panelist data; only the synthesis step will change."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _sanitize_api_error(exc: BaseException) -> str:
|
|
404
|
+
"""Shrink a raised API exception into a single-line error string."""
|
|
405
|
+
text = str(exc).strip()
|
|
406
|
+
if not text:
|
|
407
|
+
text = exc.__class__.__name__
|
|
408
|
+
# Collapse newlines and cap length so the sanitized message stays
|
|
409
|
+
# banner-friendly in stderr and JSON envelopes.
|
|
410
|
+
text = " ".join(text.splitlines())
|
|
411
|
+
if len(text) > 1_000:
|
|
412
|
+
text = text[:997] + "..."
|
|
413
|
+
return text
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def build_synthesis_error_payload(
|
|
417
|
+
exc: BaseException | None,
|
|
418
|
+
*,
|
|
419
|
+
error_type: str,
|
|
420
|
+
message: str,
|
|
421
|
+
suggested_fix: str | None = None,
|
|
422
|
+
diagnostic: dict[str, Any] | None = None,
|
|
423
|
+
) -> dict[str, Any]:
|
|
424
|
+
"""Build the structured ``synthesis_error`` dict surfaced to callers.
|
|
425
|
+
|
|
426
|
+
Mirrors the ``total_failure`` shape from sp-efip (structured, keyed
|
|
427
|
+
by category) so downstream consumers (MCP/CI/dashboards) can branch
|
|
428
|
+
on ``error_type`` without string-matching the human message.
|
|
429
|
+
"""
|
|
430
|
+
payload: dict[str, Any] = {
|
|
431
|
+
"error_type": error_type,
|
|
432
|
+
"message": message,
|
|
433
|
+
}
|
|
434
|
+
if suggested_fix:
|
|
435
|
+
payload["suggested_fix"] = suggested_fix
|
|
436
|
+
if exc is not None:
|
|
437
|
+
payload["exception"] = _sanitize_api_error(exc)
|
|
438
|
+
if diagnostic:
|
|
439
|
+
payload["diagnostic"] = diagnostic
|
|
440
|
+
return payload
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# Caps mirrored from the MCP server — the SDK inherits the same guardrails.
|
|
444
|
+
MAX_PERSONAS = 100
|
|
445
|
+
MAX_QUESTIONS = 50
|
|
446
|
+
|
|
447
|
+
# Per-panelist timeout budget used by async wrappers (seconds).
|
|
448
|
+
PANELIST_TIMEOUT = 30
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# Built-in extraction schema registry — keyed by short name.
|
|
452
|
+
EXTRACT_SCHEMA_REGISTRY: dict[str, dict[str, Any]] = {
|
|
453
|
+
"sentiment": {
|
|
454
|
+
"type": "object",
|
|
455
|
+
"properties": {
|
|
456
|
+
"sentiment": {
|
|
457
|
+
"type": "string",
|
|
458
|
+
"enum": ["positive", "negative", "neutral", "mixed"],
|
|
459
|
+
"description": "Overall sentiment of the response.",
|
|
460
|
+
},
|
|
461
|
+
"confidence": {
|
|
462
|
+
"type": "number",
|
|
463
|
+
"minimum": 0.0,
|
|
464
|
+
"maximum": 1.0,
|
|
465
|
+
"description": "Confidence score (0-1) for the sentiment classification.",
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
"required": ["sentiment", "confidence"],
|
|
469
|
+
},
|
|
470
|
+
"themes": {
|
|
471
|
+
"type": "object",
|
|
472
|
+
"properties": {
|
|
473
|
+
"themes": {
|
|
474
|
+
"type": "array",
|
|
475
|
+
"items": {"type": "string"},
|
|
476
|
+
"description": "Key themes or topics mentioned in the response.",
|
|
477
|
+
},
|
|
478
|
+
"primary_theme": {
|
|
479
|
+
"type": "string",
|
|
480
|
+
"description": "The single most dominant theme.",
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
"required": ["themes", "primary_theme"],
|
|
484
|
+
},
|
|
485
|
+
"rating": {
|
|
486
|
+
"type": "object",
|
|
487
|
+
"properties": {
|
|
488
|
+
"rating": {
|
|
489
|
+
"type": "integer",
|
|
490
|
+
"minimum": 1,
|
|
491
|
+
"maximum": 10,
|
|
492
|
+
"description": "Numeric rating (1-10) implied by the response.",
|
|
493
|
+
},
|
|
494
|
+
"explanation": {
|
|
495
|
+
"type": "string",
|
|
496
|
+
"description": "Brief rationale for the assigned rating.",
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
"required": ["rating", "explanation"],
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def resolve_extract_schema(
|
|
505
|
+
value: str | dict[str, Any] | type[BaseModel] | None,
|
|
506
|
+
) -> dict[str, Any] | None:
|
|
507
|
+
"""Resolve ``extract_schema`` into a ``{schema, model}`` envelope.
|
|
508
|
+
|
|
509
|
+
The return shape is::
|
|
510
|
+
|
|
511
|
+
{"schema": <JSON Schema dict>, "model": <Pydantic class | None>}
|
|
512
|
+
|
|
513
|
+
or ``None`` when the input is ``None``. Downstream code consumes the
|
|
514
|
+
``schema`` key for the wire-level structured-output forcing and uses
|
|
515
|
+
the ``model`` key (when present) to apply a typed
|
|
516
|
+
``model_validate`` pass on the extracted payload.
|
|
517
|
+
|
|
518
|
+
Inputs accepted:
|
|
519
|
+
|
|
520
|
+
- ``None`` → ``None``.
|
|
521
|
+
- ``str`` — looks up both registries: the post-hoc analytic registry
|
|
522
|
+
(``EXTRACT_SCHEMA_REGISTRY`` — sentiment / themes / rating) and
|
|
523
|
+
the bundled response registry (``schemas._REGISTRY`` — ranking /
|
|
524
|
+
likert / yes_no / pick_one / annotated_choice). For names in the
|
|
525
|
+
bundled registry the matching :class:`pydantic.BaseModel` from
|
|
526
|
+
:data:`MODEL_REGISTRY` is attached.
|
|
527
|
+
- ``dict`` — treated as a raw JSON Schema. ``model`` is ``None`` so
|
|
528
|
+
the post-extraction pass falls back to plain ``json.loads``.
|
|
529
|
+
- ``type[BaseModel]`` — generates the JSON Schema via
|
|
530
|
+
``model_json_schema()`` and attaches the class for typed
|
|
531
|
+
validation. The wire format and the typed validator are guaranteed
|
|
532
|
+
to be self-consistent in this branch.
|
|
533
|
+
|
|
534
|
+
Example:
|
|
535
|
+
>>> from pydantic import BaseModel, Field
|
|
536
|
+
>>> class MyChoice(BaseModel):
|
|
537
|
+
... option: str = Field(..., min_length=1)
|
|
538
|
+
... score: int = Field(..., ge=1, le=5)
|
|
539
|
+
...
|
|
540
|
+
>>> envelope = resolve_extract_schema(MyChoice)
|
|
541
|
+
>>> envelope["model"] is MyChoice
|
|
542
|
+
True
|
|
543
|
+
>>> envelope["schema"]["type"]
|
|
544
|
+
'object'
|
|
545
|
+
"""
|
|
546
|
+
if value is None:
|
|
547
|
+
return None
|
|
548
|
+
if isinstance(value, type) and issubclass(value, BaseModel):
|
|
549
|
+
return {"schema": value.model_json_schema(), "model": value}
|
|
550
|
+
if isinstance(value, dict):
|
|
551
|
+
return {"schema": value, "model": None}
|
|
552
|
+
if isinstance(value, str):
|
|
553
|
+
if value in EXTRACT_SCHEMA_REGISTRY:
|
|
554
|
+
return {"schema": EXTRACT_SCHEMA_REGISTRY[value], "model": MODEL_REGISTRY.get(value)}
|
|
555
|
+
if value in _BUNDLED_SCHEMA_REGISTRY:
|
|
556
|
+
return {"schema": _BUNDLED_SCHEMA_REGISTRY[value], "model": MODEL_REGISTRY.get(value)}
|
|
557
|
+
names = ", ".join(sorted({*EXTRACT_SCHEMA_REGISTRY, *_BUNDLED_SCHEMA_REGISTRY}))
|
|
558
|
+
raise ValueError(
|
|
559
|
+
f"Unknown extract_schema name {value!r}. Available: {names}. "
|
|
560
|
+
"Or pass an inline JSON Schema dict, or a Pydantic BaseModel subclass."
|
|
561
|
+
)
|
|
562
|
+
raise TypeError(f"extract_schema must be a string, dict, or BaseModel subclass, got {type(value).__name__}")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def format_panelist_result(pr: PanelistResult, model: str) -> dict[str, Any]:
|
|
566
|
+
"""Render a :class:`PanelistResult` into the serialisable dict shape callers expect."""
|
|
567
|
+
pr_model = pr.model or model
|
|
568
|
+
# sp-kvpx: route through resolve_cost so per-panelist ``cost`` honors
|
|
569
|
+
# sp-j3vk's precedence (provider-reported → local fallback). Prior to
|
|
570
|
+
# this, ensemble/mixed-model runs quoted per-persona cost from the
|
|
571
|
+
# local pricing table regardless of what the provider actually billed,
|
|
572
|
+
# so ``per_model_results[*].results[i].cost`` drifted from the top-level
|
|
573
|
+
# ``total_cost`` by the same ratio sp-j3vk fixed at the aggregate layer.
|
|
574
|
+
persona_cost = resolve_cost(pr.usage, pr_model)
|
|
575
|
+
rd: dict[str, Any] = {
|
|
576
|
+
"persona": pr.persona_name,
|
|
577
|
+
"responses": pr.responses,
|
|
578
|
+
"usage": pr.usage.to_dict(),
|
|
579
|
+
"cost": persona_cost.format_usd(),
|
|
580
|
+
"error": pr.error,
|
|
581
|
+
}
|
|
582
|
+
if pr.model:
|
|
583
|
+
rd["model"] = pr.model
|
|
584
|
+
return rd
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def compute_variant_data(
|
|
588
|
+
result_dicts: list[dict[str, Any]],
|
|
589
|
+
variant_names: set[str],
|
|
590
|
+
variant_mapping: dict[str, str],
|
|
591
|
+
variant_count: int,
|
|
592
|
+
questions: list[dict[str, Any]],
|
|
593
|
+
) -> dict[str, Any]:
|
|
594
|
+
"""Compute robustness scores from base + variant panel results."""
|
|
595
|
+
# Separate base and variant results
|
|
596
|
+
base_results = [r for r in result_dicts if r["persona"] not in variant_names]
|
|
597
|
+
variant_results = [r for r in result_dicts if r["persona"] in variant_names]
|
|
598
|
+
|
|
599
|
+
# Build per-question robustness scores
|
|
600
|
+
robustness_scores: list[dict[str, Any]] = []
|
|
601
|
+
per_persona_robustness: list[dict[str, Any]] = []
|
|
602
|
+
n_questions = len(questions)
|
|
603
|
+
|
|
604
|
+
for qi in range(n_questions):
|
|
605
|
+
# Group variant responses by source persona
|
|
606
|
+
variant_resps: dict[str, list[str]] = {}
|
|
607
|
+
for vr in variant_results:
|
|
608
|
+
source = variant_mapping.get(vr["persona"], "")
|
|
609
|
+
if not source:
|
|
610
|
+
continue
|
|
611
|
+
resps = vr.get("responses", [])
|
|
612
|
+
if qi < len(resps) and not resps[qi].get("error"):
|
|
613
|
+
variant_resps.setdefault(source, []).append(resps[qi].get("response", ""))
|
|
614
|
+
|
|
615
|
+
# Get base persona responses for this question
|
|
616
|
+
for br in base_results:
|
|
617
|
+
resps = br.get("responses", [])
|
|
618
|
+
if qi >= len(resps) or resps[qi].get("error"):
|
|
619
|
+
continue
|
|
620
|
+
|
|
621
|
+
base_response = resps[qi].get("response", "")
|
|
622
|
+
persona_name = br["persona"]
|
|
623
|
+
v_resps = variant_resps.get(persona_name, [])
|
|
624
|
+
|
|
625
|
+
if v_resps:
|
|
626
|
+
try:
|
|
627
|
+
rs = robustness_score({persona_name: v_resps}, base_response)
|
|
628
|
+
per_persona_robustness.append(
|
|
629
|
+
{
|
|
630
|
+
"persona": persona_name,
|
|
631
|
+
"question_index": qi,
|
|
632
|
+
"robustness": round(rs.per_persona.get(persona_name, 0.0), 4),
|
|
633
|
+
"k_variants": len(v_resps),
|
|
634
|
+
"finding_value": base_response,
|
|
635
|
+
"interpretation": rs.interpretation,
|
|
636
|
+
}
|
|
637
|
+
)
|
|
638
|
+
except (ValueError, ZeroDivisionError):
|
|
639
|
+
pass
|
|
640
|
+
|
|
641
|
+
# Overall robustness for this question across all base personas
|
|
642
|
+
if variant_resps:
|
|
643
|
+
base_responses_q = [
|
|
644
|
+
br["responses"][qi].get("response", "")
|
|
645
|
+
for br in base_results
|
|
646
|
+
if qi < len(br.get("responses", [])) and not br["responses"][qi].get("error")
|
|
647
|
+
]
|
|
648
|
+
if base_responses_q:
|
|
649
|
+
most_common = Counter(base_responses_q).most_common(1)[0][0]
|
|
650
|
+
all_v: dict[str, list[str]] = {}
|
|
651
|
+
for name, resps_list in variant_resps.items():
|
|
652
|
+
all_v[name] = resps_list
|
|
653
|
+
try:
|
|
654
|
+
agg = robustness_score(all_v, most_common)
|
|
655
|
+
robustness_scores.append(
|
|
656
|
+
{
|
|
657
|
+
"question_index": qi,
|
|
658
|
+
"question_text": questions[qi].get("text", ""),
|
|
659
|
+
"finding_value": most_common,
|
|
660
|
+
"overall_robustness": round(agg.overall_robustness, 4),
|
|
661
|
+
"interpretation": agg.interpretation,
|
|
662
|
+
}
|
|
663
|
+
)
|
|
664
|
+
except (ValueError, ZeroDivisionError):
|
|
665
|
+
pass
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
"variant_count": variant_count,
|
|
669
|
+
"robustness_scores": robustness_scores,
|
|
670
|
+
"per_persona_robustness": per_persona_robustness,
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def run_panel_sync(
|
|
675
|
+
client: LLMClient,
|
|
676
|
+
personas: list[dict[str, Any]],
|
|
677
|
+
questions: list[dict[str, Any]],
|
|
678
|
+
model: str,
|
|
679
|
+
response_schema: dict[str, Any] | None = None,
|
|
680
|
+
*,
|
|
681
|
+
synthesis: bool = True,
|
|
682
|
+
synthesis_model: str | None = None,
|
|
683
|
+
synthesis_prompt: str | None = None,
|
|
684
|
+
temperature: float | None = None,
|
|
685
|
+
top_p: float | None = None,
|
|
686
|
+
seed: int | None = None,
|
|
687
|
+
persona_models: dict[str, str] | None = None,
|
|
688
|
+
extract_schema: dict[str, Any] | None = None,
|
|
689
|
+
synthesis_temperature: float | None = None,
|
|
690
|
+
variants: int = 0,
|
|
691
|
+
attachment_bank: dict[str, dict[str, Any]] | None = None,
|
|
692
|
+
allow_empty_attachments: bool = False,
|
|
693
|
+
sessions_out: dict[str, Any] | None = None,
|
|
694
|
+
cost_gate: CostGate | None = None,
|
|
695
|
+
) -> tuple[
|
|
696
|
+
list[PanelistResult],
|
|
697
|
+
list[dict[str, Any]],
|
|
698
|
+
CostTokenUsage,
|
|
699
|
+
Any,
|
|
700
|
+
dict[str, Any] | None,
|
|
701
|
+
dict[str, Any] | None,
|
|
702
|
+
]:
|
|
703
|
+
"""Run a single-round panel synchronously.
|
|
704
|
+
|
|
705
|
+
Returns ``(panelist_results, result_dicts, panelist_usage,
|
|
706
|
+
panelist_cost, synthesis_dict, variant_data)``. ``variant_data`` is
|
|
707
|
+
``None`` when ``variants == 0``.
|
|
708
|
+
|
|
709
|
+
``sessions_out``: optional out-param. When a dict is passed, it is
|
|
710
|
+
updated in place with the per-persona
|
|
711
|
+
:class:`~althing.persistence.Session` objects the run produced,
|
|
712
|
+
keyed by persona name. Kept as an out-param rather than a seventh
|
|
713
|
+
tuple element so existing unpack sites stay valid. The MCP server
|
|
714
|
+
uses this to persist AC-7 decision-stamped transcripts.
|
|
715
|
+
|
|
716
|
+
``cost_gate``: optional :class:`~althing.cost.CostGate` (sp-utnk /
|
|
717
|
+
GH#576). Threaded to :func:`run_panel_parallel`, which records each
|
|
718
|
+
completing panelist's priced cost and soft-halts dispatch when the
|
|
719
|
+
projected run total exceeds the ceiling. When the gate has halted,
|
|
720
|
+
synthesis is skipped (same rationale as the CLI: synthesizing a
|
|
721
|
+
deliberately-truncated panel burns more budget without producing a
|
|
722
|
+
trustworthy result). The caller keeps the gate reference and is
|
|
723
|
+
expected to inspect ``cost_gate.should_halt()`` on return to shape
|
|
724
|
+
its partial-result envelope.
|
|
725
|
+
"""
|
|
726
|
+
all_personas = list(personas)
|
|
727
|
+
variant_names: set[str] = set()
|
|
728
|
+
variant_mapping: dict[str, str] = {}
|
|
729
|
+
variant_count = 0
|
|
730
|
+
|
|
731
|
+
if variants > 0:
|
|
732
|
+
logger.info("Generating %d variants per persona", variants)
|
|
733
|
+
variant_sets = generate_panel_variants(personas, client, k=variants, model=model)
|
|
734
|
+
for vs in variant_sets:
|
|
735
|
+
for v in vs.variants:
|
|
736
|
+
all_personas.append(v.persona)
|
|
737
|
+
variant_names.add(v.variant_name)
|
|
738
|
+
variant_mapping[v.variant_name] = v.source_persona_name
|
|
739
|
+
variant_count += 1
|
|
740
|
+
logger.info("Running panel with %d base + %d variant personas", len(personas), variant_count)
|
|
741
|
+
|
|
742
|
+
# GH#576: pass the gate only when armed so existing monkeypatched
|
|
743
|
+
# test doubles / caller shims with explicit signatures keep working.
|
|
744
|
+
_gate_kwargs: dict[str, Any] = {"cost_gate": cost_gate} if cost_gate is not None else {}
|
|
745
|
+
panelist_results, _registry, _sessions = run_panel_parallel(
|
|
746
|
+
client=client,
|
|
747
|
+
personas=all_personas,
|
|
748
|
+
questions=questions,
|
|
749
|
+
model=model,
|
|
750
|
+
system_prompt_fn=persona_system_prompt,
|
|
751
|
+
question_prompt_fn=build_question_prompt,
|
|
752
|
+
response_schema=response_schema,
|
|
753
|
+
temperature=temperature,
|
|
754
|
+
top_p=top_p,
|
|
755
|
+
seed=seed,
|
|
756
|
+
persona_models=persona_models,
|
|
757
|
+
extract_schema=extract_schema,
|
|
758
|
+
attachment_bank=attachment_bank,
|
|
759
|
+
allow_empty_attachments=allow_empty_attachments,
|
|
760
|
+
**_gate_kwargs,
|
|
761
|
+
)
|
|
762
|
+
if sessions_out is not None:
|
|
763
|
+
sessions_out.update(_sessions)
|
|
764
|
+
|
|
765
|
+
# sp-efip: fail loud when every panelist produced no usable data.
|
|
766
|
+
# Without this, MCP callers see a normally-shaped "panel complete"
|
|
767
|
+
# result even when every request 400'd upstream.
|
|
768
|
+
total_failure = detect_total_failure(panelist_results)
|
|
769
|
+
if total_failure is not None:
|
|
770
|
+
raise PanelTotalFailureError(
|
|
771
|
+
format_total_failure_message(total_failure),
|
|
772
|
+
diagnostic=total_failure,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
panelist_usage = ZERO_USAGE
|
|
776
|
+
result_dicts: list[dict[str, Any]] = []
|
|
777
|
+
for pr in panelist_results:
|
|
778
|
+
result_dicts.append(format_panelist_result(pr, model))
|
|
779
|
+
panelist_usage = panelist_usage + pr.usage
|
|
780
|
+
|
|
781
|
+
# sp-kvpx: use resolve_cost so panelist_cost honors sp-j3vk precedence
|
|
782
|
+
# (provider-reported → local fallback) for the MCP/SDK sync path too.
|
|
783
|
+
panelist_cost = resolve_cost(panelist_usage, model)
|
|
784
|
+
|
|
785
|
+
base_results = [pr for pr in panelist_results if pr.persona_name not in variant_names]
|
|
786
|
+
synthesis_dict: dict[str, Any] | None = None
|
|
787
|
+
# sp-avmm: synthesis failures surface as a ``synthesis_error`` key on
|
|
788
|
+
# ``synthesis_dict``. Callers (MCP server / SDK) detect this and lift
|
|
789
|
+
# it to a top-level ``synthesis_error`` + warning on the result
|
|
790
|
+
# envelope. It does not invalidate the run — panelist responses are
|
|
791
|
+
# complete; consumers can re-synthesize the saved result.
|
|
792
|
+
# GH#576: mirror the CLI's cost-gate discipline — a halted run is a
|
|
793
|
+
# deliberately-truncated partial, so spending more budget synthesizing
|
|
794
|
+
# it would produce an untrustworthy result. Skip synthesis entirely.
|
|
795
|
+
cost_gate_halted = cost_gate is not None and cost_gate.should_halt()
|
|
796
|
+
if synthesis and cost_gate_halted:
|
|
797
|
+
logger.warning("cost gate halted the panel; skipping synthesis on the partial result")
|
|
798
|
+
synthesis = False
|
|
799
|
+
if synthesis:
|
|
800
|
+
synth_model_for_check = synthesis_model or model
|
|
801
|
+
overflow = detect_synthesis_context_overflow(
|
|
802
|
+
base_results,
|
|
803
|
+
questions,
|
|
804
|
+
synthesis_model=synth_model_for_check,
|
|
805
|
+
custom_prompt=synthesis_prompt,
|
|
806
|
+
)
|
|
807
|
+
if overflow is not None:
|
|
808
|
+
actionable = format_synthesis_overflow_message(overflow)
|
|
809
|
+
logger.error("Synthesis pre-flight rejected: %s", actionable)
|
|
810
|
+
synthesis_dict = {
|
|
811
|
+
"synthesis_error": build_synthesis_error_payload(
|
|
812
|
+
None,
|
|
813
|
+
error_type="synthesis_context_overflow",
|
|
814
|
+
message=actionable,
|
|
815
|
+
suggested_fix=(
|
|
816
|
+
"Rerun with --synthesis-model gemini-2.5-flash-lite (1M context) "
|
|
817
|
+
"or gemini-2.5-pro (1M context), or reduce panel size."
|
|
818
|
+
),
|
|
819
|
+
diagnostic=overflow,
|
|
820
|
+
)
|
|
821
|
+
}
|
|
822
|
+
else:
|
|
823
|
+
try:
|
|
824
|
+
synthesis_result = synthesize_panel(
|
|
825
|
+
client,
|
|
826
|
+
base_results,
|
|
827
|
+
questions,
|
|
828
|
+
model=synthesis_model,
|
|
829
|
+
panelist_model=model,
|
|
830
|
+
custom_prompt=synthesis_prompt,
|
|
831
|
+
panelist_cost=panelist_cost,
|
|
832
|
+
temperature=synthesis_temperature,
|
|
833
|
+
seed=seed,
|
|
834
|
+
)
|
|
835
|
+
synthesis_dict = synthesis_result.to_dict()
|
|
836
|
+
except Exception as exc:
|
|
837
|
+
# sp-rcvr: synthesis recovery ladder — classify, retry
|
|
838
|
+
# transients once, map-reduce on (suspected) downstream
|
|
839
|
+
# context overflow, reroute away from a failing OpenRouter
|
|
840
|
+
# downstream — before surfacing the structured error.
|
|
841
|
+
from althing.synthesis_recovery import (
|
|
842
|
+
RecoveryContext,
|
|
843
|
+
SynthesisRecoveryError,
|
|
844
|
+
recover_synthesis_failure,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
ctx = RecoveryContext(
|
|
848
|
+
client=client,
|
|
849
|
+
panelist_results=base_results,
|
|
850
|
+
questions=questions,
|
|
851
|
+
synthesis_model=synthesis_model,
|
|
852
|
+
panelist_model=model,
|
|
853
|
+
custom_prompt=synthesis_prompt,
|
|
854
|
+
temperature=synthesis_temperature,
|
|
855
|
+
top_p=top_p,
|
|
856
|
+
seed=seed,
|
|
857
|
+
personas=personas,
|
|
858
|
+
allow_map_reduce=synthesis_prompt is None,
|
|
859
|
+
)
|
|
860
|
+
try:
|
|
861
|
+
synthesis_dict = recover_synthesis_failure(exc, ctx).to_dict()
|
|
862
|
+
except SynthesisRecoveryError as rexc:
|
|
863
|
+
logger.error("Synthesis failed: %s", rexc, exc_info=True)
|
|
864
|
+
synthesis_dict = {
|
|
865
|
+
"synthesis_error": build_synthesis_error_payload(
|
|
866
|
+
rexc,
|
|
867
|
+
error_type="synthesis_api_error",
|
|
868
|
+
message=f"Synthesis call failed: {_sanitize_api_error(rexc)}",
|
|
869
|
+
suggested_fix=(
|
|
870
|
+
f"Retry with a large-context in-family model: `{rexc.suggested_command}`. "
|
|
871
|
+
"Also check provider credentials and model availability."
|
|
872
|
+
),
|
|
873
|
+
diagnostic={"recovery_rungs": rexc.rungs},
|
|
874
|
+
)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
variant_data: dict[str, Any] | None = None
|
|
878
|
+
if variants > 0 and variant_mapping:
|
|
879
|
+
variant_data = compute_variant_data(
|
|
880
|
+
result_dicts,
|
|
881
|
+
variant_names,
|
|
882
|
+
variant_mapping,
|
|
883
|
+
variant_count,
|
|
884
|
+
questions,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
return panelist_results, result_dicts, panelist_usage, panelist_cost, synthesis_dict, variant_data
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def run_multi_round_sync(
|
|
891
|
+
client: LLMClient,
|
|
892
|
+
personas: list[dict[str, Any]],
|
|
893
|
+
instrument: Instrument,
|
|
894
|
+
model: str,
|
|
895
|
+
response_schema: dict[str, Any] | None,
|
|
896
|
+
*,
|
|
897
|
+
synthesis: bool,
|
|
898
|
+
synthesis_model: str | None,
|
|
899
|
+
synthesis_prompt: str | None,
|
|
900
|
+
temperature: float | None = None,
|
|
901
|
+
top_p: float | None = None,
|
|
902
|
+
seed: int | None = None,
|
|
903
|
+
persona_models: dict[str, str] | None = None,
|
|
904
|
+
extract_schema: dict[str, Any] | None = None,
|
|
905
|
+
synthesis_temperature: float | None = None,
|
|
906
|
+
system_prompt_fn: Any = None,
|
|
907
|
+
max_workers: int | None = None,
|
|
908
|
+
) -> MultiRoundResult:
|
|
909
|
+
"""Drive :func:`run_multi_round_panel` for v1/v2/v3 instruments.
|
|
910
|
+
|
|
911
|
+
``system_prompt_fn`` lets callers (the CLI's ``--prompt-template`` /
|
|
912
|
+
instrument-embedded ``system_prompt_template``) override the default
|
|
913
|
+
persona system prompt; ``None`` keeps :func:`persona_system_prompt`.
|
|
914
|
+
``max_workers`` bounds the per-round panelist thread pool (the CLI's
|
|
915
|
+
``--max-concurrent``); ``None`` keeps the orchestrator default.
|
|
916
|
+
"""
|
|
917
|
+
|
|
918
|
+
def _round_synth(
|
|
919
|
+
c: LLMClient,
|
|
920
|
+
panelist_results: list[PanelistResult],
|
|
921
|
+
questions: list[dict[str, Any]],
|
|
922
|
+
*,
|
|
923
|
+
model: str,
|
|
924
|
+
):
|
|
925
|
+
return synthesize_panel(
|
|
926
|
+
c,
|
|
927
|
+
panelist_results,
|
|
928
|
+
questions,
|
|
929
|
+
model=synthesis_model,
|
|
930
|
+
panelist_model=model,
|
|
931
|
+
custom_prompt=synthesis_prompt,
|
|
932
|
+
temperature=synthesis_temperature,
|
|
933
|
+
seed=seed,
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
final_fn = _round_synth if synthesis else None
|
|
937
|
+
return run_multi_round_panel(
|
|
938
|
+
client=client,
|
|
939
|
+
personas=personas,
|
|
940
|
+
instrument=instrument,
|
|
941
|
+
model=model,
|
|
942
|
+
system_prompt_fn=system_prompt_fn or persona_system_prompt,
|
|
943
|
+
question_prompt_fn=build_question_prompt,
|
|
944
|
+
synthesize_round_fn=_round_synth if synthesis else (lambda *a, **kw: None),
|
|
945
|
+
synthesize_final_fn=final_fn,
|
|
946
|
+
response_schema=response_schema,
|
|
947
|
+
max_workers=max_workers,
|
|
948
|
+
temperature=temperature,
|
|
949
|
+
top_p=top_p,
|
|
950
|
+
seed=seed,
|
|
951
|
+
persona_models=persona_models,
|
|
952
|
+
extract_schema=extract_schema,
|
|
953
|
+
)
|