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.
Files changed (156) hide show
  1. althing/__init__.py +46 -0
  2. althing/__main__.py +9 -0
  3. althing/__version__.py +1 -0
  4. althing/_compat_env.py +27 -0
  5. althing/_runners.py +953 -0
  6. althing/agent_assets/__init__.py +3 -0
  7. althing/agent_assets/commands/althing-poll.md +75 -0
  8. althing/agent_assets/skills/concept-test/SKILL.md +72 -0
  9. althing/agent_assets/skills/focus-group/SKILL.md +77 -0
  10. althing/agent_assets/skills/name-test/SKILL.md +62 -0
  11. althing/agent_assets/skills/pricing-probe/SKILL.md +75 -0
  12. althing/agent_assets/skills/survey-prescreen/SKILL.md +76 -0
  13. althing/aggregation.py +240 -0
  14. althing/analysis/__init__.py +46 -0
  15. althing/analysis/distribution.py +323 -0
  16. althing/analysis/inspect.py +586 -0
  17. althing/analysis/subgroup.py +168 -0
  18. althing/analysis/subgroup_cli.py +598 -0
  19. althing/analyze.py +839 -0
  20. althing/attachments/__init__.py +77 -0
  21. althing/attachments/filter.py +67 -0
  22. althing/attachments/models.py +50 -0
  23. althing/attachments/pdf.py +537 -0
  24. althing/attachments/store.py +111 -0
  25. althing/calibration.py +239 -0
  26. althing/checkpoint.py +763 -0
  27. althing/cli/__init__.py +1 -0
  28. althing/cli/commands.py +8080 -0
  29. althing/cli/mcp_install.py +487 -0
  30. althing/cli/output.py +106 -0
  31. althing/cli/parser.py +1706 -0
  32. althing/cli/progress.py +93 -0
  33. althing/cli/repl.py +122 -0
  34. althing/cli/slash.py +197 -0
  35. althing/conditions.py +261 -0
  36. althing/convergence.py +1091 -0
  37. althing/cost.py +950 -0
  38. althing/cost_summary.py +452 -0
  39. althing/credentials.py +311 -0
  40. althing/data/__init__.py +8 -0
  41. althing/data/synthbench-snapshot.json +120 -0
  42. althing/diff.py +331 -0
  43. althing/domains.py +284 -0
  44. althing/ensemble.py +922 -0
  45. althing/fetch/__init__.py +38 -0
  46. althing/fetch/cache.py +360 -0
  47. althing/fetch/ladder.py +437 -0
  48. althing/fetch/lower.py +199 -0
  49. althing/fetch/perimeter.py +494 -0
  50. althing/instrument.py +751 -0
  51. althing/integrations/__init__.py +21 -0
  52. althing/integrations/composio.py +345 -0
  53. althing/llm/__init__.py +32 -0
  54. althing/llm/aliases.py +138 -0
  55. althing/llm/capabilities.py +86 -0
  56. althing/llm/client.py +267 -0
  57. althing/llm/errors.py +162 -0
  58. althing/llm/fast_default.py +71 -0
  59. althing/llm/models.py +340 -0
  60. althing/llm/providers/__init__.py +18 -0
  61. althing/llm/providers/_anthropic_format.py +281 -0
  62. althing/llm/providers/_openai_format.py +381 -0
  63. althing/llm/providers/anthropic.py +172 -0
  64. althing/llm/providers/base.py +60 -0
  65. althing/llm/providers/gemini.py +107 -0
  66. althing/llm/providers/openai_compat.py +102 -0
  67. althing/llm/providers/openrouter.py +357 -0
  68. althing/llm/providers/xai.py +98 -0
  69. althing/llm/retry.py +113 -0
  70. althing/logging_config.py +80 -0
  71. althing/main.py +308 -0
  72. althing/mcp/__init__.py +7 -0
  73. althing/mcp/compat.py +110 -0
  74. althing/mcp/data.py +700 -0
  75. althing/mcp/sampling.py +380 -0
  76. althing/mcp/server.py +3371 -0
  77. althing/metadata.py +214 -0
  78. althing/metadata_migrations.py +70 -0
  79. althing/orchestrator.py +2119 -0
  80. althing/pack_diff.py +271 -0
  81. althing/packs/__init__.py +3 -0
  82. althing/packs/ai-eval-buyers.yaml +247 -0
  83. althing/packs/broad-professionals.yaml +251 -0
  84. althing/packs/developer.yaml +271 -0
  85. althing/packs/enterprise-ai-buyers.yaml +245 -0
  86. althing/packs/enterprise-buyer.yaml +252 -0
  87. althing/packs/general-consumer.yaml +271 -0
  88. althing/packs/healthcare-patient.yaml +271 -0
  89. althing/packs/instruments/__init__.py +1 -0
  90. althing/packs/instruments/churn-diagnosis.yaml +73 -0
  91. althing/packs/instruments/feature-prioritization.yaml +75 -0
  92. althing/packs/instruments/general-survey.yaml +46 -0
  93. althing/packs/instruments/happiness-probe.yaml +41 -0
  94. althing/packs/instruments/landing-page-comprehension.yaml +75 -0
  95. althing/packs/instruments/market-research.yaml +50 -0
  96. althing/packs/instruments/name-test.yaml +74 -0
  97. althing/packs/instruments/pricing-discovery.yaml +80 -0
  98. althing/packs/instruments/product-feedback.yaml +46 -0
  99. althing/packs/job-seekers.yaml +188 -0
  100. althing/packs/market-research-critics.yaml +217 -0
  101. althing/packs/product-research.yaml +248 -0
  102. althing/packs/recruiters-talent.yaml +264 -0
  103. althing/packs/skeptical-executives.yaml +227 -0
  104. althing/packs/startup-founder.yaml +242 -0
  105. althing/packs/students.yaml +352 -0
  106. althing/persistence.py +510 -0
  107. althing/perturbation.py +333 -0
  108. althing/plugins/__init__.py +34 -0
  109. althing/plugins/hooks.py +159 -0
  110. althing/plugins/lint.py +352 -0
  111. althing/plugins/manager.py +176 -0
  112. althing/plugins/manifest.py +100 -0
  113. althing/plugins/registry.py +66 -0
  114. althing/poll_summary.py +1195 -0
  115. althing/preflight.py +155 -0
  116. althing/profiles.py +264 -0
  117. althing/prompts.py +311 -0
  118. althing/py.typed +0 -0
  119. althing/question_budget.py +140 -0
  120. althing/registry/__init__.py +178 -0
  121. althing/registry/cache.py +223 -0
  122. althing/registry/fetch.py +104 -0
  123. althing/registry/github.py +212 -0
  124. althing/reporting/__init__.py +17 -0
  125. althing/reporting/loader.py +69 -0
  126. althing/reporting/markdown.py +450 -0
  127. althing/response_coercion.py +161 -0
  128. althing/routing.py +132 -0
  129. althing/runtime.py +552 -0
  130. althing/schemas/__init__.py +53 -0
  131. althing/schemas/v1.0.0.json +161 -0
  132. althing/sdk.py +1458 -0
  133. althing/stats.py +1522 -0
  134. althing/structured/__init__.py +5 -0
  135. althing/structured/models.py +149 -0
  136. althing/structured/output.py +376 -0
  137. althing/structured/retry.py +170 -0
  138. althing/structured/schemas.py +127 -0
  139. althing/structured/validate.py +303 -0
  140. althing/structured/verdict.py +146 -0
  141. althing/synthbench.py +768 -0
  142. althing/synthbench_submit.py +582 -0
  143. althing/synthesis.py +1529 -0
  144. althing/synthesis_recovery.py +467 -0
  145. althing/templates.py +179 -0
  146. althing/text_width.py +126 -0
  147. althing-1.7.0.dist-info/METADATA +1473 -0
  148. althing-1.7.0.dist-info/RECORD +156 -0
  149. althing-1.7.0.dist-info/WHEEL +5 -0
  150. althing-1.7.0.dist-info/entry_points.txt +3 -0
  151. althing-1.7.0.dist-info/licenses/LICENSE +21 -0
  152. althing-1.7.0.dist-info/top_level.txt +3 -0
  153. synth_panel/__init__.py +60 -0
  154. synth_panel/__main__.py +17 -0
  155. synthpanel/__init__.py +60 -0
  156. 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
+ )