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
atif_cli/output.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Agent-friendly output formatting for the atif-sql CLI.
|
|
4
|
+
|
|
5
|
+
One rule holds across every command: output goes through :func:`emit_rows`
|
|
6
|
+
(tabular) or :func:`emit_json` (structured), ``--format auto`` resolves to a
|
|
7
|
+
human table on a TTY and machine-readable JSON on a pipe, and classified
|
|
8
|
+
errors land on stderr as one readable line (TTY) or a JSON envelope (pipe)
|
|
9
|
+
with a stable exit code. A command that writes to stdout directly breaks the
|
|
10
|
+
pipe contract an agent depends on.
|
|
11
|
+
|
|
12
|
+
No dataframe library. The tabular surface is ``(columns, rows)`` straight off
|
|
13
|
+
the DuckDB cursor, so the table renderer is a small width-aligned formatter
|
|
14
|
+
and JSON/CSV go through the stdlib. That is what keeps this module importable
|
|
15
|
+
on the lean path — no duckdb, no dataframe stack — which the
|
|
16
|
+
fresh-interpreter lean-import test pins.
|
|
17
|
+
|
|
18
|
+
Unbounded results go through :func:`emit_cursor`, which walks the cursor in
|
|
19
|
+
``fetchmany`` batches and writes each batch straight to stdout, so the
|
|
20
|
+
client holds one batch of Python tuples instead of every row. DuckDB's
|
|
21
|
+
``memory_limit`` does not bound that client-side copy at all — it caps the
|
|
22
|
+
engine's own heap.
|
|
23
|
+
|
|
24
|
+
What streaming buys is measured, and it is narrower than "less memory
|
|
25
|
+
always". It wins when a large result comes off a cheap scan: 3,000,000 rows
|
|
26
|
+
out of ``range()`` peak near 18 MB of RSS above baseline streamed, against
|
|
27
|
+
roughly 540 MB under ``fetchall``. It does NOT win on a JSON-parsing view.
|
|
28
|
+
``SELECT * FROM steps`` over a real corpus (115,265 rows, 149 MB of JSON)
|
|
29
|
+
holds the view's scan resident near 3 GB for the whole write, and paired
|
|
30
|
+
runs put streaming and ``fetchall`` in one overlapping band with no
|
|
31
|
+
consistent ordering between them. Streaming is the default because it is
|
|
32
|
+
the mode whose client-side cost stays flat as a result grows, not because
|
|
33
|
+
it lowers the peak on every query.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import csv
|
|
39
|
+
import json
|
|
40
|
+
import sys
|
|
41
|
+
from enum import StrEnum
|
|
42
|
+
from typing import TYPE_CHECKING, Any
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
46
|
+
|
|
47
|
+
from atif_cli.errors import ClassifiedError
|
|
48
|
+
|
|
49
|
+
#: Cell truncation width, TABLE format ONLY. JSON and CSV emit the full value:
|
|
50
|
+
#: truncation is a terminal-legibility device, and silently shortening a value
|
|
51
|
+
#: on a machine-readable path would corrupt the data an agent reads back.
|
|
52
|
+
_TABLE_CELL_MAX = 120
|
|
53
|
+
|
|
54
|
+
#: Rows per ``fetchmany`` in :func:`emit_cursor`. Big enough that the
|
|
55
|
+
#: per-batch overhead is noise and the table renderer measures its column
|
|
56
|
+
#: widths over a representative sample; small enough that peak resident rows
|
|
57
|
+
#: stay bounded regardless of result size.
|
|
58
|
+
_STREAM_BATCH_ROWS = 10_000
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OutputFormat(StrEnum):
|
|
62
|
+
"""Supported output formats.
|
|
63
|
+
|
|
64
|
+
``AUTO`` resolves to ``TABLE`` when stdout is a TTY and ``JSON``
|
|
65
|
+
otherwise. A string Enum is what lets cyclopts parse ``--format json``
|
|
66
|
+
with no custom converter. The contract names ``auto|json|csv``; ``table``
|
|
67
|
+
is what ``auto`` resolves to on a TTY, and it is accepted explicitly so a
|
|
68
|
+
caller can force the human grid onto a pipe.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
AUTO = "auto"
|
|
72
|
+
TABLE = "table"
|
|
73
|
+
JSON = "json"
|
|
74
|
+
CSV = "csv"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_format(fmt: OutputFormat | str) -> OutputFormat:
|
|
78
|
+
"""Resolve ``AUTO`` against the current stdout; no-op for explicit formats."""
|
|
79
|
+
# `OutputFormat` is a `StrEnum`, so a member IS a str and the constructor is
|
|
80
|
+
# the identity on one — no branch is needed to accept either input shape.
|
|
81
|
+
resolved = OutputFormat(fmt)
|
|
82
|
+
if resolved is not OutputFormat.AUTO:
|
|
83
|
+
return resolved
|
|
84
|
+
return OutputFormat.TABLE if sys.stdout.isatty() else OutputFormat.JSON
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cell(value: Any) -> str:
|
|
88
|
+
"""Render one table cell: NULL marker, truncated, newline-flattened."""
|
|
89
|
+
if value is None:
|
|
90
|
+
return "∅"
|
|
91
|
+
text = str(value).replace("\n", "\\n")
|
|
92
|
+
if len(text) > _TABLE_CELL_MAX:
|
|
93
|
+
return text[: _TABLE_CELL_MAX - 1] + "…"
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def json_object_keys(columns: Sequence[str]) -> list[str]:
|
|
98
|
+
"""Column names made unique, so a JSON row object keeps every column.
|
|
99
|
+
|
|
100
|
+
A JSON row object is keyed by column name, and SQL column names are not
|
|
101
|
+
unique: any un-aliased self-join
|
|
102
|
+
(``SELECT s.session_id, m.session_id FROM sessions s JOIN messages m
|
|
103
|
+
USING (session_id)``) returns two columns with one name. Keying a dict
|
|
104
|
+
by them keeps only the LAST value and returns fewer columns than were
|
|
105
|
+
asked for, with no error. Repeats get a ``_<n>`` suffix instead, counting
|
|
106
|
+
from 1 in column order and skipping suffixes already taken by an
|
|
107
|
+
explicit alias, so the mapping is a pure function of ``columns``.
|
|
108
|
+
"""
|
|
109
|
+
taken = set(columns)
|
|
110
|
+
seen: set[str] = set()
|
|
111
|
+
keys: list[str] = []
|
|
112
|
+
for column in columns:
|
|
113
|
+
if column not in seen:
|
|
114
|
+
seen.add(column)
|
|
115
|
+
keys.append(column)
|
|
116
|
+
continue
|
|
117
|
+
suffix = 1
|
|
118
|
+
while f"{column}_{suffix}" in taken or f"{column}_{suffix}" in seen:
|
|
119
|
+
suffix += 1
|
|
120
|
+
key = f"{column}_{suffix}"
|
|
121
|
+
seen.add(key)
|
|
122
|
+
keys.append(key)
|
|
123
|
+
return keys
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _batched(rows: Sequence[Sequence[Any]], size: int) -> Iterator[Sequence[Sequence[Any]]]:
|
|
127
|
+
"""Slice an in-memory row sequence into batches of at most ``size``."""
|
|
128
|
+
for start in range(0, len(rows), size):
|
|
129
|
+
yield rows[start : start + size]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def emit_rows(
|
|
133
|
+
columns: Sequence[str],
|
|
134
|
+
rows: Sequence[Sequence[Any]],
|
|
135
|
+
fmt: OutputFormat | str = OutputFormat.AUTO,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Write an in-memory query result to stdout in the requested format.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
columns
|
|
142
|
+
Column names, from ``cursor.description``.
|
|
143
|
+
rows
|
|
144
|
+
Row tuples, from ``cursor.fetchall()``.
|
|
145
|
+
fmt
|
|
146
|
+
One of :class:`OutputFormat`; ``AUTO`` resolves per
|
|
147
|
+
:func:`resolve_format`.
|
|
148
|
+
|
|
149
|
+
JSON emits an ARRAY OF ROW OBJECTS, not a columns/rows pair: an agent
|
|
150
|
+
parsing the result should not have to zip two lists to read a field. CSV
|
|
151
|
+
emits a header + rows; TABLE a width-aligned plain-text grid with a
|
|
152
|
+
trailing row count. Results whose size is not known to be small belong in
|
|
153
|
+
:func:`emit_cursor`.
|
|
154
|
+
"""
|
|
155
|
+
emit_row_batches(columns, _batched(rows, _STREAM_BATCH_ROWS), fmt)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def emit_cursor(cursor: Any, fmt: OutputFormat | str = OutputFormat.AUTO) -> None:
|
|
159
|
+
"""Write a DuckDB cursor's whole result to stdout without materializing it.
|
|
160
|
+
|
|
161
|
+
Reads ``cursor.description`` for the column names, then drains
|
|
162
|
+
``cursor.fetchmany`` in :data:`_STREAM_BATCH_ROWS` batches through
|
|
163
|
+
:func:`emit_row_batches`. Duck-typed on purpose: this module stays off
|
|
164
|
+
the ``import duckdb`` path.
|
|
165
|
+
|
|
166
|
+
Output is written as batches arrive, so a driver error raised partway
|
|
167
|
+
through the scan surfaces AFTER some rows are already on stdout.
|
|
168
|
+
"""
|
|
169
|
+
columns = [d[0] for d in cursor.description or ()]
|
|
170
|
+
|
|
171
|
+
def batches() -> Iterator[Sequence[Sequence[Any]]]:
|
|
172
|
+
while True:
|
|
173
|
+
batch = cursor.fetchmany(_STREAM_BATCH_ROWS)
|
|
174
|
+
if not batch:
|
|
175
|
+
return
|
|
176
|
+
yield batch
|
|
177
|
+
|
|
178
|
+
emit_row_batches(columns, batches(), fmt)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def emit_row_batches(
|
|
182
|
+
columns: Sequence[str],
|
|
183
|
+
batches: Iterable[Sequence[Sequence[Any]]],
|
|
184
|
+
fmt: OutputFormat | str = OutputFormat.AUTO,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Write successive row batches to stdout in the requested format.
|
|
187
|
+
|
|
188
|
+
Each batch is serialized and written before the next is pulled, so peak
|
|
189
|
+
memory is one batch rather than the whole result. The JSON array
|
|
190
|
+
brackets and commas, the CSV header, and the TABLE header/rule/row-count
|
|
191
|
+
are emitted around the stream.
|
|
192
|
+
"""
|
|
193
|
+
resolved = resolve_format(fmt)
|
|
194
|
+
if resolved is OutputFormat.JSON:
|
|
195
|
+
_write_json_stream(columns, batches)
|
|
196
|
+
return
|
|
197
|
+
if resolved is OutputFormat.CSV:
|
|
198
|
+
writer = csv.writer(sys.stdout)
|
|
199
|
+
writer.writerow(columns)
|
|
200
|
+
for batch in batches:
|
|
201
|
+
writer.writerows(batch)
|
|
202
|
+
return
|
|
203
|
+
_write_table_stream(columns, batches)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _write_json_stream(
|
|
207
|
+
columns: Sequence[str],
|
|
208
|
+
batches: Iterable[Sequence[Sequence[Any]]],
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Emit a JSON array of row objects, one batch of elements at a time."""
|
|
211
|
+
keys = json_object_keys(columns)
|
|
212
|
+
sys.stdout.write("[")
|
|
213
|
+
first = True
|
|
214
|
+
for batch in batches:
|
|
215
|
+
for row in batch:
|
|
216
|
+
if not first:
|
|
217
|
+
sys.stdout.write(", ")
|
|
218
|
+
first = False
|
|
219
|
+
sys.stdout.write(json.dumps(dict(zip(keys, row, strict=True)), default=str))
|
|
220
|
+
sys.stdout.write("]\n")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _write_table_stream(
|
|
224
|
+
columns: Sequence[str],
|
|
225
|
+
batches: Iterable[Sequence[Sequence[Any]]],
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Emit a width-aligned plain-text grid, sizing columns off the first batch.
|
|
228
|
+
|
|
229
|
+
Column widths are fixed by the header plus the first batch, which is the
|
|
230
|
+
widest window available without holding the whole result. A later batch
|
|
231
|
+
with a wider cell overflows its column rather than being truncated —
|
|
232
|
+
misalignment is preferable to hiding a value.
|
|
233
|
+
"""
|
|
234
|
+
stream = iter(batches)
|
|
235
|
+
head = [[_cell(v) for v in row] for row in next(stream, ())]
|
|
236
|
+
widths = [
|
|
237
|
+
max(len(str(col)), *(len(r[i]) for r in head)) if head else len(str(col))
|
|
238
|
+
for i, col in enumerate(columns)
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
def line(cells: Sequence[str]) -> str:
|
|
242
|
+
return " | ".join(cell.ljust(w) for cell, w in zip(cells, widths, strict=True))
|
|
243
|
+
|
|
244
|
+
sys.stdout.write(line([str(col) for col in columns]) + "\n")
|
|
245
|
+
sys.stdout.write("-+-".join("-" * w for w in widths) + "\n")
|
|
246
|
+
total = 0
|
|
247
|
+
for row in head:
|
|
248
|
+
sys.stdout.write(line(row) + "\n")
|
|
249
|
+
total += 1
|
|
250
|
+
for batch in stream:
|
|
251
|
+
for raw_row in batch:
|
|
252
|
+
sys.stdout.write(line([_cell(v) for v in raw_row]) + "\n")
|
|
253
|
+
total += 1
|
|
254
|
+
sys.stdout.write(f"({total} row{'s' if total != 1 else ''})\n")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def emit_json(payload: Any, fmt: OutputFormat | str = OutputFormat.AUTO) -> None:
|
|
258
|
+
"""Write a non-tabular payload as pretty JSON (schema, status, reports).
|
|
259
|
+
|
|
260
|
+
Every format reduces to one JSON document here, because CSV over a nested
|
|
261
|
+
dict has no meaning. ``fmt`` is accepted for call-site symmetry with
|
|
262
|
+
:func:`emit_rows` and is never branched on.
|
|
263
|
+
Callers that want a bespoke human layout handle the TABLE branch
|
|
264
|
+
themselves before calling this.
|
|
265
|
+
"""
|
|
266
|
+
del fmt
|
|
267
|
+
sys.stdout.write(json.dumps(payload, indent=2, default=str))
|
|
268
|
+
sys.stdout.write("\n")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def emit_error(err: ClassifiedError, fmt: OutputFormat | str = OutputFormat.AUTO) -> None:
|
|
272
|
+
"""Write a classified error to stderr in the requested format.
|
|
273
|
+
|
|
274
|
+
Agents running ``--format json`` (or piping) get the structured
|
|
275
|
+
envelope; humans on a TTY get a readable line plus the hint. The caller
|
|
276
|
+
exits with :attr:`ClassifiedError.exit_code`.
|
|
277
|
+
"""
|
|
278
|
+
resolved = resolve_format(fmt)
|
|
279
|
+
if resolved is OutputFormat.TABLE:
|
|
280
|
+
sys.stderr.write(f"[{err.kind}] {err.message}\n")
|
|
281
|
+
if err.hint:
|
|
282
|
+
sys.stderr.write(f"hint: {err.hint}\n")
|
|
283
|
+
else:
|
|
284
|
+
sys.stderr.write(json.dumps(err.to_payload(), default=str))
|
|
285
|
+
sys.stderr.write("\n")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
__all__ = [
|
|
289
|
+
"OutputFormat",
|
|
290
|
+
"emit_cursor",
|
|
291
|
+
"emit_error",
|
|
292
|
+
"emit_json",
|
|
293
|
+
"emit_row_batches",
|
|
294
|
+
"emit_rows",
|
|
295
|
+
"json_object_keys",
|
|
296
|
+
"resolve_format",
|
|
297
|
+
]
|
atif_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Use case: convert one session AND account for what the conversion lost.
|
|
4
|
+
|
|
5
|
+
Composes the raw-side census (:mod:`atif_converter.infrastructure.census`)
|
|
6
|
+
with the harbor adapter
|
|
7
|
+
(:mod:`atif_converter.infrastructure.harbor_adapter`) into a
|
|
8
|
+
``(ConversionResult, LossReport)`` pair, the honest answer to "what did we
|
|
9
|
+
just materialize, and what did upstream drop on the floor?".
|
|
10
|
+
|
|
11
|
+
ONE READ. The session's files are read once, by
|
|
12
|
+
:func:`~atif_converter.infrastructure.harbor_adapter.read_session`, which
|
|
13
|
+
fingerprints and parses each file in the same pass. The converter, the census,
|
|
14
|
+
the edges emitter and the enrichment pass all consume that one list of
|
|
15
|
+
records, so the trajectory, the loss report and ``edges.jsonl`` describe the
|
|
16
|
+
same bytes by construction. Before this, the converter and the audit each
|
|
17
|
+
parsed the files and the snapshot hashed them three times over.
|
|
18
|
+
|
|
19
|
+
SNAPSHOT DISCIPLINE: the fingerprints taken by that read are re-checked once
|
|
20
|
+
the artifacts are built. A Claude Code session that resumes writing during
|
|
21
|
+
the read, or anywhere before the check, fails the session: a stat pair alone
|
|
22
|
+
would miss a same-length rewrite inside one mtime tick, so the re-check hashes
|
|
23
|
+
the bytes again. That second hash pass is the one read the audit still pays
|
|
24
|
+
beyond the parse.
|
|
25
|
+
|
|
26
|
+
ATTACHMENTS AND TYPED SIGNALS. Before conversion, every inline base64
|
|
27
|
+
attachment in the loaded records is replaced by a placeholder and its bytes
|
|
28
|
+
collected (:mod:`atif_converter.domain.blobs`), so neither the trajectory nor
|
|
29
|
+
the loss report nor the edges ever carry base64. After enrichment,
|
|
30
|
+
:mod:`atif_converter.domain.result_signals` writes the typed tool-outcome,
|
|
31
|
+
attachment and subagent fields, the subagent links read from the same pass's
|
|
32
|
+
``agent-*.meta.json`` sidecars.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from loguru import logger
|
|
41
|
+
|
|
42
|
+
from atif_converter.domain.blobs import BlobCollector, extract_claude_code_blobs
|
|
43
|
+
from atif_converter.domain.edges import edges_jsonl_lines
|
|
44
|
+
from atif_converter.domain.enrichment import enrich_trajectory
|
|
45
|
+
from atif_converter.domain.errors import SourceMutatedDuringConversion
|
|
46
|
+
from atif_converter.domain.fidelity import (
|
|
47
|
+
CONVERTIBLE_RECORD_TYPES,
|
|
48
|
+
AnyRecordType,
|
|
49
|
+
FidelityGap,
|
|
50
|
+
LossReport,
|
|
51
|
+
)
|
|
52
|
+
from atif_converter.domain.result_signals import annotate_claude_code_trajectory
|
|
53
|
+
from atif_converter.domain.session_events import (
|
|
54
|
+
claude_reported_cost,
|
|
55
|
+
claude_session_events,
|
|
56
|
+
session_events_jsonl_lines,
|
|
57
|
+
)
|
|
58
|
+
from atif_converter.infrastructure.census import SessionCensus, census_from_snapshot
|
|
59
|
+
from atif_converter.infrastructure.harbor_adapter import (
|
|
60
|
+
ConversionResult,
|
|
61
|
+
convert_loaded_session,
|
|
62
|
+
read_session,
|
|
63
|
+
validate_trajectory,
|
|
64
|
+
)
|
|
65
|
+
from atif_converter.infrastructure.raw_records import (
|
|
66
|
+
LoadedSession,
|
|
67
|
+
SessionSnapshot,
|
|
68
|
+
mutated_files,
|
|
69
|
+
)
|
|
70
|
+
from atif_converter.infrastructure.source_archive import SourceArchiveWriter
|
|
71
|
+
|
|
72
|
+
#: ``agent-<id>.meta.json``: the sidecar's name carries the subagent id.
|
|
73
|
+
_SIDECAR_PREFIX = "agent-"
|
|
74
|
+
_SIDECAR_SUFFIX = ".meta.json"
|
|
75
|
+
|
|
76
|
+
#: Gaps every conversion exhibits regardless of session content. The parent
|
|
77
|
+
#: chain is flattened into a timestamp-sorted step list, and nothing after
|
|
78
|
+
#: conversion rebuilds it inside the trajectory (``edges.jsonl`` keeps it).
|
|
79
|
+
_STRUCTURAL_GAPS: frozenset[FidelityGap] = frozenset({FidelityGap.PARENT_CHAIN_FLATTENED})
|
|
80
|
+
|
|
81
|
+
_ENRICHMENT_REFUSAL_KEYS: tuple[str, ...] = (
|
|
82
|
+
"enrichment_unattributed_steps",
|
|
83
|
+
"enrichment_truncated_at_step",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _attributed_uuids(trajectory: dict[str, Any]) -> set[str]:
|
|
88
|
+
uuids: set[str] = set()
|
|
89
|
+
for step in trajectory.get("steps") or []:
|
|
90
|
+
extra = step.get("extra") if isinstance(step, dict) else None
|
|
91
|
+
source_uuids = extra.get("source_uuids") if isinstance(extra, dict) else None
|
|
92
|
+
if isinstance(source_uuids, list):
|
|
93
|
+
uuids.update(uuid for uuid in source_uuids if isinstance(uuid, str))
|
|
94
|
+
return uuids
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _has_cache_creation_usage(records: list[dict[str, Any]]) -> bool:
|
|
98
|
+
for record in records:
|
|
99
|
+
if record.get("type") != "assistant":
|
|
100
|
+
continue
|
|
101
|
+
message = record.get("message")
|
|
102
|
+
usage = message.get("usage") if isinstance(message, dict) else None
|
|
103
|
+
if isinstance(usage, dict) and isinstance(usage.get("cache_creation_input_tokens"), int):
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _unrepaired_gaps(enriched: dict[str, Any], records: list[dict[str, Any]]) -> set[FidelityGap]:
|
|
109
|
+
"""The three gaps enrichment repairs, reported only where it DIDN'T.
|
|
110
|
+
|
|
111
|
+
* ``uuid_not_preserved``: enrichment refused to attribute some step
|
|
112
|
+
(an unattributed-steps count or a truncation marker on the trajectory).
|
|
113
|
+
* ``compact_summary_unhandled``: a compaction-summary record exists and
|
|
114
|
+
its uuid landed in no step's ``source_uuids``, so no step carries the
|
|
115
|
+
``is_compact_summary`` flag for it.
|
|
116
|
+
* ``cache_split_partial``: assistant usage carries
|
|
117
|
+
``cache_creation_input_tokens`` but the trajectory has no
|
|
118
|
+
``cache_creation_total``.
|
|
119
|
+
"""
|
|
120
|
+
gaps: set[FidelityGap] = set()
|
|
121
|
+
extra = enriched.get("extra")
|
|
122
|
+
extra = extra if isinstance(extra, dict) else {}
|
|
123
|
+
if any(extra.get(key) is not None for key in _ENRICHMENT_REFUSAL_KEYS):
|
|
124
|
+
gaps.add(FidelityGap.UUID_NOT_PRESERVED)
|
|
125
|
+
compact_uuids = {
|
|
126
|
+
record["uuid"]
|
|
127
|
+
for record in records
|
|
128
|
+
if record.get("isCompactSummary") and isinstance(record.get("uuid"), str)
|
|
129
|
+
}
|
|
130
|
+
if compact_uuids and not compact_uuids <= _attributed_uuids(enriched):
|
|
131
|
+
gaps.add(FidelityGap.COMPACT_SUMMARY_UNHANDLED)
|
|
132
|
+
if _has_cache_creation_usage(records) and "cache_creation_total" not in extra:
|
|
133
|
+
gaps.add(FidelityGap.CACHE_SPLIT_PARTIAL)
|
|
134
|
+
return gaps
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _loss_report(
|
|
138
|
+
census: SessionCensus,
|
|
139
|
+
*,
|
|
140
|
+
captured: int,
|
|
141
|
+
unrepaired: set[FidelityGap],
|
|
142
|
+
) -> LossReport:
|
|
143
|
+
converted = sum(
|
|
144
|
+
count
|
|
145
|
+
for record_type, count in census.record_counts.items()
|
|
146
|
+
if record_type in CONVERTIBLE_RECORD_TYPES
|
|
147
|
+
)
|
|
148
|
+
total = sum(census.record_counts.values())
|
|
149
|
+
dropped = total - converted - captured
|
|
150
|
+
|
|
151
|
+
gaps = set(_STRUCTURAL_GAPS) | unrepaired
|
|
152
|
+
if dropped:
|
|
153
|
+
gaps.add(FidelityGap.NON_MESSAGE_RECORDS_DROPPED)
|
|
154
|
+
if census.subagent_files:
|
|
155
|
+
gaps.add(FidelityGap.SUBAGENTS_INLINED)
|
|
156
|
+
# Named rather than inlined: LossReport is shared by both agents, so its
|
|
157
|
+
# key type is the UNION of the two record taxonomies and a same-typed dict
|
|
158
|
+
# of one agent's members does not match the constructor overload without
|
|
159
|
+
# being widened here.
|
|
160
|
+
record_counts: dict[AnyRecordType, int] = {**census.record_counts}
|
|
161
|
+
return LossReport(
|
|
162
|
+
record_counts=record_counts,
|
|
163
|
+
records_converted=converted,
|
|
164
|
+
records_captured=captured,
|
|
165
|
+
records_dropped=dropped,
|
|
166
|
+
gaps_observed=frozenset(gaps),
|
|
167
|
+
subagent_files_found=len(census.subagent_files) + len(census.workflow_subagent_files),
|
|
168
|
+
# Our converter discovers every side file itself, workflow-nested ones
|
|
169
|
+
# included, so all of them are convertible.
|
|
170
|
+
subagent_files_convertible=len(census.subagent_files) + len(census.workflow_subagent_files),
|
|
171
|
+
workflow_subagent_files_found=len(census.workflow_subagent_files),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _stamp_reported_cost(enriched: dict[str, Any], reported: dict[str, Any]) -> None:
|
|
176
|
+
"""Put Claude Code's own cost figure beside the computed estimate."""
|
|
177
|
+
if not reported:
|
|
178
|
+
return
|
|
179
|
+
final_metrics = enriched.setdefault("final_metrics", {})
|
|
180
|
+
extra = final_metrics.get("extra")
|
|
181
|
+
if not isinstance(extra, dict):
|
|
182
|
+
extra = {}
|
|
183
|
+
final_metrics["extra"] = extra
|
|
184
|
+
extra.update(reported)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _sidecars_by_agent_id(loaded: LoadedSession) -> dict[str, Any]:
|
|
188
|
+
"""Parsed ``agent-<id>.meta.json`` sidecars keyed by ``<id>``; first path wins."""
|
|
189
|
+
by_agent: dict[str, Any] = {}
|
|
190
|
+
for path, value in loaded.sidecars.items():
|
|
191
|
+
name = path.name
|
|
192
|
+
if name.startswith(_SIDECAR_PREFIX) and name.endswith(_SIDECAR_SUFFIX):
|
|
193
|
+
agent_id = name[len(_SIDECAR_PREFIX) : -len(_SIDECAR_SUFFIX)]
|
|
194
|
+
if agent_id:
|
|
195
|
+
by_agent.setdefault(agent_id, value)
|
|
196
|
+
return by_agent
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _refuse_if_mutated(
|
|
200
|
+
snapshot: SessionSnapshot, archive: SourceArchiveWriter | None = None
|
|
201
|
+
) -> None:
|
|
202
|
+
mutated = mutated_files(snapshot, archive=archive)
|
|
203
|
+
if not mutated:
|
|
204
|
+
if archive is not None:
|
|
205
|
+
# The verifying read already archived the transcripts AND the
|
|
206
|
+
# parsed sidecars; the sweep takes only files nothing parsed.
|
|
207
|
+
archive.archive_side_files(
|
|
208
|
+
snapshot.session_jsonl,
|
|
209
|
+
already=(*snapshot.files, *snapshot.sidecar_fingerprints),
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
logger.warning(
|
|
213
|
+
"convert_and_audit: {} source file(s) changed while converting {}; refusing",
|
|
214
|
+
len(mutated),
|
|
215
|
+
snapshot.session_jsonl,
|
|
216
|
+
)
|
|
217
|
+
raise SourceMutatedDuringConversion(snapshot.session_jsonl, mutated)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def convert_and_audit(
|
|
221
|
+
session_jsonl: Path,
|
|
222
|
+
*,
|
|
223
|
+
include_subagents: bool = True,
|
|
224
|
+
archive: SourceArchiveWriter | None = None,
|
|
225
|
+
) -> tuple[ConversionResult, LossReport]:
|
|
226
|
+
"""Convert ``session_jsonl`` to ATIF and produce its loss accounting.
|
|
227
|
+
|
|
228
|
+
The returned :class:`ConversionResult` carries the ENRICHED trajectory
|
|
229
|
+
(``source_uuids`` / ``is_compact_summary`` on steps,
|
|
230
|
+
``cache_creation_total`` on the trajectory, see
|
|
231
|
+
:func:`~atif_converter.domain.enrichment.enrich_trajectory`) plus the
|
|
232
|
+
ready-to-write ``edges.jsonl`` lines derived from the RAW records, per
|
|
233
|
+
the corpus contract. Validation is re-run post-enrichment.
|
|
234
|
+
|
|
235
|
+
The census counts EVERY raw record, so ``LossReport.records_dropped``
|
|
236
|
+
is an upper bound on what the materialized trajectory is missing.
|
|
237
|
+
|
|
238
|
+
Note: ``records_converted`` counts raw user/assistant RECORDS, not ATIF
|
|
239
|
+
steps. harbor legitimately bundles several assistant events (one
|
|
240
|
+
``message.id``) into a single agent step, so the two numbers differ by
|
|
241
|
+
design.
|
|
242
|
+
|
|
243
|
+
With ``archive``, the verifying re-read also streams every source file
|
|
244
|
+
into that writer (see
|
|
245
|
+
:mod:`atif_converter.infrastructure.source_archive`): the archive costs
|
|
246
|
+
no extra open of a transcript and holds exactly the bytes that were
|
|
247
|
+
parsed. It is complete only when this function returns; on any raise the
|
|
248
|
+
caller must discard it.
|
|
249
|
+
|
|
250
|
+
Raises
|
|
251
|
+
------
|
|
252
|
+
InvalidSessionInput: ``session_jsonl`` is not an existing ``.jsonl`` file.
|
|
253
|
+
SourceMutatedDuringConversion: a source file changed anywhere between
|
|
254
|
+
the read and the end of the audit, so the artifacts would not
|
|
255
|
+
describe the session as it stands.
|
|
256
|
+
"""
|
|
257
|
+
loaded = read_session(session_jsonl)
|
|
258
|
+
snapshot = loaded.snapshot
|
|
259
|
+
collector = BlobCollector()
|
|
260
|
+
blob_index = extract_claude_code_blobs(
|
|
261
|
+
(record for records in loaded.records_by_file.values() for record in records),
|
|
262
|
+
collector,
|
|
263
|
+
)
|
|
264
|
+
result = convert_loaded_session(loaded, include_subagents=include_subagents)
|
|
265
|
+
|
|
266
|
+
pairs = loaded.record_pairs()
|
|
267
|
+
census = census_from_snapshot(snapshot, pairs)
|
|
268
|
+
edges_lines = tuple(edges_jsonl_lines(pairs))
|
|
269
|
+
events = claude_session_events(pairs)
|
|
270
|
+
events_lines = tuple(session_events_jsonl_lines(events))
|
|
271
|
+
records = [record for record, _src in pairs]
|
|
272
|
+
# The harbor trajectory has no other consumer, so enrich in place rather
|
|
273
|
+
# than deep-copying a whole large transcript's worth of steps.
|
|
274
|
+
enriched = enrich_trajectory(result.trajectory, records, copy_input=False)
|
|
275
|
+
annotate_claude_code_trajectory(
|
|
276
|
+
enriched, records, blob_index, sidecars=_sidecars_by_agent_id(loaded)
|
|
277
|
+
)
|
|
278
|
+
_stamp_reported_cost(enriched, claude_reported_cost(pairs))
|
|
279
|
+
report = _loss_report(
|
|
280
|
+
census,
|
|
281
|
+
captured=len(events),
|
|
282
|
+
unrepaired=_unrepaired_gaps(enriched, records),
|
|
283
|
+
)
|
|
284
|
+
del pairs, loaded, records, events
|
|
285
|
+
_refuse_if_mutated(snapshot, archive)
|
|
286
|
+
|
|
287
|
+
enriched_result = ConversionResult(
|
|
288
|
+
trajectory=enriched,
|
|
289
|
+
validation_errors=validate_trajectory(enriched),
|
|
290
|
+
edges_lines=edges_lines,
|
|
291
|
+
events_lines=events_lines,
|
|
292
|
+
blobs=collector.blobs,
|
|
293
|
+
)
|
|
294
|
+
return enriched_result, report
|