hypermind 0.11.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 (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,254 @@
1
+ """Pluggable model routing for consult panels.
2
+
3
+ A :class:`ResponderRouter` decides *which responder* to dispatch to *for a
4
+ given panel member and query*. This unblocks three patterns at once:
5
+
6
+ 1. **Cost-aware routing** (:class:`CostAwareRouter`) — try the strongest
7
+ model first; fall back to cheaper models on rate-limits, errors, or
8
+ when the per-call budget would be exceeded.
9
+
10
+ 2. **Multi-model panels** (:class:`MultiModelPanelRouter`) — assign
11
+ different LLMs to different panel members so the panel reflects
12
+ genuine model diversity, not just role-prompt theatre.
13
+
14
+ 3. **Per-archetype routing** (:class:`ArchetypeMappedRouter`) — each
15
+ analyst archetype gets its own model (e.g., intel-agency → claude-opus,
16
+ social-intel → llama-3.3).
17
+
18
+ A router is itself a :class:`Responder` — it satisfies
19
+ ``Callable[[bytes, str], Awaitable[ResponderResult]]`` so callers can pass
20
+ it directly to ``consult(responder=router)``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import random
26
+ from collections.abc import Callable, Iterable
27
+ from typing import Any, Protocol
28
+
29
+ from hypermind.responders.base import (
30
+ LLMResponderError,
31
+ Responder,
32
+ ResponderResult,
33
+ unpack_responder_result,
34
+ )
35
+
36
+
37
+ class ResponderRouter(Protocol):
38
+ """Routes one ``(agent_kid, query)`` to the appropriate underlying responder.
39
+
40
+ A router is callable like a responder, so it can be passed directly
41
+ as ``consult(responder=router)``.
42
+ """
43
+
44
+ async def __call__(self, agent_kid: bytes, query: str) -> ResponderResult: ...
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # CostAwareRouter — strongest-first, fallback on error or budget breach
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class CostAwareRouter:
53
+ """Try ``primary`` first; fall back through the chain on errors.
54
+
55
+ Useful pattern: ``[claude-opus, claude-sonnet, claude-haiku]`` —
56
+ if Opus times out or rate-limits, the panel doesn't collapse, it
57
+ just gets a cheaper answer for that member.
58
+
59
+ Each fallback emits a recorder event (if a recorder is attached) so
60
+ operators can spot which models are flapping.
61
+
62
+ ``budget_usd_per_call`` is a soft cap: if the *previous* call's cost
63
+ exceeded this, the router skips the most expensive model for the
64
+ next call. (We can't predict cost ahead of time without prompts.)
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ chain: list[Responder],
70
+ *,
71
+ budget_usd_per_call: float | None = None,
72
+ recorder: Any = None,
73
+ ) -> None:
74
+ if not chain:
75
+ raise ValueError("CostAwareRouter chain must contain at least one responder")
76
+ self.chain = list(chain)
77
+ self.budget_usd_per_call = budget_usd_per_call
78
+ self.recorder = recorder
79
+ # When the last successful call cost more than budget, mark it
80
+ # so the next call skips the heaviest model.
81
+ self._last_call_exceeded_budget = False
82
+
83
+ def _emit(self, event_type: str, fields: dict[str, Any]) -> None:
84
+ if self.recorder is None:
85
+ return
86
+ try:
87
+ self.recorder.emit(
88
+ event_type=event_type,
89
+ severity="info",
90
+ fields=fields,
91
+ )
92
+ except Exception:
93
+ pass
94
+
95
+ async def __call__(self, agent_kid: bytes, query: str) -> ResponderResult:
96
+ chain = self.chain
97
+ if self._last_call_exceeded_budget and len(chain) > 1:
98
+ # Skip the most expensive (assumed first) — fall straight
99
+ # through to the cheaper tier for one call.
100
+ chain = chain[1:]
101
+ self._last_call_exceeded_budget = False
102
+
103
+ last_exc: BaseException | None = None
104
+ for idx, responder in enumerate(chain):
105
+ try:
106
+ result = await responder(agent_kid, query)
107
+ except LLMResponderError as exc:
108
+ last_exc = exc
109
+ self._emit(
110
+ "router_fallback",
111
+ {
112
+ "tier": idx,
113
+ "reason": "llm_responder_error",
114
+ "error": str(exc)[:160],
115
+ },
116
+ )
117
+ continue
118
+ except Exception as exc:
119
+ last_exc = exc
120
+ self._emit(
121
+ "router_fallback",
122
+ {
123
+ "tier": idx,
124
+ "reason": "unexpected_error",
125
+ "error_type": type(exc).__name__,
126
+ },
127
+ )
128
+ continue
129
+
130
+ # Check budget — only meaningful on tuple results with token_usage
131
+ try:
132
+ _unc, structured = unpack_responder_result(result)
133
+ except TypeError:
134
+ # Pass through whatever responder returned; budget check skipped
135
+ return result
136
+
137
+ if (
138
+ self.budget_usd_per_call is not None
139
+ and structured is not None
140
+ and structured.token_usage is not None
141
+ and structured.token_usage.cost_usd is not None
142
+ and structured.token_usage.cost_usd > self.budget_usd_per_call
143
+ ):
144
+ self._last_call_exceeded_budget = True
145
+ self._emit(
146
+ "router_budget_exceeded",
147
+ {
148
+ "tier": idx,
149
+ "cost_usd": structured.token_usage.cost_usd,
150
+ "budget_usd": self.budget_usd_per_call,
151
+ },
152
+ )
153
+ return result
154
+
155
+ # Every tier failed → re-raise as LLMResponderError
156
+ raise LLMResponderError(
157
+ f"CostAwareRouter exhausted all {len(self.chain)} tiers; last error: {last_exc}"
158
+ )
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # MultiModelPanelRouter — different model per panel member
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ class MultiModelPanelRouter:
167
+ """Assign each ``agent_kid`` a stable responder from a pool.
168
+
169
+ The mapping is deterministic in the kid — re-running the same
170
+ cohort produces the same model assignments — but spread evenly
171
+ across the pool. Useful for genuine model diversity (mix Claude,
172
+ GPT-4o, Llama, Gemini in one panel).
173
+
174
+ The pool order is preserved; assignment uses
175
+ ``int.from_bytes(kid[:4]) % len(pool)``.
176
+ """
177
+
178
+ def __init__(self, pool: Iterable[Responder]) -> None:
179
+ self.pool = list(pool)
180
+ if not self.pool:
181
+ raise ValueError("MultiModelPanelRouter pool cannot be empty")
182
+
183
+ def _select(self, agent_kid: bytes) -> Responder:
184
+ if not agent_kid:
185
+ return self.pool[0]
186
+ idx = int.from_bytes(agent_kid[:4], "big") % len(self.pool)
187
+ return self.pool[idx]
188
+
189
+ async def __call__(self, agent_kid: bytes, query: str) -> ResponderResult:
190
+ responder = self._select(agent_kid)
191
+ return await responder(agent_kid, query)
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # ArchetypeMappedRouter — explicit archetype → responder mapping
196
+ # ---------------------------------------------------------------------------
197
+
198
+
199
+ class ArchetypeMappedRouter:
200
+ """Look up a responder by archetype, with a default fallback.
201
+
202
+ Callers supply an ``archetype_for(kid) -> str`` callable so the
203
+ router can route per-agent. If the lookup returns an unknown
204
+ archetype, ``default_responder`` handles the call.
205
+ """
206
+
207
+ def __init__(
208
+ self,
209
+ mapping: dict[str, Responder],
210
+ *,
211
+ archetype_for: Callable[[bytes], str | None],
212
+ default_responder: Responder,
213
+ ) -> None:
214
+ self.mapping = dict(mapping)
215
+ self.archetype_for = archetype_for
216
+ self.default_responder = default_responder
217
+
218
+ async def __call__(self, agent_kid: bytes, query: str) -> ResponderResult:
219
+ archetype = self.archetype_for(agent_kid)
220
+ responder = self.mapping.get(archetype or "", self.default_responder)
221
+ return await responder(agent_kid, query)
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # RandomPoolRouter — stochastic mixture (for benchmarking model diversity)
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ class RandomPoolRouter:
230
+ """Pick a uniform-random responder from the pool for each call.
231
+
232
+ Distinct from :class:`MultiModelPanelRouter` (which is deterministic
233
+ per kid). This is for benchmarking and Monte-Carlo experiments where
234
+ you want each call to potentially use a different model.
235
+ """
236
+
237
+ def __init__(self, pool: Iterable[Responder], *, seed: int | None = None) -> None:
238
+ self.pool = list(pool)
239
+ if not self.pool:
240
+ raise ValueError("RandomPoolRouter pool cannot be empty")
241
+ self._rng = random.Random(seed)
242
+
243
+ async def __call__(self, agent_kid: bytes, query: str) -> ResponderResult:
244
+ responder = self._rng.choice(self.pool)
245
+ return await responder(agent_kid, query)
246
+
247
+
248
+ __all__ = [
249
+ "ArchetypeMappedRouter",
250
+ "CostAwareRouter",
251
+ "MultiModelPanelRouter",
252
+ "RandomPoolRouter",
253
+ "ResponderRouter",
254
+ ]
@@ -0,0 +1,287 @@
1
+ """Structured reasoning capture for LLM-backed responders.
2
+
3
+ Single-shot LLM panels return ``{confidence, reasoning}`` — a flat scalar
4
+ and a short prose rationale. Sub-claims, evidence references, alternative
5
+ scenarios, and key unknowns are lost at parse time.
6
+
7
+ This module defines the richer schema that the LLM is *also* asked to
8
+ produce, with graceful fallback when the model ignores the extended
9
+ structure (or pricing/cost data isn't available).
10
+
11
+ The widened :class:`StructuredResponse` is attached to every
12
+ :class:`PanelMember` and surfaced in the :class:`ConsultResult`, so the
13
+ viewer can show *why* an analyst answered the way they did, what they
14
+ thought might change their mind, and which references they cited.
15
+
16
+ Backward-compat: every field has a default. A bare confidence-only
17
+ response from the LLM still produces a valid (but minimally-populated)
18
+ :class:`StructuredResponse`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Token usage (cost tracking hook — Phase 5 wires it through cost.py)
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TokenUsage:
35
+ """LLM token accounting for one panel-member call.
36
+
37
+ ``cost_usd`` is None when the model isn't in the price table —
38
+ operators see the warning emit on the recorder rather than a wrong
39
+ cost figure.
40
+ """
41
+
42
+ model: str
43
+ prompt_tokens: int
44
+ completion_tokens: int
45
+ cost_usd: float | None = None
46
+
47
+ @property
48
+ def total_tokens(self) -> int:
49
+ return self.prompt_tokens + self.completion_tokens
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ return {
53
+ "model": self.model,
54
+ "prompt_tokens": self.prompt_tokens,
55
+ "completion_tokens": self.completion_tokens,
56
+ "total_tokens": self.total_tokens,
57
+ "cost_usd": self.cost_usd,
58
+ }
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # StructuredResponse — what an LLM tells us beyond a scalar confidence
63
+ # ---------------------------------------------------------------------------
64
+
65
+ #: Hard caps so the viewer / aggregator can rely on bounded sizes.
66
+ MAX_REASONING_CHARS = 1000
67
+ MAX_LIST_ITEMS = 8
68
+ MAX_LIST_ITEM_CHARS = 240
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class StructuredResponse:
73
+ """Rich reasoning record from one LLM call.
74
+
75
+ Fields:
76
+ confidence: scalar in [0, 1] — already used to build Uncertainty
77
+ reasoning: one-paragraph rationale (≤1000 chars)
78
+ sub_claims: bulleted decomposition of the main claim
79
+ alternatives: scenarios that would change the answer
80
+ evidence_refs: cited external references (URLs, doc IDs, …)
81
+ key_unknowns: things the analyst flags as unresolved
82
+ token_usage: cost accounting (filled by responder, may be None)
83
+ raw_json: raw LLM payload — kept for replay and debugging
84
+ """
85
+
86
+ confidence: float
87
+ reasoning: str = ""
88
+ sub_claims: list[str] = field(default_factory=list)
89
+ alternatives: list[str] = field(default_factory=list)
90
+ evidence_refs: list[str] = field(default_factory=list)
91
+ key_unknowns: list[str] = field(default_factory=list)
92
+ token_usage: TokenUsage | None = None
93
+ raw_json: str = ""
94
+
95
+ def to_dict(self) -> dict[str, Any]:
96
+ return {
97
+ "confidence": self.confidence,
98
+ "reasoning": self.reasoning,
99
+ "sub_claims": list(self.sub_claims),
100
+ "alternatives": list(self.alternatives),
101
+ "evidence_refs": list(self.evidence_refs),
102
+ "key_unknowns": list(self.key_unknowns),
103
+ "token_usage": self.token_usage.to_dict() if self.token_usage else None,
104
+ }
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Parsing
109
+ # ---------------------------------------------------------------------------
110
+
111
+ _JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}", re.MULTILINE)
112
+
113
+
114
+ def _truncate(s: str, n: int) -> str:
115
+ s = (s or "").strip()
116
+ return s if len(s) <= n else s[:n].rstrip() + "…"
117
+
118
+
119
+ def _coerce_str_list(value: Any, max_items: int = MAX_LIST_ITEMS) -> list[str]:
120
+ """Best-effort coercion of LLM output into a clean list[str].
121
+
122
+ Accepts list, comma-separated string, or single string. Drops empty
123
+ items, dedupes preserving order, truncates to ``max_items`` and
124
+ each item to :data:`MAX_LIST_ITEM_CHARS` chars.
125
+ """
126
+ if value is None:
127
+ return []
128
+ if isinstance(value, str):
129
+ # Treat a stringified list (e.g. "a, b, c") as comma-separated
130
+ items = [p.strip() for p in value.split(",")]
131
+ elif isinstance(value, (list, tuple)):
132
+ items = []
133
+ for raw in value:
134
+ if isinstance(raw, str):
135
+ items.append(raw.strip())
136
+ elif isinstance(raw, dict):
137
+ # LLMs sometimes nest {"text": "..."} or similar
138
+ text = raw.get("text") or raw.get("claim") or raw.get("title") or ""
139
+ items.append(str(text).strip())
140
+ else:
141
+ items.append(str(raw).strip())
142
+ else:
143
+ return []
144
+
145
+ cleaned: list[str] = []
146
+ seen: set[str] = set()
147
+ for it in items:
148
+ if not it:
149
+ continue
150
+ truncated = _truncate(it, MAX_LIST_ITEM_CHARS)
151
+ key = truncated.lower()
152
+ if key in seen:
153
+ continue
154
+ seen.add(key)
155
+ cleaned.append(truncated)
156
+ if len(cleaned) >= max_items:
157
+ break
158
+ return cleaned
159
+
160
+
161
+ def parse_structured_response(
162
+ raw: str,
163
+ *,
164
+ agent_kid: bytes | None = None,
165
+ token_usage: TokenUsage | None = None,
166
+ ) -> StructuredResponse:
167
+ """Parse the LLM's JSON response into a :class:`StructuredResponse`.
168
+
169
+ Tolerant of:
170
+ * bare confidence-only payloads (legacy contract)
171
+ * extra prose around the JSON block (extracts the first ``{...}``)
172
+ * field aliases (``rationale``/``explanation`` for reasoning, etc.)
173
+ * list-or-string variations on sub_claims/alternatives/evidence
174
+
175
+ Raises :class:`ValueError` if no valid ``confidence`` can be extracted —
176
+ callers (the OpenAI/Anthropic responder) re-raise as
177
+ :class:`LLMResponderError` to keep the consult-panel drop-policy intact.
178
+
179
+ The ``agent_kid`` argument is currently informational (kept for future
180
+ audit-trail correlation); it does NOT affect parsing.
181
+ """
182
+ _ = agent_kid # reserved for future per-agent parse caching
183
+
184
+ if raw is None:
185
+ raise ValueError("empty LLM response")
186
+
187
+ text = str(raw).strip()
188
+ if not text:
189
+ raise ValueError("empty LLM response")
190
+
191
+ # Try direct JSON parse first; fall back to extracting the first object.
192
+ parsed: Any
193
+ try:
194
+ parsed = json.loads(text)
195
+ except json.JSONDecodeError:
196
+ m = _JSON_BLOCK_RE.search(text)
197
+ if m is None:
198
+ raise ValueError("LLM response is not JSON and contains no JSON object") from None
199
+ try:
200
+ parsed = json.loads(m.group(0))
201
+ except json.JSONDecodeError as exc:
202
+ raise ValueError(f"LLM response JSON block did not parse: {exc}") from exc
203
+
204
+ if not isinstance(parsed, dict):
205
+ raise ValueError(f"LLM response top-level is {type(parsed).__name__}, expected object")
206
+
207
+ # Confidence is mandatory.
208
+ if "confidence" not in parsed:
209
+ raise ValueError("LLM response missing required 'confidence' field")
210
+ try:
211
+ confidence = float(parsed["confidence"])
212
+ except (TypeError, ValueError) as exc:
213
+ raise ValueError(f"LLM 'confidence' is not a float: {exc}") from exc
214
+
215
+ # Reasoning: accept multiple aliases the model may have used.
216
+ reasoning_raw = (
217
+ parsed.get("reasoning")
218
+ or parsed.get("rationale")
219
+ or parsed.get("explanation")
220
+ or parsed.get("thought")
221
+ or ""
222
+ )
223
+ reasoning = _truncate(str(reasoning_raw), MAX_REASONING_CHARS)
224
+
225
+ sub_claims = _coerce_str_list(
226
+ parsed.get("sub_claims") or parsed.get("claims") or parsed.get("factors")
227
+ )
228
+ alternatives = _coerce_str_list(
229
+ parsed.get("alternatives")
230
+ or parsed.get("alternative_scenarios")
231
+ or parsed.get("counterfactuals")
232
+ )
233
+ evidence_refs = _coerce_str_list(
234
+ parsed.get("evidence_refs")
235
+ or parsed.get("evidence")
236
+ or parsed.get("citations")
237
+ or parsed.get("references")
238
+ )
239
+ key_unknowns = _coerce_str_list(
240
+ parsed.get("key_unknowns") or parsed.get("unknowns") or parsed.get("open_questions")
241
+ )
242
+
243
+ return StructuredResponse(
244
+ confidence=confidence,
245
+ reasoning=reasoning,
246
+ sub_claims=sub_claims,
247
+ alternatives=alternatives,
248
+ evidence_refs=evidence_refs,
249
+ key_unknowns=key_unknowns,
250
+ token_usage=token_usage,
251
+ raw_json=text if len(text) <= 4096 else text[:4096] + "…",
252
+ )
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # System-prompt helper — what we ask the LLM to produce
257
+ # ---------------------------------------------------------------------------
258
+
259
+ #: Default extension to any system prompt to elicit the rich schema.
260
+ #: Callers compose this with their archetype-specific prompt; the OpenAI
261
+ #: and Anthropic responders auto-append it when ``capture_structured=True``.
262
+ STRUCTURED_PROMPT_SUFFIX = """\
263
+
264
+ Respond with a single json object using this schema:
265
+
266
+ {
267
+ "confidence": <float in [0, 1] — probability the answer is YES>,
268
+ "reasoning": "<one-paragraph rationale, ≤300 words>",
269
+ "sub_claims": ["<bullet>", "<bullet>", ...],
270
+ "alternatives": ["<scenario that would change my answer>", ...],
271
+ "evidence_refs": ["<source/url/doc id>", ...],
272
+ "key_unknowns": ["<thing I am uncertain about>", ...]
273
+ }
274
+
275
+ Lists may be empty. Do NOT add any prose outside the JSON object.
276
+ """
277
+
278
+
279
+ __all__ = [
280
+ "MAX_LIST_ITEMS",
281
+ "MAX_LIST_ITEM_CHARS",
282
+ "MAX_REASONING_CHARS",
283
+ "STRUCTURED_PROMPT_SUFFIX",
284
+ "StructuredResponse",
285
+ "TokenUsage",
286
+ "parse_structured_response",
287
+ ]