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,412 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Post-conversion enrichment: restore what harbor 0.22.0 drops.
|
|
4
|
+
|
|
5
|
+
Pure function over a trajectory dict + raw records — no harbor import, no
|
|
6
|
+
I/O. Repairs three fidelity gaps wrap-locally (fidelity.py names them):
|
|
7
|
+
|
|
8
|
+
- gap 7 (``UUID_NOT_PRESERVED``): ``step.extra["source_uuids"]`` lists the
|
|
9
|
+
raw record uuids that contributed to each step;
|
|
10
|
+
- gap 5 (``COMPACT_SUMMARY_UNHANDLED``): ``step.extra["is_compact_summary"]``
|
|
11
|
+
when any contributing record carried ``isCompactSummary``;
|
|
12
|
+
- gap 6 (``CACHE_SPLIT_PARTIAL``): ``trajectory.extra["cache_creation_total"]``
|
|
13
|
+
surfaced from ``final_metrics.extra``.
|
|
14
|
+
|
|
15
|
+
MATCHING STRATEGY: harbor 0.22.0 puts no record identity in ``step.extra`` —
|
|
16
|
+
it reads ``requestId`` off the MESSAGE dict and ``id`` off the EVENT dict,
|
|
17
|
+
whereas real transcripts carry ``requestId`` on the event and ``uuid`` on the
|
|
18
|
+
event, so neither lands in extra and there is nothing to join on. The pass
|
|
19
|
+
therefore re-runs harbor's deterministic normalization order:
|
|
20
|
+
|
|
21
|
+
- agent steps <- assistant records grouped by ``message.id``, groups in
|
|
22
|
+
first-appearance order over the uuid-deduped, timestamp-sorted record walk
|
|
23
|
+
(exactly harbor's ``turn_by_msgid`` bundling); cross-checked via
|
|
24
|
+
``tool_calls[].call_id`` vs the group's tool_use block ids when present;
|
|
25
|
+
- tool_result user records <- joined into the agent step whose
|
|
26
|
+
``tool_calls`` / ``observation.results[].source_call_id`` contain their
|
|
27
|
+
``tool_use_id``;
|
|
28
|
+
- plain user text steps <- 1:1 walk of raw user records that produce a
|
|
29
|
+
visible text message (harbor's rules replicated), in timestamp order.
|
|
30
|
+
|
|
31
|
+
Known edge cases:
|
|
32
|
+
- a user record mixing text blocks AND tool_result blocks contributes to
|
|
33
|
+
BOTH a user step (text) and an agent step (result attachment) — its uuid
|
|
34
|
+
appears in both steps' ``source_uuids``;
|
|
35
|
+
- orphan tool_results (no pending call, e.g. replayed after compaction)
|
|
36
|
+
become standalone harbor AGENT steps interleaved in event order. They have
|
|
37
|
+
no assistant record, so they must NOT consume a ``message.id`` group in
|
|
38
|
+
the positional zip — the pass detects them (every one of the step's tool
|
|
39
|
+
call ids is absent from every assistant group's tool_use ids), keeps them
|
|
40
|
+
out of the zip, and attributes each one from the tool_result-carrying
|
|
41
|
+
user record via ``tool_use_id`` when possible (else it stays
|
|
42
|
+
unattributed);
|
|
43
|
+
- assistant records without ``message.id`` each form their own group,
|
|
44
|
+
mirroring harbor's one-turn-per-event fallback.
|
|
45
|
+
|
|
46
|
+
DESYNC ACCOUNTING (refuse-and-log, never guess): the agent walk advances a
|
|
47
|
+
(step, group) pair only when the tool-id cross-check passes — the ids
|
|
48
|
+
intersect, or BOTH sides are text-only (no tool ids on either side). The
|
|
49
|
+
first mismatching pair STOPS agent attribution outright: once positions
|
|
50
|
+
disagree, every later pairing is suspect, so continuing would misattribute.
|
|
51
|
+
The user walk likewise advances only while the record's harbor-derived text
|
|
52
|
+
equals the step's message. Either walk halting (mismatch or leftover steps)
|
|
53
|
+
records the first refused step in
|
|
54
|
+
``trajectory.extra["enrichment_truncated_at_step"]`` and logs a warning.
|
|
55
|
+
After both walks, any step left with no ``source_uuids`` and any group /
|
|
56
|
+
user text record never attached to a step are counted and surfaced as
|
|
57
|
+
``trajectory.extra["enrichment_unattributed_steps"]`` /
|
|
58
|
+
``trajectory.extra["enrichment_leftover_groups"]`` (only present when
|
|
59
|
+
nonzero). ``extra`` is free-form per the contract, so this is
|
|
60
|
+
contract-compatible; a truncation marker or nonzero count means the
|
|
61
|
+
positional re-derivation disagreed with harbor and the affected
|
|
62
|
+
attributions were REFUSED, not guessed.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import copy
|
|
68
|
+
import json
|
|
69
|
+
from collections.abc import Sequence
|
|
70
|
+
from dataclasses import dataclass, field
|
|
71
|
+
from typing import Any
|
|
72
|
+
|
|
73
|
+
from loguru import logger
|
|
74
|
+
|
|
75
|
+
_SOURCE_UUIDS = "source_uuids"
|
|
76
|
+
_TRUNCATED_AT = "enrichment_truncated_at_step"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True)
|
|
80
|
+
class _AssistantGroup:
|
|
81
|
+
"""One harbor turn: assistant records sharing a ``message.id``."""
|
|
82
|
+
|
|
83
|
+
uuids: list[str] = field(default_factory=list)
|
|
84
|
+
compact: bool = False
|
|
85
|
+
tool_use_ids: set[str] = field(default_factory=set)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _dedup_and_sort(records: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
89
|
+
"""Replicate harbor's pre-pass: dedup by uuid, then stable-sort by ts."""
|
|
90
|
+
seen: set[str] = set()
|
|
91
|
+
deduped: list[dict[str, Any]] = []
|
|
92
|
+
for record in records:
|
|
93
|
+
uuid = record.get("uuid")
|
|
94
|
+
if isinstance(uuid, str) and uuid:
|
|
95
|
+
if uuid in seen:
|
|
96
|
+
continue
|
|
97
|
+
seen.add(uuid)
|
|
98
|
+
deduped.append(record)
|
|
99
|
+
deduped.sort(key=lambda r: r.get("timestamp") or "")
|
|
100
|
+
return deduped
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _stringify(value: Any) -> str:
|
|
104
|
+
"""Harbor's ``_stringify``, replicated: str verbatim, else JSON, else str."""
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
return value
|
|
107
|
+
try:
|
|
108
|
+
return json.dumps(value, ensure_ascii=False)
|
|
109
|
+
except TypeError:
|
|
110
|
+
return str(value)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _visible_user_text(message: dict[str, Any]) -> str | None:
|
|
114
|
+
"""The user-step message text harbor would emit for this record, or None.
|
|
115
|
+
|
|
116
|
+
Replicates harbor 0.22.0's user-content rules exactly (including the
|
|
117
|
+
finding-5 divergence: a ``{"type": "text", "text": <non-str>}`` block is
|
|
118
|
+
NOT skipped — harbor json-encodes the whole block into a text part, so
|
|
119
|
+
the record still produces a visible step). Returning the text (not just
|
|
120
|
+
a bool) lets the user-step walk cross-check each pairing instead of
|
|
121
|
+
trusting position alone.
|
|
122
|
+
"""
|
|
123
|
+
content = message.get("content")
|
|
124
|
+
if isinstance(content, str):
|
|
125
|
+
return content if content.strip() else None
|
|
126
|
+
if isinstance(content, list):
|
|
127
|
+
parts: list[str] = []
|
|
128
|
+
for block in content:
|
|
129
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
130
|
+
continue
|
|
131
|
+
if (
|
|
132
|
+
isinstance(block, dict)
|
|
133
|
+
and block.get("type") == "text"
|
|
134
|
+
and isinstance(block.get("text"), str)
|
|
135
|
+
):
|
|
136
|
+
parts.append(block["text"])
|
|
137
|
+
continue
|
|
138
|
+
# any other block (incl. text blocks with non-str text) is
|
|
139
|
+
# stringified by harbor into a text part
|
|
140
|
+
parts.append(_stringify(block))
|
|
141
|
+
text = "\n\n".join(part for part in parts if part.strip())
|
|
142
|
+
return text or None
|
|
143
|
+
if content not in (None, ""):
|
|
144
|
+
text = _stringify(content)
|
|
145
|
+
return text if text.strip() else None
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _tool_result_ids(message: dict[str, Any]) -> list[str]:
|
|
150
|
+
content = message.get("content")
|
|
151
|
+
if not isinstance(content, list):
|
|
152
|
+
return []
|
|
153
|
+
ids: list[str] = []
|
|
154
|
+
for block in content:
|
|
155
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
156
|
+
tool_use_id = block.get("tool_use_id")
|
|
157
|
+
if isinstance(tool_use_id, str) and tool_use_id:
|
|
158
|
+
ids.append(tool_use_id)
|
|
159
|
+
return ids
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _step_call_ids(step: dict[str, Any]) -> set[str]:
|
|
163
|
+
"""All tool call ids a step owns: tool_calls plus observation results."""
|
|
164
|
+
ids: set[str] = set()
|
|
165
|
+
for call in step.get("tool_calls") or []:
|
|
166
|
+
call_id = call.get("tool_call_id") or call.get("call_id")
|
|
167
|
+
if isinstance(call_id, str) and call_id:
|
|
168
|
+
ids.add(call_id)
|
|
169
|
+
observation = step.get("observation") or {}
|
|
170
|
+
for result in observation.get("results") or []:
|
|
171
|
+
source_call_id = result.get("source_call_id")
|
|
172
|
+
if isinstance(source_call_id, str) and source_call_id:
|
|
173
|
+
ids.add(source_call_id)
|
|
174
|
+
return ids
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _step_id(step: dict[str, Any]) -> int | None:
|
|
178
|
+
"""The step's ``step_id`` when it is an int (ATIF requires it; be safe)."""
|
|
179
|
+
value = step.get("step_id")
|
|
180
|
+
return value if isinstance(value, int) else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _attach(step: dict[str, Any], uuids: list[str], *, compact: bool) -> None:
|
|
184
|
+
extra = step.get("extra")
|
|
185
|
+
if not isinstance(extra, dict):
|
|
186
|
+
extra = {}
|
|
187
|
+
step["extra"] = extra
|
|
188
|
+
existing = extra.get(_SOURCE_UUIDS)
|
|
189
|
+
merged: list[str] = list(existing) if isinstance(existing, list) else []
|
|
190
|
+
for uuid in uuids:
|
|
191
|
+
if uuid not in merged:
|
|
192
|
+
merged.append(uuid)
|
|
193
|
+
extra[_SOURCE_UUIDS] = merged
|
|
194
|
+
if compact:
|
|
195
|
+
extra["is_compact_summary"] = True
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def enrich_trajectory(
|
|
199
|
+
trajectory: dict[str, Any],
|
|
200
|
+
raw_records: Sequence[dict[str, Any]],
|
|
201
|
+
*,
|
|
202
|
+
copy_input: bool = True,
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
"""Return the enriched trajectory.
|
|
205
|
+
|
|
206
|
+
``raw_records`` are the parsed records of EVERY discovered session file
|
|
207
|
+
(main + all side-files); order does not matter — the pass re-applies
|
|
208
|
+
harbor's dedup + timestamp sort itself.
|
|
209
|
+
|
|
210
|
+
``copy_input=True`` (default) leaves ``trajectory`` untouched. Pass
|
|
211
|
+
``False`` when the caller owns the only reference and wants the mutation
|
|
212
|
+
in place: the deep copy duplicates the whole step list, which buys
|
|
213
|
+
nothing when the original is discarded straight afterwards.
|
|
214
|
+
"""
|
|
215
|
+
enriched = copy.deepcopy(trajectory) if copy_input else trajectory
|
|
216
|
+
records = _dedup_and_sort(raw_records)
|
|
217
|
+
|
|
218
|
+
# -- assistant records: group by message.id in first-appearance order.
|
|
219
|
+
group_order: list[str] = []
|
|
220
|
+
groups: dict[str, _AssistantGroup] = {}
|
|
221
|
+
# tool_use_id -> contributing user (tool_result) records
|
|
222
|
+
results_by_call_id: dict[str, list[dict[str, Any]]] = {}
|
|
223
|
+
user_text_records: list[dict[str, Any]] = []
|
|
224
|
+
|
|
225
|
+
for record in records:
|
|
226
|
+
message = record.get("message")
|
|
227
|
+
if not isinstance(message, dict):
|
|
228
|
+
continue
|
|
229
|
+
record_type = record.get("type")
|
|
230
|
+
uuid = record.get("uuid")
|
|
231
|
+
uuid_str = uuid if isinstance(uuid, str) and uuid else None
|
|
232
|
+
compact = bool(record.get("isCompactSummary", False))
|
|
233
|
+
|
|
234
|
+
if record_type == "assistant":
|
|
235
|
+
msg_id = message.get("id")
|
|
236
|
+
key = msg_id if isinstance(msg_id, str) and msg_id else f"__event__{id(record)}"
|
|
237
|
+
group = groups.get(key)
|
|
238
|
+
if group is None:
|
|
239
|
+
group = _AssistantGroup()
|
|
240
|
+
groups[key] = group
|
|
241
|
+
group_order.append(key)
|
|
242
|
+
if uuid_str:
|
|
243
|
+
group.uuids.append(uuid_str)
|
|
244
|
+
group.compact = group.compact or compact
|
|
245
|
+
content = message.get("content")
|
|
246
|
+
if isinstance(content, list):
|
|
247
|
+
for block in content:
|
|
248
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
249
|
+
block_id = block.get("id")
|
|
250
|
+
if isinstance(block_id, str) and block_id:
|
|
251
|
+
group.tool_use_ids.add(block_id)
|
|
252
|
+
elif record_type == "user":
|
|
253
|
+
for call_id in _tool_result_ids(message):
|
|
254
|
+
results_by_call_id.setdefault(call_id, []).append(record)
|
|
255
|
+
if _visible_user_text(message) is not None:
|
|
256
|
+
user_text_records.append(record)
|
|
257
|
+
|
|
258
|
+
steps = enriched.get("steps") or []
|
|
259
|
+
agent_steps = [s for s in steps if s.get("source") == "agent"]
|
|
260
|
+
user_steps = [s for s in steps if s.get("source") == "user"]
|
|
261
|
+
|
|
262
|
+
# -- partition agent steps: ORPHAN tool_result steps (harbor emits a
|
|
263
|
+
# standalone agent step for a tool_result with no pending call, e.g.
|
|
264
|
+
# replayed after compaction) have NO assistant record behind them, so
|
|
265
|
+
# they must not consume a message.id group in the positional zip below.
|
|
266
|
+
# Detection: the step owns tool call ids, and every one of them is
|
|
267
|
+
# absent from every assistant group's tool_use ids.
|
|
268
|
+
all_group_tool_use_ids: set[str] = set()
|
|
269
|
+
for group in groups.values():
|
|
270
|
+
all_group_tool_use_ids |= group.tool_use_ids
|
|
271
|
+
orphan_steps: list[dict[str, Any]] = []
|
|
272
|
+
genuine_agent_steps: list[dict[str, Any]] = []
|
|
273
|
+
for step in agent_steps:
|
|
274
|
+
step_ids = _step_call_ids(step)
|
|
275
|
+
if step_ids and step_ids.isdisjoint(all_group_tool_use_ids):
|
|
276
|
+
orphan_steps.append(step)
|
|
277
|
+
else:
|
|
278
|
+
genuine_agent_steps.append(step)
|
|
279
|
+
|
|
280
|
+
# -- orphan steps <- the tool_result-carrying user record, joined on
|
|
281
|
+
# tool_use_id when possible; otherwise the step stays unattributed
|
|
282
|
+
# (and is counted below).
|
|
283
|
+
for step in orphan_steps:
|
|
284
|
+
for call_id in sorted(_step_call_ids(step)):
|
|
285
|
+
for record in results_by_call_id.get(call_id, ()):
|
|
286
|
+
uuid = record.get("uuid")
|
|
287
|
+
if isinstance(uuid, str) and uuid:
|
|
288
|
+
_attach(
|
|
289
|
+
step,
|
|
290
|
+
[uuid],
|
|
291
|
+
compact=bool(record.get("isCompactSummary", False)),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# -- genuine agent steps <- assistant groups, walked in lockstep
|
|
295
|
+
# (harbor's turn order). The cross-check is REQUIRED to advance: a pair
|
|
296
|
+
# attributes only when the two sides' tool ids intersect, or when BOTH
|
|
297
|
+
# sides are text-only. The FIRST mismatching pair stops the walk — once
|
|
298
|
+
# positions disagree, every later pairing is suspect, so refusing from
|
|
299
|
+
# that point beats silently misattributing (module docstring). A side
|
|
300
|
+
# running out while the other has leftovers is the same desync; the
|
|
301
|
+
# accounting below surfaces it.
|
|
302
|
+
truncated_at: int | None = None
|
|
303
|
+
consumed_groups = 0
|
|
304
|
+
for step, key in zip(genuine_agent_steps, group_order, strict=False):
|
|
305
|
+
group = groups[key]
|
|
306
|
+
step_ids = _step_call_ids(step)
|
|
307
|
+
aligned = bool(step_ids & group.tool_use_ids) if (step_ids or group.tool_use_ids) else True
|
|
308
|
+
if not aligned:
|
|
309
|
+
truncated_at = _step_id(step)
|
|
310
|
+
logger.warning(
|
|
311
|
+
"enrichment: agent-step alignment mismatch at step {} "
|
|
312
|
+
"(step tool ids {} vs group tool_use ids {}); "
|
|
313
|
+
"attribution stopped from this step onward",
|
|
314
|
+
truncated_at,
|
|
315
|
+
sorted(step_ids),
|
|
316
|
+
sorted(group.tool_use_ids),
|
|
317
|
+
)
|
|
318
|
+
break
|
|
319
|
+
consumed_groups += 1
|
|
320
|
+
_attach(step, group.uuids, compact=group.compact)
|
|
321
|
+
# tool_result user records join the agent step that owns their call
|
|
322
|
+
for call_id in sorted(step_ids):
|
|
323
|
+
for record in results_by_call_id.get(call_id, ()):
|
|
324
|
+
uuid = record.get("uuid")
|
|
325
|
+
if isinstance(uuid, str) and uuid:
|
|
326
|
+
_attach(
|
|
327
|
+
step,
|
|
328
|
+
[uuid],
|
|
329
|
+
compact=bool(record.get("isCompactSummary", False)),
|
|
330
|
+
)
|
|
331
|
+
if truncated_at is None and consumed_groups < len(genuine_agent_steps):
|
|
332
|
+
# length mismatch: agent steps left over with no group to claim them.
|
|
333
|
+
truncated_at = _step_id(genuine_agent_steps[consumed_groups])
|
|
334
|
+
logger.warning(
|
|
335
|
+
"enrichment: {} agent step(s) beyond the {} assistant group(s); "
|
|
336
|
+
"attribution stopped at step {}",
|
|
337
|
+
len(genuine_agent_steps) - consumed_groups,
|
|
338
|
+
len(group_order),
|
|
339
|
+
truncated_at,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# -- plain user text steps <- ts-order walk of user text records, with
|
|
343
|
+
# the SAME refuse-and-log protection: a pair advances only while the
|
|
344
|
+
# record's harbor-derived text equals the step's message, and leftover
|
|
345
|
+
# steps stop the walk instead of being silently dropped.
|
|
346
|
+
consumed_user_records = 0
|
|
347
|
+
for step, record in zip(user_steps, user_text_records, strict=False):
|
|
348
|
+
message = record.get("message")
|
|
349
|
+
expected = _visible_user_text(message) if isinstance(message, dict) else None
|
|
350
|
+
if step.get("message") != expected:
|
|
351
|
+
step_id = _step_id(step)
|
|
352
|
+
logger.warning(
|
|
353
|
+
"enrichment: user-step alignment mismatch at step {} "
|
|
354
|
+
"(step message differs from the walked record's text); "
|
|
355
|
+
"user attribution stopped from this step onward",
|
|
356
|
+
step_id,
|
|
357
|
+
)
|
|
358
|
+
if truncated_at is None or (step_id is not None and step_id < truncated_at):
|
|
359
|
+
truncated_at = step_id
|
|
360
|
+
break
|
|
361
|
+
consumed_user_records += 1
|
|
362
|
+
uuid = record.get("uuid")
|
|
363
|
+
uuids = [uuid] if isinstance(uuid, str) and uuid else []
|
|
364
|
+
_attach(step, uuids, compact=bool(record.get("isCompactSummary", False)))
|
|
365
|
+
else:
|
|
366
|
+
if consumed_user_records < len(user_steps):
|
|
367
|
+
# length mismatch: user steps left over with no record.
|
|
368
|
+
step_id = _step_id(user_steps[consumed_user_records])
|
|
369
|
+
logger.warning(
|
|
370
|
+
"enrichment: {} user step(s) beyond the {} user text record(s); "
|
|
371
|
+
"attribution stopped at step {}",
|
|
372
|
+
len(user_steps) - consumed_user_records,
|
|
373
|
+
len(user_text_records),
|
|
374
|
+
step_id,
|
|
375
|
+
)
|
|
376
|
+
if truncated_at is None or (step_id is not None and step_id < truncated_at):
|
|
377
|
+
truncated_at = step_id
|
|
378
|
+
|
|
379
|
+
# -- desync accounting (see module docstring): count anything the walks
|
|
380
|
+
# left behind on EITHER side and surface it on trajectory.extra. A
|
|
381
|
+
# truncation marker / nonzero count means attribution was refused
|
|
382
|
+
# somewhere, never guessed.
|
|
383
|
+
unattributed_steps = sum(
|
|
384
|
+
1 for step in steps if not (step.get("extra") or {}).get(_SOURCE_UUIDS)
|
|
385
|
+
)
|
|
386
|
+
leftover_groups = (len(group_order) - consumed_groups) + (
|
|
387
|
+
len(user_text_records) - consumed_user_records
|
|
388
|
+
)
|
|
389
|
+
if unattributed_steps or leftover_groups or truncated_at is not None:
|
|
390
|
+
trajectory_extra = enriched.get("extra")
|
|
391
|
+
if not isinstance(trajectory_extra, dict):
|
|
392
|
+
trajectory_extra = {}
|
|
393
|
+
enriched["extra"] = trajectory_extra
|
|
394
|
+
if unattributed_steps:
|
|
395
|
+
trajectory_extra["enrichment_unattributed_steps"] = unattributed_steps
|
|
396
|
+
if leftover_groups:
|
|
397
|
+
trajectory_extra["enrichment_leftover_groups"] = leftover_groups
|
|
398
|
+
if truncated_at is not None:
|
|
399
|
+
trajectory_extra[_TRUNCATED_AT] = truncated_at
|
|
400
|
+
|
|
401
|
+
# -- gap 6: surface cache_creation_total on trajectory.extra.
|
|
402
|
+
final_metrics = enriched.get("final_metrics") or {}
|
|
403
|
+
metrics_extra = final_metrics.get("extra") or {}
|
|
404
|
+
cache_creation = metrics_extra.get("total_cache_creation_input_tokens")
|
|
405
|
+
if isinstance(cache_creation, int):
|
|
406
|
+
trajectory_extra = enriched.get("extra")
|
|
407
|
+
if not isinstance(trajectory_extra, dict):
|
|
408
|
+
trajectory_extra = {}
|
|
409
|
+
enriched["extra"] = trajectory_extra
|
|
410
|
+
trajectory_extra["cache_creation_total"] = cache_creation
|
|
411
|
+
|
|
412
|
+
return enriched
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The domain error hierarchy for atif-converter.
|
|
4
|
+
|
|
5
|
+
Pure, dependency-free exception types. Callers map these to exit codes
|
|
6
|
+
themselves (atif-cli owns that mapping in ``atif_cli.errors``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DomainError(Exception):
|
|
15
|
+
"""Base for every atif-converter domain error.
|
|
16
|
+
|
|
17
|
+
Anything that crosses a layer boundary as an error value is a subclass of
|
|
18
|
+
this. Adapters may raise transport-specific exceptions internally, but the
|
|
19
|
+
application layer surfaces only ``DomainError`` subtypes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidSessionInput(DomainError): # noqa: N818 — named as a terminal input verdict, not "*Error"
|
|
24
|
+
"""The supplied path is not a Claude Code session JSONL file."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EmptySessionError(DomainError):
|
|
28
|
+
"""Harbor produced no trajectory (no convertible events in the session)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TrajectoryValidationError(DomainError):
|
|
32
|
+
"""The converted trajectory failed harbor's ``TrajectoryValidator``.
|
|
33
|
+
|
|
34
|
+
Carries the validator's error list so callers can report the exact
|
|
35
|
+
schema violations.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, errors: list[str]) -> None:
|
|
39
|
+
super().__init__(f"trajectory failed validation with {len(errors)} error(s)")
|
|
40
|
+
self.errors = errors
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConversionError(DomainError):
|
|
44
|
+
"""Unexpected failure inside the harbor adapter during conversion."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SourceMutatedDuringConversion(DomainError): # noqa: N818 — names a race verdict, not a runtime fault
|
|
48
|
+
"""A source file changed while the session was being converted.
|
|
49
|
+
|
|
50
|
+
The artifacts derived from the pre-change snapshot would disagree with
|
|
51
|
+
each other, so conversion refuses instead of publishing an inconsistent
|
|
52
|
+
generation. Retrying once the session goes quiet succeeds.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, session_jsonl: Path, mutated: tuple[Path, ...]) -> None:
|
|
56
|
+
super().__init__(
|
|
57
|
+
f"{len(mutated)} source file(s) changed while converting {session_jsonl}: "
|
|
58
|
+
f"{', '.join(str(path) for path in mutated)}"
|
|
59
|
+
)
|
|
60
|
+
self.mutated = mutated
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The fidelity policy: what harbor's Claude Code -> ATIF conversion loses.
|
|
4
|
+
|
|
5
|
+
Pure value objects — no harbor import, no I/O. This module NAMES the known
|
|
6
|
+
upstream gaps (verified empirically against harbor==0.22.0 on 2026-08-22)
|
|
7
|
+
rather than fixing them; the converter's job today is honest loss accounting,
|
|
8
|
+
not repair. The unit tests pin each gap so a harbor version bump that changes
|
|
9
|
+
behavior trips the suite.
|
|
10
|
+
|
|
11
|
+
Codex CLI has its own taxonomy and its own gaps, in
|
|
12
|
+
:mod:`atif_converter.domain.codex_fidelity`. :class:`LossReport` is shared by
|
|
13
|
+
both: it holds whichever agent's record types and gaps it was built with, and
|
|
14
|
+
``to_json()`` flattens either family to the same ``loss_report.json`` shape,
|
|
15
|
+
so atif-duck reads one projection for every agent.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
from atif_converter.domain.codex_fidelity import CodexFidelityGap, CodexRecordType
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RecordType(Enum):
|
|
27
|
+
"""Taxonomy of raw Claude Code session JSONL record types.
|
|
28
|
+
|
|
29
|
+
``type`` field values seen in the wild, plus ``OTHER`` for anything new.
|
|
30
|
+
Only USER and ASSISTANT records are converted by harbor 0.22.0; everything
|
|
31
|
+
else is silently dropped (:attr:`FidelityGap.NON_MESSAGE_RECORDS_DROPPED`).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
USER = "user"
|
|
35
|
+
ASSISTANT = "assistant"
|
|
36
|
+
SYSTEM = "system"
|
|
37
|
+
ATTACHMENT = "attachment"
|
|
38
|
+
QUEUE_OPERATION = "queue-operation"
|
|
39
|
+
MODE = "mode"
|
|
40
|
+
LAST_PROMPT = "last-prompt"
|
|
41
|
+
SUMMARY = "summary"
|
|
42
|
+
PR_LINK = "pr-link"
|
|
43
|
+
STARTED = "started"
|
|
44
|
+
RESULT = "result"
|
|
45
|
+
#: Claude Code's running session cost (``totalCostUSD`` + per-model usage).
|
|
46
|
+
COST_STATE = "cost-state"
|
|
47
|
+
PERMISSION_MODE = "permission-mode"
|
|
48
|
+
OTHER = "other"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
#: Record types harbor 0.22.0 actually converts into ATIF steps.
|
|
52
|
+
CONVERTIBLE_RECORD_TYPES: frozenset[RecordType] = frozenset({RecordType.USER, RecordType.ASSISTANT})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FidelityGap(Enum):
|
|
56
|
+
"""The known conversion gaps, inherited from harbor 0.22.0's converter.
|
|
57
|
+
|
|
58
|
+
Each member documents one loss verified against harbor's Claude Code
|
|
59
|
+
conversion. Our converter (:mod:`atif_converter.domain.claude_code_conversion`)
|
|
60
|
+
is a parity port of it, so these losses are OURS now, by choice: the
|
|
61
|
+
parity oracle holds the port to harbor's output, and fixing a gap means
|
|
62
|
+
deciding to diverge from that oracle on purpose.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
#: (1) RETIRED. ``workflow_subagents_missed`` named harbor's discovery,
|
|
66
|
+
#: ``rglob("subagents/*.jsonl")``, which cannot see workflow-nested side
|
|
67
|
+
#: files. Our converter discovers every ``*.jsonl`` under the session's side
|
|
68
|
+
#: directory itself, so the gap has no mechanism left to describe. The value
|
|
69
|
+
#: is not reused; a corpus materialized before the port still carries it.
|
|
70
|
+
|
|
71
|
+
#: (2) Only user/assistant events are converted; system / attachment /
|
|
72
|
+
#: queue-operation / mode / last-prompt / summary records are silently dropped.
|
|
73
|
+
#: The ones :mod:`atif_converter.domain.session_events` keeps (hooks, injected
|
|
74
|
+
#: context, API errors, compaction boundaries, cost-state, mode changes) land
|
|
75
|
+
#: in ``session_events.jsonl`` and count as ``records_captured``; the gap is
|
|
76
|
+
#: observed only when something is left over after that.
|
|
77
|
+
NON_MESSAGE_RECORDS_DROPPED = "non_message_records_dropped"
|
|
78
|
+
|
|
79
|
+
#: (3) The parentUuid tree is flattened by timestamp sort, not chain-walked,
|
|
80
|
+
#: so branch/rewind structure is lost.
|
|
81
|
+
PARENT_CHAIN_FLATTENED = "parent_chain_flattened"
|
|
82
|
+
|
|
83
|
+
#: (4) Subagent events are INLINED into the flat step list (marked only via
|
|
84
|
+
#: ``extra.is_sidechain``) instead of ``subagent_trajectories`` embedding.
|
|
85
|
+
SUBAGENTS_INLINED = "subagents_inlined"
|
|
86
|
+
|
|
87
|
+
#: (5) No ``isCompactSummary`` handling — compaction summaries are not
|
|
88
|
+
#: distinguished from ordinary user messages.
|
|
89
|
+
COMPACT_SUMMARY_UNHANDLED = "compact_summary_unhandled"
|
|
90
|
+
|
|
91
|
+
#: (6) Cache split partially preserved: ``cached_tokens`` = cache_read only;
|
|
92
|
+
#: cache_creation survives only inside ``metrics.extra``.
|
|
93
|
+
CACHE_SPLIT_PARTIAL = "cache_split_partial"
|
|
94
|
+
|
|
95
|
+
#: (7) The event-level ``uuid`` is read only for dedup and never lands in
|
|
96
|
+
#: ``Step.extra`` — step-to-raw-record identity is unrecoverable.
|
|
97
|
+
#: (Verified 2026-08-22: step.extra carries requestId / message id /
|
|
98
|
+
#: is_sidechain, never the event uuid.)
|
|
99
|
+
UUID_NOT_PRESERVED = "uuid_not_preserved"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
#: Either agent's raw-record taxonomy. ``LossReport.record_counts`` is keyed
|
|
103
|
+
#: by one of these, and ``to_json()`` reads only ``.value``, so a report built
|
|
104
|
+
#: from Codex records serializes to the same shape as a Claude Code one.
|
|
105
|
+
type AnyRecordType = RecordType | CodexRecordType
|
|
106
|
+
|
|
107
|
+
#: Either agent's gap taxonomy. The two enums' values are disjoint (the Codex
|
|
108
|
+
#: members are namespaced ``codex_*``), so a mixed ``gaps_observed`` array
|
|
109
|
+
#: stays unambiguous about which policy each entry came from.
|
|
110
|
+
type AnyFidelityGap = FidelityGap | CodexFidelityGap
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class LossReport:
|
|
115
|
+
"""Per-session loss accounting: raw-side census vs converted output.
|
|
116
|
+
|
|
117
|
+
``record_counts`` is the raw census by :class:`RecordType`;
|
|
118
|
+
``records_converted`` (became steps), ``records_captured`` (became
|
|
119
|
+
``session_events`` rows) and ``records_dropped`` partition the total;
|
|
120
|
+
``gaps_observed`` names which :class:`FidelityGap` members this session
|
|
121
|
+
actually exhibits (a session with no subagents cannot observe gap 1 or 4).
|
|
122
|
+
|
|
123
|
+
CENSUS SCOPE: ``record_counts`` covers every record of every ``*.jsonl``
|
|
124
|
+
discovered under the session's side directory, at ANY depth and whether
|
|
125
|
+
or not a ``subagents/`` part appears in its path. That is the same file
|
|
126
|
+
set the adapter stages into harbor and the same set edges.jsonl is built
|
|
127
|
+
from, so ``records_total`` equals the edges line count for any session.
|
|
128
|
+
A side-file outside ``subagents/`` therefore raises ``records_total``
|
|
129
|
+
(and, for a non-message type, ``records_dropped``) relative to a census
|
|
130
|
+
scoped to ``subagents/`` alone.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
record_counts: dict[AnyRecordType, int] = field(default_factory=dict)
|
|
134
|
+
records_converted: int = 0
|
|
135
|
+
records_dropped: int = 0
|
|
136
|
+
records_captured: int = 0
|
|
137
|
+
gaps_observed: frozenset[AnyFidelityGap] = frozenset()
|
|
138
|
+
subagent_files_found: int = 0
|
|
139
|
+
subagent_files_convertible: int = 0
|
|
140
|
+
workflow_subagent_files_found: int = 0
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def records_total(self) -> int:
|
|
144
|
+
"""Total raw records counted in the census."""
|
|
145
|
+
return sum(self.record_counts.values())
|
|
146
|
+
|
|
147
|
+
def to_json(self) -> dict[str, int | dict[str, int] | list[str]]:
|
|
148
|
+
"""The CONTRACT ``loss_report.json`` document, JSON-serializable.
|
|
149
|
+
|
|
150
|
+
Named by docs/CONTRACT.md ("loss_report.json — atif_converter
|
|
151
|
+
LossReport.to_json()") and read back by atif-duck's
|
|
152
|
+
``_LOSS_REPORT_COLUMNS`` projection, so the keys here are wire
|
|
153
|
+
contract. Enum members flatten to their string values;
|
|
154
|
+
``gaps_observed`` is sorted for deterministic output.
|
|
155
|
+
"""
|
|
156
|
+
return {
|
|
157
|
+
"record_counts": {
|
|
158
|
+
record_type.value: count
|
|
159
|
+
for record_type, count in sorted(
|
|
160
|
+
self.record_counts.items(), key=lambda kv: kv[0].value
|
|
161
|
+
)
|
|
162
|
+
},
|
|
163
|
+
"records_total": self.records_total,
|
|
164
|
+
"records_converted": self.records_converted,
|
|
165
|
+
"records_captured": self.records_captured,
|
|
166
|
+
"records_dropped": self.records_dropped,
|
|
167
|
+
"gaps_observed": sorted(gap.value for gap in self.gaps_observed),
|
|
168
|
+
"subagent_files_found": self.subagent_files_found,
|
|
169
|
+
"subagent_files_convertible": self.subagent_files_convertible,
|
|
170
|
+
"workflow_subagent_files_found": self.workflow_subagent_files_found,
|
|
171
|
+
}
|