atif-sql 0.2.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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Inline base64 attachments -> content-addressed blobs plus a short text placeholder.
|
|
4
|
+
|
|
5
|
+
Claude Code writes every image a tool returned (a ``Read`` of a PNG, a
|
|
6
|
+
screenshot) and every image a user pasted into the transcript as base64, and
|
|
7
|
+
the conversion used to carry that text all the way into ``trajectory.json``
|
|
8
|
+
and the ``tool_results.content`` column, where it was about half of all tool
|
|
9
|
+
result text. Anything reading that text (embeddings, an LLM prompt, a regex)
|
|
10
|
+
paid for megabytes of noise per image.
|
|
11
|
+
|
|
12
|
+
This module lifts the bytes out BEFORE conversion. Each attachment is decoded
|
|
13
|
+
once, named by the SHA-256 of its bytes, handed to the caller as a
|
|
14
|
+
:class:`Blob` to store, and replaced in the raw record by a short, stable
|
|
15
|
+
placeholder::
|
|
16
|
+
|
|
17
|
+
[image sha256:<64 hex> image/png 123456 bytes]
|
|
18
|
+
|
|
19
|
+
so the converter, the enrichment pass and every text consumer see the
|
|
20
|
+
placeholder and never the base64. The rewrite runs on the parsed records the
|
|
21
|
+
converter and the audit share (one read, see
|
|
22
|
+
:mod:`atif_converter.infrastructure.raw_records`), so every artifact agrees
|
|
23
|
+
about it.
|
|
24
|
+
|
|
25
|
+
WHICH SHAPES. Measured over the local transcripts on 2026-09-27, three Claude
|
|
26
|
+
Code shapes carry base64 and each is handled:
|
|
27
|
+
|
|
28
|
+
* a ``tool_result`` block's content item
|
|
29
|
+
``{"type": "image"|"document", "source": {"type": "base64", ...}}``
|
|
30
|
+
(``Read`` results, 10,798 of them);
|
|
31
|
+
* the same block shape directly in a user message's content (pasted images
|
|
32
|
+
and PDFs);
|
|
33
|
+
* ``toolUseResult.file.base64`` on the ``Read`` record, the harness's own copy
|
|
34
|
+
of the same file, which the converter used to dump into the result text a
|
|
35
|
+
second time as ``[metadata]``.
|
|
36
|
+
|
|
37
|
+
In a Codex rollout the shape is ``{"type": "input_image", "image_url":
|
|
38
|
+
"data:<media>;base64,<data>"}`` inside a ``response_item`` message's content
|
|
39
|
+
or a tool output list. A tool output list can also carry an MCP image block,
|
|
40
|
+
``{"type": "image", "data": "<base64>", "mimeType": "image/png"}``. The
|
|
41
|
+
converter's harbor port names every inline image in a tool output it can save
|
|
42
|
+
by a placeholder (:func:`codex_tool_image_ref` is its rule: a supported image
|
|
43
|
+
type, a payload that decodes once whitespace is removed, data URL parameters
|
|
44
|
+
allowed), so a tool output's MCP image, and an ``input_image`` the strict data
|
|
45
|
+
URL match above misses, is lifted by that same rule and the placeholder names
|
|
46
|
+
bytes the collector holds. A base64 value anywhere else (a tool CALL's own
|
|
47
|
+
arguments, an ``image`` block in message content) is left alone.
|
|
48
|
+
|
|
49
|
+
Replacement is by block: an attachment block becomes a TEXT block holding the
|
|
50
|
+
placeholder (``input_text`` for Codex), which is the one block type every
|
|
51
|
+
downstream text rule already renders verbatim. A value that does not decode as
|
|
52
|
+
strict base64 is left exactly as it was: a malformed attachment is kept, never
|
|
53
|
+
silently dropped.
|
|
54
|
+
|
|
55
|
+
Pure: no I/O. The one library call is :mod:`imagesize`, which reads the width
|
|
56
|
+
and height out of the image header in memory.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import base64
|
|
62
|
+
import binascii
|
|
63
|
+
import hashlib
|
|
64
|
+
import io
|
|
65
|
+
import re
|
|
66
|
+
from dataclasses import dataclass, field
|
|
67
|
+
from typing import TYPE_CHECKING, Any
|
|
68
|
+
|
|
69
|
+
import imagesize # type: ignore[import-untyped]
|
|
70
|
+
from loguru import logger
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from collections.abc import Iterable, Sequence
|
|
74
|
+
|
|
75
|
+
#: File extension per media type; anything else is stored as ``.bin``.
|
|
76
|
+
_EXTENSIONS: dict[str, str] = {
|
|
77
|
+
"image/png": "png",
|
|
78
|
+
"image/jpeg": "jpg",
|
|
79
|
+
"image/gif": "gif",
|
|
80
|
+
"image/webp": "webp",
|
|
81
|
+
"application/pdf": "pdf",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#: A media type is kept only when it looks like one; the placeholder and the
|
|
85
|
+
#: typed columns must not carry arbitrary transcript text.
|
|
86
|
+
_MEDIA_TYPE_RE = re.compile(r"^[a-z]+/[a-z0-9][a-z0-9.+-]{0,126}$")
|
|
87
|
+
|
|
88
|
+
#: The fallback media type for an attachment whose declared type is missing or malformed.
|
|
89
|
+
_OCTET_STREAM = "application/octet-stream"
|
|
90
|
+
|
|
91
|
+
#: A Codex data URL: ``data:<media>;base64,<payload>``.
|
|
92
|
+
_DATA_URL_RE = re.compile(r"^data:([^;,]{1,128});base64,", re.ASCII)
|
|
93
|
+
|
|
94
|
+
#: The image types harbor 0.24.0 saves from a Codex tool output
|
|
95
|
+
#: (``Codex._image_media_type``); any other type is ``[image omitted]`` there.
|
|
96
|
+
CODEX_TOOL_IMAGE_MEDIA_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
|
|
97
|
+
|
|
98
|
+
#: harbor's data URL for a Codex tool output image: parameters allowed before ``;base64``.
|
|
99
|
+
_TOOL_IMAGE_DATA_URL = re.compile(
|
|
100
|
+
r"data:(?P<media_type>[^;,]+)(?:;[^;,]*)*;base64,(?P<data>.*)", re.DOTALL
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class BlobRef:
|
|
106
|
+
"""What a trajectory keeps of one attachment once its bytes are stored."""
|
|
107
|
+
|
|
108
|
+
#: Lowercase hex SHA-256 of the decoded bytes: the blob's name.
|
|
109
|
+
sha256: str
|
|
110
|
+
#: The declared media type (``image/png``), or ``application/octet-stream``.
|
|
111
|
+
media_type: str
|
|
112
|
+
#: Decoded length in bytes.
|
|
113
|
+
byte_count: int
|
|
114
|
+
#: Pixel width from the image header, when the format is one it can read.
|
|
115
|
+
width: int | None = None
|
|
116
|
+
#: Pixel height, same rule.
|
|
117
|
+
height: int | None = None
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def extension(self) -> str:
|
|
121
|
+
"""The stored file's extension (``png``, ``jpg``, ..., ``bin``)."""
|
|
122
|
+
return _EXTENSIONS.get(self.media_type, "bin")
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_image(self) -> bool:
|
|
126
|
+
"""True for an ``image/*`` media type."""
|
|
127
|
+
return self.media_type.startswith("image/")
|
|
128
|
+
|
|
129
|
+
def placeholder(self) -> str:
|
|
130
|
+
"""The stable text left where the base64 was."""
|
|
131
|
+
kind = "image" if self.is_image else "file"
|
|
132
|
+
return f"[{kind} sha256:{self.sha256} {self.media_type} {self.byte_count} bytes]"
|
|
133
|
+
|
|
134
|
+
def to_json(self) -> dict[str, Any]:
|
|
135
|
+
"""The ``extra.images[]`` entry the enrichment pass writes into the trajectory."""
|
|
136
|
+
return {
|
|
137
|
+
"sha256": self.sha256,
|
|
138
|
+
"media_type": self.media_type,
|
|
139
|
+
"bytes": self.byte_count,
|
|
140
|
+
"width": self.width,
|
|
141
|
+
"height": self.height,
|
|
142
|
+
"extension": self.extension,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class Blob:
|
|
148
|
+
"""One attachment's bytes, to be written to the corpus blob store under ``ref``."""
|
|
149
|
+
|
|
150
|
+
ref: BlobRef
|
|
151
|
+
data: bytes
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _media_type(value: Any) -> str:
|
|
155
|
+
lowered = str(value).strip().lower() if isinstance(value, str) else ""
|
|
156
|
+
return lowered if _MEDIA_TYPE_RE.match(lowered) else _OCTET_STREAM
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _dimensions(data: bytes) -> tuple[int | None, int | None]:
|
|
160
|
+
try:
|
|
161
|
+
width, height = imagesize.get(io.BytesIO(data))
|
|
162
|
+
except (ValueError, OSError, IndexError) as exc: # a truncated or odd header
|
|
163
|
+
logger.debug("blobs: no dimensions from image header: {}", exc)
|
|
164
|
+
return None, None
|
|
165
|
+
# imagesize answers (-1, -1) for a format it cannot read.
|
|
166
|
+
if width < 0 or height < 0:
|
|
167
|
+
return None, None
|
|
168
|
+
return width, height
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(slots=True)
|
|
172
|
+
class BlobCollector:
|
|
173
|
+
"""Accumulates one session's decoded attachments, deduplicated by hash."""
|
|
174
|
+
|
|
175
|
+
_blobs: dict[str, Blob] = field(default_factory=dict)
|
|
176
|
+
#: How many attachments were replaced (a repeated image counts every time).
|
|
177
|
+
references: int = 0
|
|
178
|
+
|
|
179
|
+
def add_base64(self, text: str, media_type: Any) -> BlobRef | None:
|
|
180
|
+
"""Decode ``text`` and register it; ``None`` when it is not strict base64.
|
|
181
|
+
|
|
182
|
+
Strict means :func:`base64.b64decode` with ``validate=True``: a value
|
|
183
|
+
carrying anything outside the base64 alphabet is not an attachment
|
|
184
|
+
this module understands, and is left in place by the caller.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
data = base64.b64decode(text, validate=True)
|
|
188
|
+
except (binascii.Error, ValueError):
|
|
189
|
+
return None
|
|
190
|
+
if not data:
|
|
191
|
+
return None
|
|
192
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
193
|
+
existing = self._blobs.get(digest)
|
|
194
|
+
declared = _media_type(media_type)
|
|
195
|
+
if existing is not None and existing.ref.media_type == declared:
|
|
196
|
+
self.references += 1
|
|
197
|
+
return existing.ref
|
|
198
|
+
width, height = _dimensions(data) if declared.startswith("image/") else (None, None)
|
|
199
|
+
ref = BlobRef(
|
|
200
|
+
sha256=digest,
|
|
201
|
+
media_type=declared,
|
|
202
|
+
byte_count=len(data),
|
|
203
|
+
width=width,
|
|
204
|
+
height=height,
|
|
205
|
+
)
|
|
206
|
+
# The same bytes declared under two media types keep the first one's
|
|
207
|
+
# blob; both refs name the same content, which is what the hash promises.
|
|
208
|
+
self._blobs.setdefault(digest, Blob(ref=ref, data=data))
|
|
209
|
+
self.references += 1
|
|
210
|
+
return ref
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def blobs(self) -> tuple[Blob, ...]:
|
|
214
|
+
"""Every distinct blob, ordered by hash so the output is deterministic."""
|
|
215
|
+
return tuple(self._blobs[key] for key in sorted(self._blobs))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(slots=True)
|
|
219
|
+
class BlobIndex:
|
|
220
|
+
"""Where each replaced attachment sat, so enrichment can type it per row."""
|
|
221
|
+
|
|
222
|
+
#: Tool call id -> the attachments in that call's result, in order, deduplicated.
|
|
223
|
+
by_tool_call_id: dict[str, list[BlobRef]] = field(default_factory=dict)
|
|
224
|
+
#: Record key (Claude Code ``uuid``; Codex edge uuid) -> attachments in
|
|
225
|
+
#: that record's own message content (not inside a tool result).
|
|
226
|
+
by_record_key: dict[str, list[BlobRef]] = field(default_factory=dict)
|
|
227
|
+
|
|
228
|
+
def add_tool_result(self, call_id: Any, refs: Sequence[BlobRef]) -> None:
|
|
229
|
+
"""Attach ``refs`` to one tool call's result."""
|
|
230
|
+
if not isinstance(call_id, str) or not call_id or not refs:
|
|
231
|
+
return
|
|
232
|
+
_extend_unique(self.by_tool_call_id.setdefault(call_id, []), refs)
|
|
233
|
+
|
|
234
|
+
def add_record(self, key: Any, refs: Sequence[BlobRef]) -> None:
|
|
235
|
+
"""Attach ``refs`` to one record's message content."""
|
|
236
|
+
if not isinstance(key, str) or not key or not refs:
|
|
237
|
+
return
|
|
238
|
+
_extend_unique(self.by_record_key.setdefault(key, []), refs)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _extend_unique(target: list[BlobRef], refs: Iterable[BlobRef]) -> None:
|
|
242
|
+
seen = {ref.sha256 for ref in target}
|
|
243
|
+
for ref in refs:
|
|
244
|
+
if ref.sha256 not in seen:
|
|
245
|
+
target.append(ref)
|
|
246
|
+
seen.add(ref.sha256)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Claude Code
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _anthropic_block_ref(block: Any, collector: BlobCollector) -> BlobRef | None:
|
|
255
|
+
"""The ref for an ``image``/``document`` block with a base64 source, else ``None``."""
|
|
256
|
+
if not isinstance(block, dict) or _str(block.get("type")) not in {"image", "document"}:
|
|
257
|
+
return None
|
|
258
|
+
source = block.get("source")
|
|
259
|
+
if not isinstance(source, dict) or source.get("type") != "base64":
|
|
260
|
+
return None
|
|
261
|
+
data = source.get("data")
|
|
262
|
+
if not isinstance(data, str):
|
|
263
|
+
return None
|
|
264
|
+
return collector.add_base64(data, source.get("media_type"))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _rewrite_anthropic_blocks(blocks: list[Any], collector: BlobCollector) -> list[BlobRef]:
|
|
268
|
+
"""Replace every base64 block in ``blocks`` in place; return the refs in order."""
|
|
269
|
+
refs: list[BlobRef] = []
|
|
270
|
+
for index, block in enumerate(blocks):
|
|
271
|
+
ref = _anthropic_block_ref(block, collector)
|
|
272
|
+
if ref is None:
|
|
273
|
+
continue
|
|
274
|
+
blocks[index] = {"type": "text", "text": ref.placeholder()}
|
|
275
|
+
refs.append(ref)
|
|
276
|
+
return refs
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _rewrite_tool_use_result(tool_use_result: Any, collector: BlobCollector) -> list[BlobRef]:
|
|
280
|
+
"""Replace ``toolUseResult.file.base64`` (the ``Read`` shape) with a ``blob`` placeholder."""
|
|
281
|
+
if not isinstance(tool_use_result, dict):
|
|
282
|
+
return []
|
|
283
|
+
file_value = tool_use_result.get("file")
|
|
284
|
+
if not isinstance(file_value, dict):
|
|
285
|
+
return []
|
|
286
|
+
data = file_value.get("base64")
|
|
287
|
+
if not isinstance(data, str):
|
|
288
|
+
return []
|
|
289
|
+
media_type = file_value.get("type")
|
|
290
|
+
if not isinstance(media_type, str) and tool_use_result.get("type") == "pdf":
|
|
291
|
+
media_type = "application/pdf"
|
|
292
|
+
ref = collector.add_base64(data, media_type)
|
|
293
|
+
if ref is None:
|
|
294
|
+
return []
|
|
295
|
+
del file_value["base64"]
|
|
296
|
+
file_value["blob"] = ref.placeholder()
|
|
297
|
+
return [ref]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def extract_claude_code_blobs(records: Iterable[Any], collector: BlobCollector) -> BlobIndex:
|
|
301
|
+
"""Rewrite every inline attachment in ``records`` IN PLACE; index where each one was.
|
|
302
|
+
|
|
303
|
+
``records`` are the session's parsed records (every file, main and side),
|
|
304
|
+
the same objects the converter and the audit will read next.
|
|
305
|
+
"""
|
|
306
|
+
index = BlobIndex()
|
|
307
|
+
for record in records:
|
|
308
|
+
if not isinstance(record, dict):
|
|
309
|
+
continue
|
|
310
|
+
message = record.get("message")
|
|
311
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
312
|
+
result_ids: list[str] = []
|
|
313
|
+
if isinstance(content, list):
|
|
314
|
+
own_refs = _rewrite_anthropic_blocks(content, collector)
|
|
315
|
+
index.add_record(record.get("uuid"), own_refs)
|
|
316
|
+
for block in content:
|
|
317
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
318
|
+
continue
|
|
319
|
+
call_id = block.get("tool_use_id")
|
|
320
|
+
if isinstance(call_id, str) and call_id:
|
|
321
|
+
result_ids.append(call_id)
|
|
322
|
+
inner = block.get("content")
|
|
323
|
+
if isinstance(inner, list):
|
|
324
|
+
index.add_tool_result(call_id, _rewrite_anthropic_blocks(inner, collector))
|
|
325
|
+
file_refs = _rewrite_tool_use_result(record.get("toolUseResult"), collector)
|
|
326
|
+
# ``toolUseResult`` describes the record's one tool result. A record
|
|
327
|
+
# carrying several results cannot say which one it belongs to, so its
|
|
328
|
+
# attachment is still replaced but attributed to none of them.
|
|
329
|
+
if file_refs and len(result_ids) == 1:
|
|
330
|
+
index.add_tool_result(result_ids[0], file_refs)
|
|
331
|
+
return index
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# Codex
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def codex_tool_image_media_type(value: str) -> str | None:
|
|
340
|
+
"""The supported image MIME type for ``value``, as harbor's ``Codex._image_media_type`` maps it."""
|
|
341
|
+
media_type = value.strip().lower()
|
|
342
|
+
if media_type == "image/jpg":
|
|
343
|
+
media_type = "image/jpeg"
|
|
344
|
+
return media_type if media_type in CODEX_TOOL_IMAGE_MEDIA_TYPES else None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def codex_tool_image_ref(data: str, media_type: Any, collector: BlobCollector) -> BlobRef | None:
|
|
348
|
+
"""Register one inline Codex tool output image the way harbor 0.24.0 would save it.
|
|
349
|
+
|
|
350
|
+
``data`` is base64 or a ``data:`` URL (whose media type then wins over
|
|
351
|
+
``media_type``); harbor saves it only when the media type is one of
|
|
352
|
+
:data:`CODEX_TOOL_IMAGE_MEDIA_TYPES` and the payload, with whitespace
|
|
353
|
+
removed, decodes. ``None`` means harbor would write ``[image omitted]``:
|
|
354
|
+
nothing is registered. The converter's port and the pre-pass both call
|
|
355
|
+
this, so the placeholder text and the stored blob agree byte for byte.
|
|
356
|
+
"""
|
|
357
|
+
data_url = _TOOL_IMAGE_DATA_URL.fullmatch(data)
|
|
358
|
+
base64_text = data
|
|
359
|
+
if data_url is not None:
|
|
360
|
+
media_type, base64_text = data_url.group("media_type"), str(data_url.group("data"))
|
|
361
|
+
if not isinstance(media_type, str):
|
|
362
|
+
return None
|
|
363
|
+
valid_media_type = codex_tool_image_media_type(media_type)
|
|
364
|
+
if valid_media_type is None:
|
|
365
|
+
return None
|
|
366
|
+
return collector.add_base64("".join(base64_text.split()), valid_media_type)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _mcp_image_ref(item: dict[str, Any], collector: BlobCollector) -> BlobRef | None:
|
|
370
|
+
"""An MCP ``image`` block's ref, read the way the converter's port reads it."""
|
|
371
|
+
data = item.get("data")
|
|
372
|
+
if not isinstance(data, str) or ("://" in data and not data.startswith("data:")):
|
|
373
|
+
return None
|
|
374
|
+
return codex_tool_image_ref(data, item.get("mimeType") or item.get("mime_type"), collector)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _tool_input_image_ref(item: dict[str, Any], collector: BlobCollector) -> BlobRef | None:
|
|
378
|
+
"""A tool output ``input_image``'s ref by the port's rule (see :func:`codex_tool_image_ref`)."""
|
|
379
|
+
url = item.get("image_url")
|
|
380
|
+
if not isinstance(url, str) or ("://" in url and not url.startswith("data:")):
|
|
381
|
+
return None
|
|
382
|
+
return codex_tool_image_ref(url, None, collector)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _input_image_ref(item: dict[str, Any], collector: BlobCollector) -> BlobRef | None:
|
|
386
|
+
"""An ``input_image`` item's ref when its ``image_url`` is a base64 data URL."""
|
|
387
|
+
url = item.get("image_url")
|
|
388
|
+
if not isinstance(url, str):
|
|
389
|
+
return None
|
|
390
|
+
match = _DATA_URL_RE.match(url)
|
|
391
|
+
if match is None:
|
|
392
|
+
return None
|
|
393
|
+
return collector.add_base64(url[match.end() :], match.group(1))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _codex_image_ref(item: Any, collector: BlobCollector, *, tool_output: bool) -> BlobRef | None:
|
|
397
|
+
if not isinstance(item, dict):
|
|
398
|
+
return None
|
|
399
|
+
item_type = item.get("type")
|
|
400
|
+
if item_type == "input_image":
|
|
401
|
+
ref = _input_image_ref(item, collector)
|
|
402
|
+
if ref is None and tool_output:
|
|
403
|
+
# The port's rule is wider (data URL parameters, whitespace in
|
|
404
|
+
# the payload); whatever it would name, the collector must hold.
|
|
405
|
+
ref = _tool_input_image_ref(item, collector)
|
|
406
|
+
return ref
|
|
407
|
+
if item_type == "image" and tool_output:
|
|
408
|
+
return _mcp_image_ref(item, collector)
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _rewrite_codex_items(
|
|
413
|
+
items: list[Any], collector: BlobCollector, *, tool_output: bool = False
|
|
414
|
+
) -> list[BlobRef]:
|
|
415
|
+
refs: list[BlobRef] = []
|
|
416
|
+
for position, item in enumerate(items):
|
|
417
|
+
ref = _codex_image_ref(item, collector, tool_output=tool_output)
|
|
418
|
+
if ref is None:
|
|
419
|
+
continue
|
|
420
|
+
items[position] = {"type": "input_text", "text": ref.placeholder()}
|
|
421
|
+
refs.append(ref)
|
|
422
|
+
return refs
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _rewrite_nested_mcp_result(
|
|
426
|
+
payload: dict[str, Any], collector: BlobCollector, index: BlobIndex
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Lift the images out of a completed ``McpToolCall`` item's result content.
|
|
429
|
+
|
|
430
|
+
A code-mode script's nested MCP call becomes a tool call of its own
|
|
431
|
+
(:mod:`atif_converter.domain.codex_nested_calls`), keyed by the item's
|
|
432
|
+
``id``, so its images are indexed under that id, by the tool output rule.
|
|
433
|
+
"""
|
|
434
|
+
item = payload.get("item") if payload.get("type") == "item_completed" else None
|
|
435
|
+
if not isinstance(item, dict) or item.get("type") != "McpToolCall":
|
|
436
|
+
return
|
|
437
|
+
result = item.get("result")
|
|
438
|
+
content = result.get("content") if isinstance(result, dict) else None
|
|
439
|
+
if isinstance(content, list):
|
|
440
|
+
index.add_tool_result(
|
|
441
|
+
item.get("id"), _rewrite_codex_items(content, collector, tool_output=True)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def extract_codex_blobs(
|
|
446
|
+
records: Sequence[Any],
|
|
447
|
+
collector: BlobCollector,
|
|
448
|
+
record_keys: Sequence[str] | None = None,
|
|
449
|
+
) -> BlobIndex:
|
|
450
|
+
"""Rewrite every inline image in a rollout's response items.
|
|
451
|
+
|
|
452
|
+
An ``input_image`` data URL is lifted from message content and tool
|
|
453
|
+
outputs; an MCP ``image`` block with inline base64 only from a tool output
|
|
454
|
+
list, by :func:`codex_tool_image_ref`'s rule. The result content of a
|
|
455
|
+
completed ``McpToolCall`` item (a code-mode script's nested MCP call) is a
|
|
456
|
+
tool output list too, indexed by the item's ``id``.
|
|
457
|
+
|
|
458
|
+
``record_keys`` (parallel to ``records``) names each record the way the
|
|
459
|
+
enrichment pass names it in ``source_uuids``; without it, attachments in
|
|
460
|
+
message content are replaced but indexed nowhere. A tool output's
|
|
461
|
+
attachments are indexed by the output's ``call_id``.
|
|
462
|
+
"""
|
|
463
|
+
index = BlobIndex()
|
|
464
|
+
for position, record in enumerate(records):
|
|
465
|
+
if not isinstance(record, dict):
|
|
466
|
+
continue
|
|
467
|
+
payload = record.get("payload")
|
|
468
|
+
if not isinstance(payload, dict):
|
|
469
|
+
continue
|
|
470
|
+
if record.get("type") == "event_msg":
|
|
471
|
+
_rewrite_nested_mcp_result(payload, collector, index)
|
|
472
|
+
continue
|
|
473
|
+
if record.get("type") != "response_item":
|
|
474
|
+
continue
|
|
475
|
+
payload_type = payload.get("type")
|
|
476
|
+
if payload_type == "message" and isinstance(payload.get("content"), list):
|
|
477
|
+
refs = _rewrite_codex_items(payload["content"], collector)
|
|
478
|
+
if record_keys is not None and refs:
|
|
479
|
+
index.add_record(record_keys[position], refs)
|
|
480
|
+
elif _str(payload_type) in {"function_call_output", "custom_tool_call_output"}:
|
|
481
|
+
output = payload.get("output")
|
|
482
|
+
if isinstance(output, list):
|
|
483
|
+
index.add_tool_result(
|
|
484
|
+
payload.get("call_id"),
|
|
485
|
+
_rewrite_codex_items(output, collector, tool_output=True),
|
|
486
|
+
)
|
|
487
|
+
return index
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _str(value: object) -> str | None:
|
|
491
|
+
"""``value`` when it is a string, else ``None``: a set-membership key that cannot raise.
|
|
492
|
+
|
|
493
|
+
A transcript field can hold any JSON value, and an object or a list is unhashable, so
|
|
494
|
+
testing one against a set raised ``TypeError`` past the converter (found by the fuzzer).
|
|
495
|
+
A non-string value never matched one of these string sets, so no result changes.
|
|
496
|
+
"""
|
|
497
|
+
return value if isinstance(value, str) else None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
__all__ = [
|
|
501
|
+
"CODEX_TOOL_IMAGE_MEDIA_TYPES",
|
|
502
|
+
"Blob",
|
|
503
|
+
"BlobCollector",
|
|
504
|
+
"BlobIndex",
|
|
505
|
+
"BlobRef",
|
|
506
|
+
"codex_tool_image_media_type",
|
|
507
|
+
"codex_tool_image_ref",
|
|
508
|
+
"extract_claude_code_blobs",
|
|
509
|
+
"extract_codex_blobs",
|
|
510
|
+
]
|