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.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|