alpha-engine-lib 0.32.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.
- alpha_engine_lib/__init__.py +3 -0
- alpha_engine_lib/agent_schemas.py +663 -0
- alpha_engine_lib/alerts.py +576 -0
- alpha_engine_lib/arcticdb.py +340 -0
- alpha_engine_lib/collector_results.py +69 -0
- alpha_engine_lib/cost.py +665 -0
- alpha_engine_lib/dates.py +273 -0
- alpha_engine_lib/decision_capture.py +462 -0
- alpha_engine_lib/ec2_spot.py +363 -0
- alpha_engine_lib/email_sender.py +206 -0
- alpha_engine_lib/eval_artifacts.py +361 -0
- alpha_engine_lib/logging.py +303 -0
- alpha_engine_lib/model_pricing.yaml +73 -0
- alpha_engine_lib/pillars.py +756 -0
- alpha_engine_lib/pipeline_status/__init__.py +70 -0
- alpha_engine_lib/pipeline_status/read.py +541 -0
- alpha_engine_lib/pipeline_status/registry.py +368 -0
- alpha_engine_lib/pipeline_status/templates.py +120 -0
- alpha_engine_lib/preflight.py +444 -0
- alpha_engine_lib/rag/__init__.py +39 -0
- alpha_engine_lib/rag/db.py +96 -0
- alpha_engine_lib/rag/embeddings.py +63 -0
- alpha_engine_lib/rag/migrations/0001_content_tsv.sql +39 -0
- alpha_engine_lib/rag/rerank.py +377 -0
- alpha_engine_lib/rag/retrieval.py +465 -0
- alpha_engine_lib/rag/schema.sql +65 -0
- alpha_engine_lib/reconcile.py +203 -0
- alpha_engine_lib/secrets.py +186 -0
- alpha_engine_lib/sources/__init__.py +35 -0
- alpha_engine_lib/sources/protocols.py +227 -0
- alpha_engine_lib/ssm_log_capture.py +274 -0
- alpha_engine_lib/telegram.py +165 -0
- alpha_engine_lib/trading_calendar.py +236 -0
- alpha_engine_lib/transparency.py +746 -0
- alpha_engine_lib/transparency_inventory.yaml +260 -0
- alpha_engine_lib/universe.py +83 -0
- alpha_engine_lib-0.32.0.dist-info/METADATA +217 -0
- alpha_engine_lib-0.32.0.dist-info/RECORD +40 -0
- alpha_engine_lib-0.32.0.dist-info/WHEEL +5 -0
- alpha_engine_lib-0.32.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decision-artifact persistence schema for Alpha Engine agent capture.
|
|
3
|
+
|
|
4
|
+
Every LLM agent invocation across the Alpha Engine stack persists a
|
|
5
|
+
``DecisionArtifact`` to S3 at ``s3://alpha-engine-research/decision_artifacts/
|
|
6
|
+
{YYYY}/{MM}/{DD}/{agent_id}/{run_id}.json``. The artifact captures the full
|
|
7
|
+
prompt + input data snapshot + agent output + cost so each decision can be:
|
|
8
|
+
|
|
9
|
+
- replayed against a different model version (capability-delta measurement
|
|
10
|
+
vs Claude 5 / Sonnet 5 / etc. as frontier models ship);
|
|
11
|
+
- analyzed for rationale clustering (does this agent do varied work or
|
|
12
|
+
collapse to deterministic patterns?);
|
|
13
|
+
- judged for output quality (LLM-as-judge eval against held-out decisions);
|
|
14
|
+
- audited for cost regressions (token spend per agent over time).
|
|
15
|
+
|
|
16
|
+
**Public surface:**
|
|
17
|
+
|
|
18
|
+
- :class:`DecisionArtifact` — the schema (top-level capture record).
|
|
19
|
+
- :class:`ModelMetadata` / :class:`FullPromptContext` — nested metadata.
|
|
20
|
+
- :func:`truncate_snapshot` — 1MB cap enforcer for input snapshots.
|
|
21
|
+
- :func:`capture_decision` — construct a ``DecisionArtifact`` and write
|
|
22
|
+
it to S3. Hard-fails on S3 errors per ``feedback_no_silent_fails``.
|
|
23
|
+
- :exc:`DecisionCaptureWriteError` — raised by ``capture_decision`` on
|
|
24
|
+
S3 failure (do not swallow).
|
|
25
|
+
|
|
26
|
+
**Schema versioning rule:** ``schema_version`` accepts ``1`` (legacy
|
|
27
|
+
LLM-only artifacts, pre-2026-05-11) or ``2`` (current — adds support for
|
|
28
|
+
deterministic decisions with ``model_metadata = None`` and
|
|
29
|
+
``full_prompt_context = None``). New writes go out as v2. Reads accept
|
|
30
|
+
either. Fields are additive-only within a version; any rename or removal
|
|
31
|
+
would trigger ``schema_version=3``. Use :func:`is_llm_decision` to
|
|
32
|
+
discriminate LLM vs deterministic artifacts at read time.
|
|
33
|
+
|
|
34
|
+
**Compatibility posture:** the top-level ``DecisionArtifact`` model is
|
|
35
|
+
``extra="forbid"`` (the contract is locked); the per-agent ``input_data_snapshot``
|
|
36
|
+
and ``agent_output`` dicts use ``extra="allow"`` since their shapes vary by
|
|
37
|
+
agent. ``ModelMetadata`` and ``FullPromptContext`` lock down the cross-cutting
|
|
38
|
+
metadata fields.
|
|
39
|
+
|
|
40
|
+
Workstream design: ``alpha-engine-docs/private/alpha-engine-research-typed-
|
|
41
|
+
state-capture-260429.md``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
import logging
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
from typing import Any, Literal
|
|
50
|
+
|
|
51
|
+
import boto3
|
|
52
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
53
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Cross-cutting metadata ────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ModelMetadata(BaseModel):
|
|
62
|
+
"""Per-invocation model identifier + token cost + run/agent context.
|
|
63
|
+
|
|
64
|
+
Token counts are zero-defaulted because some agent paths don't track
|
|
65
|
+
cache reads/creates. ``cost_usd`` is a derived convenience: the load-
|
|
66
|
+
bearing facts are token counts (immutable) and the active price card
|
|
67
|
+
at the time of the call. Use :func:`alpha_engine_lib.cost.recompute_cost`
|
|
68
|
+
to recompute from token counts whenever the rate card changes — never
|
|
69
|
+
treat ``cost_usd`` as canonical for analytics.
|
|
70
|
+
|
|
71
|
+
The remaining fields propagate run + agent context through the cost
|
|
72
|
+
telemetry stream so that cost rows can be drilled down by agent,
|
|
73
|
+
sector team, run type, and prompt version. All optional — populated
|
|
74
|
+
by callers as the matching upstream features ship (prompt versioning
|
|
75
|
+
populates ``prompt_id`` + ``prompt_version``; the LangGraph node
|
|
76
|
+
wrapper populates ``node_name``; the run-orchestrator populates
|
|
77
|
+
``run_type`` + ``sector_team_id``).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
model_config = ConfigDict(extra="forbid")
|
|
81
|
+
|
|
82
|
+
model_name: str
|
|
83
|
+
model_version: str | None = None
|
|
84
|
+
input_tokens: int = Field(default=0, ge=0)
|
|
85
|
+
output_tokens: int = Field(default=0, ge=0)
|
|
86
|
+
cache_read_tokens: int = Field(default=0, ge=0)
|
|
87
|
+
cache_create_tokens: int = Field(default=0, ge=0)
|
|
88
|
+
# Server-tool request counts (Anthropic ``Message.usage.server_tool_use``).
|
|
89
|
+
# Distinct from token classes — these are flat per-request fees billed
|
|
90
|
+
# via :class:`alpha_engine_lib.cost.ToolFee`, not the per-1M-token rate
|
|
91
|
+
# on :class:`PriceCard`. Zero-defaulted so consumers that don't use
|
|
92
|
+
# server tools omit the field harmlessly. Additive within schema v2.
|
|
93
|
+
web_search_requests: int = Field(default=0, ge=0)
|
|
94
|
+
web_fetch_requests: int = Field(default=0, ge=0)
|
|
95
|
+
cost_usd: float = Field(default=0.0, ge=0.0)
|
|
96
|
+
run_type: Literal["weekly_research", "morning", "EOD"] | None = None
|
|
97
|
+
node_name: str | None = None
|
|
98
|
+
sector_team_id: str | None = None
|
|
99
|
+
prompt_id: str | None = None
|
|
100
|
+
prompt_version: str | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FullPromptContext(BaseModel):
|
|
104
|
+
"""Captures the full prompt the agent saw — system prompt, user prompt,
|
|
105
|
+
and any tool definitions registered for the call.
|
|
106
|
+
|
|
107
|
+
``prompt_version_hash`` ties the capture to a specific prompt revision
|
|
108
|
+
via the prompt-versioning pipeline (P2.3 in the workstream design).
|
|
109
|
+
None until prompt versioning ships, at which point the wrapper begins
|
|
110
|
+
populating it.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
model_config = ConfigDict(extra="forbid")
|
|
114
|
+
|
|
115
|
+
system_prompt: str
|
|
116
|
+
user_prompt: str
|
|
117
|
+
tool_definitions: list[dict] = Field(default_factory=list)
|
|
118
|
+
prompt_version_hash: str | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Top-level artifact ────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_INPUT_SNAPSHOT_DEFAULT_CAP_BYTES = 1_000_000 # 1 MB — see truncate_snapshot()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DecisionArtifact(BaseModel):
|
|
128
|
+
"""One captured agent decision — the substrate for replay, rationale
|
|
129
|
+
clustering, agent-justification, and LLM-as-judge eval.
|
|
130
|
+
|
|
131
|
+
Fields:
|
|
132
|
+
|
|
133
|
+
- ``schema_version``: ``1`` (legacy LLM-only) or ``2`` (current —
|
|
134
|
+
supports deterministic decisions with ``model_metadata=None`` +
|
|
135
|
+
``full_prompt_context=None``). New writes go out as v2.
|
|
136
|
+
- ``run_id``: unique per pipeline invocation; ties multiple agents'
|
|
137
|
+
artifacts together for one Saturday SF run or weekday morning run.
|
|
138
|
+
- ``timestamp``: ISO-8601 capture time (wall clock at the moment the
|
|
139
|
+
wrapper writes to S3).
|
|
140
|
+
- ``agent_id``: identifies which agent produced this — e.g.
|
|
141
|
+
``"sector_quant"``, ``"sector_qual"``, ``"macro_economist"``,
|
|
142
|
+
``"ic_cio"``, ``"executor:entry_triggers"``, ``"executor:risk_guard"``.
|
|
143
|
+
- ``model_metadata``: model + version + cost. ``None`` for
|
|
144
|
+
deterministic decisions (e.g. ``executor:*`` algorithmic agents).
|
|
145
|
+
Required-paired with ``full_prompt_context``: both present or both
|
|
146
|
+
``None``, never half-populated.
|
|
147
|
+
- ``full_prompt_context``: prompt + tool definitions. ``None`` for
|
|
148
|
+
deterministic decisions. Required-paired with ``model_metadata``.
|
|
149
|
+
- ``input_data_snapshot``: full input payload the agent saw at
|
|
150
|
+
decision time (market state, portfolio state, retrieved RAG chunks,
|
|
151
|
+
etc.). Truncated to fit ``_INPUT_SNAPSHOT_DEFAULT_CAP_BYTES`` if
|
|
152
|
+
pathologically large; ``input_data_truncated_at`` records the
|
|
153
|
+
pre-truncation byte size when truncation fires.
|
|
154
|
+
- ``input_data_summary``: deterministic human-readable view of the
|
|
155
|
+
snapshot (no LLM call). Populated by the wrapper from a typed
|
|
156
|
+
input model's ``__str__`` or equivalent. Not load-bearing for
|
|
157
|
+
replay — the full snapshot is.
|
|
158
|
+
- ``input_data_truncated_at``: byte size of the original snapshot
|
|
159
|
+
if truncation fired; None otherwise.
|
|
160
|
+
- ``agent_output``: serialized agent output dict (typed Pydantic
|
|
161
|
+
model dumped via ``model_dump()``). Includes reasoning, tool
|
|
162
|
+
calls, final structured decision. For deterministic decisions,
|
|
163
|
+
the decision verdict (chosen trigger / sizing / veto verdict).
|
|
164
|
+
|
|
165
|
+
Use :func:`is_llm_decision` to discriminate LLM vs deterministic at
|
|
166
|
+
read time — consumers (cost telemetry, replay harness, LLM-as-judge,
|
|
167
|
+
rationale clustering) gate on it to skip deterministic rows.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
model_config = ConfigDict(extra="forbid")
|
|
171
|
+
|
|
172
|
+
schema_version: Literal[1, 2] = 2
|
|
173
|
+
run_id: str
|
|
174
|
+
timestamp: str # ISO-8601, e.g. "2026-04-29T22:30:00.000Z"
|
|
175
|
+
agent_id: str
|
|
176
|
+
model_metadata: ModelMetadata | None = None
|
|
177
|
+
full_prompt_context: FullPromptContext | None = None
|
|
178
|
+
input_data_snapshot: dict[str, Any]
|
|
179
|
+
input_data_summary: str | None = None
|
|
180
|
+
input_data_truncated_at: int | None = Field(default=None, ge=0)
|
|
181
|
+
agent_output: dict[str, Any]
|
|
182
|
+
|
|
183
|
+
@model_validator(mode="after")
|
|
184
|
+
def _llm_fields_paired(self) -> "DecisionArtifact":
|
|
185
|
+
"""``model_metadata`` and ``full_prompt_context`` must both be
|
|
186
|
+
present (LLM agent) or both be ``None`` (deterministic decision).
|
|
187
|
+
|
|
188
|
+
Catches the silent-bug case where an LLM-agent producer accidentally
|
|
189
|
+
omits one of the two fields. Deterministic-producer callers pass
|
|
190
|
+
``None`` for both, explicitly, by convention.
|
|
191
|
+
"""
|
|
192
|
+
if (self.model_metadata is None) != (self.full_prompt_context is None):
|
|
193
|
+
raise ValueError(
|
|
194
|
+
"model_metadata and full_prompt_context must both be "
|
|
195
|
+
"present (LLM agent) or both be None (deterministic "
|
|
196
|
+
"decision); got "
|
|
197
|
+
f"model_metadata={'set' if self.model_metadata is not None else 'None'}, "
|
|
198
|
+
f"full_prompt_context={'set' if self.full_prompt_context is not None else 'None'}."
|
|
199
|
+
)
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def is_llm_decision(artifact: DecisionArtifact) -> bool:
|
|
204
|
+
"""True iff ``artifact`` was produced by an LLM agent (vs a deterministic
|
|
205
|
+
decision).
|
|
206
|
+
|
|
207
|
+
Consumers gate on this to skip rows they can't or shouldn't process:
|
|
208
|
+
|
|
209
|
+
- **Cost telemetry** — skips deterministic rows ($0 cost, 0 tokens).
|
|
210
|
+
- **Replay harness** — deterministic decisions don't replay through an
|
|
211
|
+
LLM model; dispatch to a different replay path (or skip).
|
|
212
|
+
- **LLM-as-judge** — judge has nothing to evaluate without a prompt.
|
|
213
|
+
- **Rationale clustering** — clustering needs the reasoning text inside
|
|
214
|
+
``agent_output``, which deterministic rows don't carry.
|
|
215
|
+
|
|
216
|
+
Discrimination rule: ``model_metadata is not None``. The
|
|
217
|
+
:meth:`DecisionArtifact._llm_fields_paired` validator guarantees that
|
|
218
|
+
``model_metadata`` and ``full_prompt_context`` are both present or both
|
|
219
|
+
``None``, so either field is sufficient to decide.
|
|
220
|
+
"""
|
|
221
|
+
return artifact.model_metadata is not None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ── Truncation helper for the 1MB cap ─────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _serialized_size(payload: dict) -> int:
|
|
228
|
+
"""JSON-serialize and return the byte length. Used as the size metric
|
|
229
|
+
for the cap check (matches what S3 will actually store)."""
|
|
230
|
+
return len(json.dumps(payload, default=str).encode("utf-8"))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def truncate_snapshot(
|
|
234
|
+
payload: dict,
|
|
235
|
+
cap_bytes: int = _INPUT_SNAPSHOT_DEFAULT_CAP_BYTES,
|
|
236
|
+
) -> tuple[dict, int | None]:
|
|
237
|
+
"""Truncate ``payload`` to fit under ``cap_bytes`` when serialized to JSON.
|
|
238
|
+
|
|
239
|
+
Strategy:
|
|
240
|
+
|
|
241
|
+
1. If serialized size ≤ ``cap_bytes`` → return ``(payload, None)``.
|
|
242
|
+
No truncation marker.
|
|
243
|
+
2. Otherwise, repeatedly drop the largest top-level field by size and
|
|
244
|
+
replace it with a ``{"_truncated": True, "original_field": <name>,
|
|
245
|
+
"original_size_bytes": <N>}`` marker until under cap.
|
|
246
|
+
3. Return ``(truncated_payload, original_size)`` where ``original_size``
|
|
247
|
+
is the pre-truncation byte size — the wrapper stores this on
|
|
248
|
+
``DecisionArtifact.input_data_truncated_at`` so consumers can detect
|
|
249
|
+
truncation.
|
|
250
|
+
|
|
251
|
+
Replay correctness note: a truncated artifact loses the dropped fields,
|
|
252
|
+
so replay against a different model would feed it different inputs and
|
|
253
|
+
produce non-comparable output. The truncation marker is a flag for
|
|
254
|
+
consumers to skip replay on truncated artifacts (or fall back to the
|
|
255
|
+
summary view). Steady-state agent inputs (sector_quant ~10-50KB,
|
|
256
|
+
macro_economist ~20-50KB, sector_qual ~300-400KB) are well under the
|
|
257
|
+
1 MB default cap; truncation is a safety net for pathological cases,
|
|
258
|
+
not the steady-state path.
|
|
259
|
+
"""
|
|
260
|
+
original_size = _serialized_size(payload)
|
|
261
|
+
if original_size <= cap_bytes:
|
|
262
|
+
return payload, None
|
|
263
|
+
|
|
264
|
+
# Truncation: drop largest top-level field, replace with marker, repeat.
|
|
265
|
+
truncated = dict(payload)
|
|
266
|
+
while _serialized_size(truncated) > cap_bytes:
|
|
267
|
+
# Find largest top-level field by serialized size.
|
|
268
|
+
sized_fields = [
|
|
269
|
+
(k, _serialized_size({k: v}))
|
|
270
|
+
for k, v in truncated.items()
|
|
271
|
+
if not (isinstance(v, dict) and v.get("_truncated") is True)
|
|
272
|
+
]
|
|
273
|
+
if not sized_fields:
|
|
274
|
+
# Every field is already a truncation marker; pathological case.
|
|
275
|
+
# Replace whole payload with a single marker.
|
|
276
|
+
truncated = {
|
|
277
|
+
"_truncated": True,
|
|
278
|
+
"reason": "exceeded_cap_after_full_field_drop",
|
|
279
|
+
"original_size_bytes": original_size,
|
|
280
|
+
"cap_bytes": cap_bytes,
|
|
281
|
+
}
|
|
282
|
+
break
|
|
283
|
+
sized_fields.sort(key=lambda x: -x[1]) # largest first
|
|
284
|
+
largest_field, largest_size = sized_fields[0]
|
|
285
|
+
truncated[largest_field] = {
|
|
286
|
+
"_truncated": True,
|
|
287
|
+
"original_field": largest_field,
|
|
288
|
+
"original_size_bytes": largest_size,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return truncated, original_size
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── Capture wrapper (constructs DecisionArtifact + writes to S3) ──────────
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
_DEFAULT_S3_BUCKET = "alpha-engine-research"
|
|
298
|
+
_DEFAULT_S3_PREFIX = "decision_artifacts"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class DecisionCaptureWriteError(RuntimeError):
|
|
302
|
+
"""Raised when the S3 write of a captured ``DecisionArtifact`` fails.
|
|
303
|
+
|
|
304
|
+
Per ``feedback_no_silent_fails``, the capture path does not swallow
|
|
305
|
+
S3 errors — every captured artifact must land or the run hard-fails.
|
|
306
|
+
Callers that wish to make capture best-effort should catch this
|
|
307
|
+
exception explicitly at the call site (and add a CloudWatch metric
|
|
308
|
+
so silent capture loss is observable).
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _build_s3_key(
|
|
313
|
+
*,
|
|
314
|
+
s3_prefix: str,
|
|
315
|
+
capture_dt: datetime,
|
|
316
|
+
agent_id: str,
|
|
317
|
+
run_id: str,
|
|
318
|
+
) -> str:
|
|
319
|
+
"""Compute the canonical S3 key for a captured artifact.
|
|
320
|
+
|
|
321
|
+
Format: ``{s3_prefix}/{YYYY}/{MM}/{DD}/{agent_id}/{run_id}.json``
|
|
322
|
+
|
|
323
|
+
Date-partitioned by capture date (UTC) so consumers (replay harness,
|
|
324
|
+
rationale clustering, LLM-as-judge) can window by day cheaply.
|
|
325
|
+
Per-agent partitioning lets a single agent's corpus be queried
|
|
326
|
+
independently. ``run_id`` is the leaf so all artifacts from one
|
|
327
|
+
pipeline run can be discovered by listing the directory.
|
|
328
|
+
"""
|
|
329
|
+
yyyy = capture_dt.strftime("%Y")
|
|
330
|
+
mm = capture_dt.strftime("%m")
|
|
331
|
+
dd = capture_dt.strftime("%d")
|
|
332
|
+
return f"{s3_prefix}/{yyyy}/{mm}/{dd}/{agent_id}/{run_id}.json"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def capture_decision(
|
|
336
|
+
*,
|
|
337
|
+
run_id: str,
|
|
338
|
+
agent_id: str,
|
|
339
|
+
model_metadata: ModelMetadata | None,
|
|
340
|
+
full_prompt_context: FullPromptContext | None,
|
|
341
|
+
input_data_snapshot: dict[str, Any],
|
|
342
|
+
agent_output: dict[str, Any],
|
|
343
|
+
input_data_summary: str | None = None,
|
|
344
|
+
s3_bucket: str = _DEFAULT_S3_BUCKET,
|
|
345
|
+
s3_prefix: str = _DEFAULT_S3_PREFIX,
|
|
346
|
+
s3_client: Any | None = None,
|
|
347
|
+
timestamp: datetime | None = None,
|
|
348
|
+
snapshot_cap_bytes: int = _INPUT_SNAPSHOT_DEFAULT_CAP_BYTES,
|
|
349
|
+
) -> str:
|
|
350
|
+
"""Construct a :class:`DecisionArtifact` and write it to S3.
|
|
351
|
+
|
|
352
|
+
Returns the S3 key the artifact was written to. Raises
|
|
353
|
+
:exc:`DecisionCaptureWriteError` on any S3 failure — the caller MUST
|
|
354
|
+
NOT swallow this silently per ``feedback_no_silent_fails``.
|
|
355
|
+
|
|
356
|
+
Parameters
|
|
357
|
+
----------
|
|
358
|
+
run_id
|
|
359
|
+
Unique-per-pipeline-invocation identifier. Ties artifacts from
|
|
360
|
+
multiple agents in one run together.
|
|
361
|
+
agent_id
|
|
362
|
+
Identifies which agent produced this — e.g. ``"sector_quant"``,
|
|
363
|
+
``"sector_qual"``, ``"macro_economist"``, ``"ic_cio"``,
|
|
364
|
+
``"executor:entry_triggers"``, ``"executor:risk_guard"``.
|
|
365
|
+
model_metadata
|
|
366
|
+
Model identifier + token counts + cost. Pass ``None`` for
|
|
367
|
+
deterministic decisions (e.g. ``executor:*`` algorithmic agents).
|
|
368
|
+
Must be paired with ``full_prompt_context`` — both ``None`` or
|
|
369
|
+
both populated; half-populated raises.
|
|
370
|
+
full_prompt_context
|
|
371
|
+
System prompt + user prompt + tool definitions seen by the agent.
|
|
372
|
+
Pass ``None`` for deterministic decisions; paired with
|
|
373
|
+
``model_metadata``.
|
|
374
|
+
input_data_snapshot
|
|
375
|
+
Full input payload — load-bearing for replay correctness. Will
|
|
376
|
+
be truncated to ``snapshot_cap_bytes`` if oversized; truncation
|
|
377
|
+
is recorded in ``DecisionArtifact.input_data_truncated_at``.
|
|
378
|
+
agent_output
|
|
379
|
+
Serialized agent output dict (typed Pydantic model dumped via
|
|
380
|
+
``model_dump()``).
|
|
381
|
+
input_data_summary
|
|
382
|
+
Optional human-readable summary derived deterministically from
|
|
383
|
+
the typed input model. Not load-bearing for replay; useful for
|
|
384
|
+
dashboards + click-through views.
|
|
385
|
+
s3_bucket, s3_prefix
|
|
386
|
+
S3 location overrides. Defaults to
|
|
387
|
+
``s3://alpha-engine-research/decision_artifacts/`` per the
|
|
388
|
+
workstream design.
|
|
389
|
+
s3_client
|
|
390
|
+
For testing — pass a moto-mocked or otherwise stubbed boto3 S3
|
|
391
|
+
client. Defaults to ``boto3.client("s3")`` if not provided.
|
|
392
|
+
timestamp
|
|
393
|
+
For testing — pass a fixed UTC datetime. Defaults to
|
|
394
|
+
``datetime.now(timezone.utc)`` if not provided.
|
|
395
|
+
snapshot_cap_bytes
|
|
396
|
+
Override the default 1MB truncation cap.
|
|
397
|
+
|
|
398
|
+
Returns
|
|
399
|
+
-------
|
|
400
|
+
str
|
|
401
|
+
The S3 key the artifact was written to (e.g.
|
|
402
|
+
``"decision_artifacts/2026/04/29/sector_quant/run-abc123.json"``).
|
|
403
|
+
|
|
404
|
+
Raises
|
|
405
|
+
------
|
|
406
|
+
DecisionCaptureWriteError
|
|
407
|
+
If the S3 ``PutObject`` call fails for any reason
|
|
408
|
+
(BotoCoreError, ClientError including AccessDenied, etc.).
|
|
409
|
+
"""
|
|
410
|
+
capture_dt = timestamp if timestamp is not None else datetime.now(timezone.utc)
|
|
411
|
+
|
|
412
|
+
# 1. Apply truncation cap on the snapshot.
|
|
413
|
+
snapshot_for_artifact, truncated_at = truncate_snapshot(
|
|
414
|
+
input_data_snapshot, cap_bytes=snapshot_cap_bytes,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# 2. Construct the artifact (Pydantic validates).
|
|
418
|
+
artifact = DecisionArtifact(
|
|
419
|
+
run_id=run_id,
|
|
420
|
+
timestamp=capture_dt.isoformat(),
|
|
421
|
+
agent_id=agent_id,
|
|
422
|
+
model_metadata=model_metadata,
|
|
423
|
+
full_prompt_context=full_prompt_context,
|
|
424
|
+
input_data_snapshot=snapshot_for_artifact,
|
|
425
|
+
input_data_summary=input_data_summary,
|
|
426
|
+
input_data_truncated_at=truncated_at,
|
|
427
|
+
agent_output=agent_output,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# 3. Compute S3 key + serialize.
|
|
431
|
+
s3_key = _build_s3_key(
|
|
432
|
+
s3_prefix=s3_prefix,
|
|
433
|
+
capture_dt=capture_dt,
|
|
434
|
+
agent_id=agent_id,
|
|
435
|
+
run_id=run_id,
|
|
436
|
+
)
|
|
437
|
+
body = artifact.model_dump_json().encode("utf-8")
|
|
438
|
+
|
|
439
|
+
# 4. Write to S3 — hard-fail on any error.
|
|
440
|
+
client = s3_client if s3_client is not None else boto3.client("s3")
|
|
441
|
+
try:
|
|
442
|
+
client.put_object(
|
|
443
|
+
Bucket=s3_bucket,
|
|
444
|
+
Key=s3_key,
|
|
445
|
+
Body=body,
|
|
446
|
+
ContentType="application/json",
|
|
447
|
+
)
|
|
448
|
+
except (BotoCoreError, ClientError) as exc:
|
|
449
|
+
raise DecisionCaptureWriteError(
|
|
450
|
+
f"Failed to write decision artifact to "
|
|
451
|
+
f"s3://{s3_bucket}/{s3_key}: {exc}"
|
|
452
|
+
) from exc
|
|
453
|
+
|
|
454
|
+
if truncated_at is not None:
|
|
455
|
+
logger.warning(
|
|
456
|
+
"[decision_capture:%s] artifact for run_id=%s truncated "
|
|
457
|
+
"(original size %d bytes > cap %d bytes); replay correctness "
|
|
458
|
+
"may be reduced for this artifact",
|
|
459
|
+
agent_id, run_id, truncated_at, snapshot_cap_bytes,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
return s3_key
|