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,641 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
r"""Write and detect the typed columnar artifacts (see :mod:`atif_duck.domain.columnar`).
|
|
4
|
+
|
|
5
|
+
Two jobs, both keyed on the contract in the domain module:
|
|
6
|
+
|
|
7
|
+
* :class:`ColumnarArtifactProducer` writes the five parquet files for one
|
|
8
|
+
session from its trajectory dict. The lake loader runs it to stage each
|
|
9
|
+
session it loads (:mod:`atif_duck.infrastructure.lake`); it still fits
|
|
10
|
+
atif-corpus's ``ArtifactProducer`` port, though materialize no longer
|
|
11
|
+
injects it. A corpus written before that keeps these files until
|
|
12
|
+
``atif-sql corpus slim`` removes them.
|
|
13
|
+
* :func:`session_has_columnar` and :func:`columnar_coverage` answer "may this
|
|
14
|
+
session be read from parquet?" for the registry and for ``atif-sql status``,
|
|
15
|
+
with one predicate so the two can never disagree.
|
|
16
|
+
|
|
17
|
+
Why the producer walks the dict instead of ``read_json`` over the stored
|
|
18
|
+
trajectory: DuckDB's JSON reader holds the whole document plus its
|
|
19
|
+
typed conversion in memory, which measured at 515 MB just to parse a 204 MB
|
|
20
|
+
session, against a materialize pass that otherwise peaks at 667 MB on the same
|
|
21
|
+
corpus. The dict is already in memory, so the producer streams it into DuckDB
|
|
22
|
+
as byte-bounded Arrow batches (one JSON text per step member, per tool call,
|
|
23
|
+
per result) and lets DuckDB do the typing.
|
|
24
|
+
|
|
25
|
+
Why one DuckDB statement per batch and a pyarrow writer, rather than one
|
|
26
|
+
``COPY ... TO`` per file: everything DuckDB allocates outside its buffer
|
|
27
|
+
manager (yyjson documents, string heaps, exported Arrow buffers) stays
|
|
28
|
+
resident in its bundled allocator until the statement ends, so a single COPY
|
|
29
|
+
over the 204 MB session measured +416 MB over the dict, and exporting the
|
|
30
|
+
typed rows as Arrow tables measured +230 MB, both roughly the size of the
|
|
31
|
+
text they had processed. Fetching each batch's typed rows as Python objects
|
|
32
|
+
(glibc-allocated, freed on the spot) and appending them to the parquet file
|
|
33
|
+
as one row group each measured +108 MB with 1 MiB batches. Row order is the
|
|
34
|
+
trajectory's array order by construction.
|
|
35
|
+
|
|
36
|
+
Why DuckDB still does the typing: the JSON columns (``tool_input``,
|
|
37
|
+
``content``, ``source_uuids``) must be byte-identical to what the JSON-path
|
|
38
|
+
views return, and those are yyjson's re-serialization of the value. Python's
|
|
39
|
+
``json.dumps`` output differs (``\u`` escapes, number spelling), but DuckDB
|
|
40
|
+
re-parses whatever text it is handed and re-serializes it, so passing
|
|
41
|
+
``json.dumps(value)`` in and reading a JSON column out lands on exactly the
|
|
42
|
+
bytes ``json_extract`` would have produced from ``trajectory.json``. The
|
|
43
|
+
column expressions themselves are shared with the views through
|
|
44
|
+
:mod:`atif_duck.infrastructure.projections`. TIMESTAMP columns cross into
|
|
45
|
+
Python as ``epoch_us`` integers and are cast back on the Arrow side, so the
|
|
46
|
+
value never passes through ``datetime`` and its range.
|
|
47
|
+
|
|
48
|
+
File mode: every parquet is written ``0o444``. The query sandbox grants each
|
|
49
|
+
one by path so a view can read it lazily, and a DuckDB path grant also permits
|
|
50
|
+
``COPY ... TO <path> (USE_TMP_FILE false)``. A read-only file turns that write
|
|
51
|
+
into an ``IO Error`` for any non-root user, which is what keeps injected SQL
|
|
52
|
+
from rewriting the transcript-derived surface it is reading.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import json
|
|
58
|
+
import os
|
|
59
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
60
|
+
from dataclasses import dataclass
|
|
61
|
+
from typing import TYPE_CHECKING, Any
|
|
62
|
+
|
|
63
|
+
from loguru import logger
|
|
64
|
+
|
|
65
|
+
from atif_duck.domain.columnar import (
|
|
66
|
+
COLUMNAR_FILENAMES,
|
|
67
|
+
COLUMNAR_SCHEMA_VERSION,
|
|
68
|
+
COLUMNAR_SCHEMAS,
|
|
69
|
+
META_COLUMNAR_KEY,
|
|
70
|
+
MIN_PARQUET_BYTES,
|
|
71
|
+
SESSION_COLUMNS,
|
|
72
|
+
SESSION_EVENTS_JSONL,
|
|
73
|
+
SESSION_EVENTS_PARQUET,
|
|
74
|
+
SESSION_PARQUET,
|
|
75
|
+
STEPS_PARQUET,
|
|
76
|
+
TOOL_CALLS_PARQUET,
|
|
77
|
+
TOOL_RESULTS_PARQUET,
|
|
78
|
+
columnar_paths,
|
|
79
|
+
is_current_columnar_schema,
|
|
80
|
+
)
|
|
81
|
+
from atif_duck.domain.session_id import session_id_rejection
|
|
82
|
+
from atif_duck.domain.sql_literal import SqlFragment, sql_literal
|
|
83
|
+
from atif_duck.infrastructure.projections import (
|
|
84
|
+
CALL_COLUMNS,
|
|
85
|
+
EVENT_COLUMNS,
|
|
86
|
+
MEMBER_COLUMNS,
|
|
87
|
+
RESULT_COLUMNS,
|
|
88
|
+
STEP_MEMBERS,
|
|
89
|
+
render,
|
|
90
|
+
step_columns,
|
|
91
|
+
step_key_columns,
|
|
92
|
+
)
|
|
93
|
+
from atif_duck.infrastructure.stored_artifacts import iter_lines
|
|
94
|
+
|
|
95
|
+
if TYPE_CHECKING:
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
|
|
98
|
+
import duckdb
|
|
99
|
+
|
|
100
|
+
# pyarrow ships no type information, so its schema and batch objects are
|
|
101
|
+
# annotated ``Any`` here (the same treatment atif-analytics gives them); the
|
|
102
|
+
# checker would otherwise report every one of them as Unknown.
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Detection (shared by the registry and `atif-sql status`)
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _readable_parquet(path: Path) -> bool:
|
|
110
|
+
try:
|
|
111
|
+
return path.stat().st_size > MIN_PARQUET_BYTES
|
|
112
|
+
except OSError:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def session_has_columnar(session_dir: Path, meta_columnar_schema: object) -> bool:
|
|
117
|
+
"""True when ``session_dir`` may be read from its columnar artifacts.
|
|
118
|
+
|
|
119
|
+
Two conditions, both required: ``meta.json``'s ``columnar_schema`` names
|
|
120
|
+
this build's schema version, and all five parquet files are present and
|
|
121
|
+
longer than a parquet header. A session failing either is read from
|
|
122
|
+
``trajectory.json``, which is always correct and merely slower, so this
|
|
123
|
+
predicate can only ever cost speed.
|
|
124
|
+
"""
|
|
125
|
+
return is_current_columnar_schema(meta_columnar_schema) and all(
|
|
126
|
+
_readable_parquet(path) for path in columnar_paths(session_dir)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class ColumnarCoverage:
|
|
132
|
+
"""How many complete sessions a corpus can serve from parquet versus JSON."""
|
|
133
|
+
|
|
134
|
+
#: Sessions the registry reads from their columnar artifacts.
|
|
135
|
+
columnar_sessions: int
|
|
136
|
+
#: Sessions the registry reads from ``trajectory.json``.
|
|
137
|
+
json_sessions: int
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def query_path(self) -> str:
|
|
141
|
+
"""``columnar``, ``json``, ``mixed``, or ``empty`` (no complete session)."""
|
|
142
|
+
if self.columnar_sessions and not self.json_sessions:
|
|
143
|
+
return "columnar"
|
|
144
|
+
if self.json_sessions and not self.columnar_sessions:
|
|
145
|
+
return "json"
|
|
146
|
+
if self.columnar_sessions and self.json_sessions:
|
|
147
|
+
return "mixed"
|
|
148
|
+
return "empty"
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def total_sessions(self) -> int:
|
|
152
|
+
"""Complete sessions of either kind."""
|
|
153
|
+
return self.columnar_sessions + self.json_sessions
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def columnar_coverage(corpus_root: Path) -> ColumnarCoverage:
|
|
157
|
+
"""Count columnar-readable and JSON-only sessions under ``corpus_root``.
|
|
158
|
+
|
|
159
|
+
Reads each session's ``meta.json`` (a torn dir without one is not a
|
|
160
|
+
session at all, exactly as the registry's meta gate sees it) and applies
|
|
161
|
+
:func:`session_has_columnar`. Used by ``atif-sql status`` so an operator
|
|
162
|
+
can see which path ``query`` will take without opening DuckDB.
|
|
163
|
+
"""
|
|
164
|
+
sessions_dir = corpus_root / "sessions"
|
|
165
|
+
if not sessions_dir.is_dir():
|
|
166
|
+
return ColumnarCoverage(columnar_sessions=0, json_sessions=0)
|
|
167
|
+
columnar = json_only = 0
|
|
168
|
+
for session_dir in sorted(sessions_dir.iterdir()):
|
|
169
|
+
if not session_dir.is_dir() or session_id_rejection(session_dir.name) is not None:
|
|
170
|
+
# The registry registers nothing from a dir whose name fails the
|
|
171
|
+
# session id boundary, so the coverage count must not see it either.
|
|
172
|
+
continue
|
|
173
|
+
try:
|
|
174
|
+
meta = json.loads((session_dir / "meta.json").read_text(encoding="utf-8"))
|
|
175
|
+
except (OSError, ValueError):
|
|
176
|
+
continue
|
|
177
|
+
schema = meta.get(META_COLUMNAR_KEY) if isinstance(meta, dict) else None
|
|
178
|
+
if session_has_columnar(session_dir, schema):
|
|
179
|
+
columnar += 1
|
|
180
|
+
else:
|
|
181
|
+
json_only += 1
|
|
182
|
+
return ColumnarCoverage(columnar_sessions=columnar, json_sessions=json_only)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Producer
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
#: Rows are batched into Arrow record batches by JSON text size, not row
|
|
190
|
+
#: count, so a session of a few huge tool results and a session of thousands
|
|
191
|
+
#: of tiny ones both stay inside a few MB of transient memory. One batch is
|
|
192
|
+
#: one DuckDB statement and one parquet row group. Measured on the 204 MB
|
|
193
|
+
#: session (201 MB of tool-result text): 1 MiB batches hold the producer's
|
|
194
|
+
#: peak at +108 MB over the trajectory dict, 4 MiB batches cost +184 MB, and
|
|
195
|
+
#: 512 KiB batches saved nothing further.
|
|
196
|
+
_BATCH_BYTES: int = 1024 * 1024
|
|
197
|
+
|
|
198
|
+
#: The relation every projection reads. One Arrow batch is registered under
|
|
199
|
+
#: this name per statement and unregistered right after.
|
|
200
|
+
_SOURCE: str = "batch_src"
|
|
201
|
+
|
|
202
|
+
#: Read-only for owner, group, and world: see the module docstring.
|
|
203
|
+
_ARTIFACT_MODE: int = 0o444
|
|
204
|
+
|
|
205
|
+
#: Parquet compression for every artifact.
|
|
206
|
+
_COMPRESSION: str = "zstd"
|
|
207
|
+
|
|
208
|
+
_COMPACT: tuple[str, str] = (",", ":")
|
|
209
|
+
|
|
210
|
+
#: DuckDB types that map to an Arrow type one to one. JSON, TIMESTAMP, and
|
|
211
|
+
#: STRUCT are handled by :func:`_arrow_type` itself.
|
|
212
|
+
_FLAT_ARROW_TYPES: tuple[str, ...] = ("VARCHAR", "BIGINT", "DOUBLE", "BOOLEAN")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _dumps(value: Any) -> str:
|
|
216
|
+
"""Serialize exactly as the corpus writer serializes ``trajectory.json``.
|
|
217
|
+
|
|
218
|
+
Not required for equivalence (DuckDB re-serializes on the way in) but
|
|
219
|
+
keeps the producer's input the same text the JSON path parses.
|
|
220
|
+
"""
|
|
221
|
+
return json.dumps(value, separators=_COMPACT)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _member(obj: Mapping[str, Any], key: str) -> str | None:
|
|
225
|
+
"""JSON text of ``obj[key]``, or SQL NULL when the key is absent.
|
|
226
|
+
|
|
227
|
+
Mirrors ``json_extract(step, '$.key')``: a missing key is NULL, a present
|
|
228
|
+
``null`` is the JSON value ``null``. The two behave the same under every
|
|
229
|
+
downstream cast except ``json_type``, and the message flattener reads
|
|
230
|
+
both as "not an ARRAY", so the distinction is preserved rather than
|
|
231
|
+
reasoned away.
|
|
232
|
+
"""
|
|
233
|
+
return _dumps(obj[key]) if key in obj else None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _step_rows(session_id: str, steps: Iterable[Any]) -> Iterator[tuple[str | None, ...]]:
|
|
237
|
+
for step in steps:
|
|
238
|
+
if not isinstance(step, Mapping):
|
|
239
|
+
continue
|
|
240
|
+
yield (session_id, *(_member(step, key) for key in STEP_MEMBERS))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _call_rows(session_id: str, steps: Iterable[Any]) -> Iterator[tuple[str | None, ...]]:
|
|
244
|
+
# Mirrors ``WHERE json_extract(step, '$.tool_calls') IS NOT NULL`` then
|
|
245
|
+
# ``UNNEST(json_extract(step, '$.tool_calls[*]'))``: only a list yields rows.
|
|
246
|
+
for step in steps:
|
|
247
|
+
if not isinstance(step, Mapping):
|
|
248
|
+
continue
|
|
249
|
+
calls = step.get("tool_calls")
|
|
250
|
+
if not isinstance(calls, list):
|
|
251
|
+
continue
|
|
252
|
+
step_id, ts = _member(step, "step_id"), _member(step, "timestamp")
|
|
253
|
+
for call in calls:
|
|
254
|
+
yield (session_id, step_id, ts, _dumps(call))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _result_rows(session_id: str, steps: Iterable[Any]) -> Iterator[tuple[str | None, ...]]:
|
|
258
|
+
for step in steps:
|
|
259
|
+
if not isinstance(step, Mapping):
|
|
260
|
+
continue
|
|
261
|
+
observation = step.get("observation")
|
|
262
|
+
results = observation.get("results") if isinstance(observation, Mapping) else None
|
|
263
|
+
if not isinstance(results, list):
|
|
264
|
+
continue
|
|
265
|
+
step_id, ts = _member(step, "step_id"), _member(step, "timestamp")
|
|
266
|
+
for result in results:
|
|
267
|
+
yield (session_id, step_id, ts, _dumps(result))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _event_rows(session_id: str, events_jsonl: Path | None) -> Iterator[tuple[str | None, ...]]:
|
|
271
|
+
"""One ``(session_id, line)`` row per non-blank line of the events file.
|
|
272
|
+
|
|
273
|
+
The file may be stored compressed. A missing file yields nothing, so the
|
|
274
|
+
artifact is an empty parquet with the right columns: a session written
|
|
275
|
+
before the events artifact existed has none.
|
|
276
|
+
"""
|
|
277
|
+
if events_jsonl is None:
|
|
278
|
+
return
|
|
279
|
+
for line in iter_lines(events_jsonl):
|
|
280
|
+
text = line.strip()
|
|
281
|
+
if text:
|
|
282
|
+
yield (session_id, text)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _batches(rows: Iterable[tuple[str | None, ...]], schema: Any) -> Iterator[Any]:
|
|
286
|
+
"""Group rows into ``pyarrow.RecordBatch``es of about :data:`_BATCH_BYTES` of text."""
|
|
287
|
+
import pyarrow as pa
|
|
288
|
+
|
|
289
|
+
width = len(schema)
|
|
290
|
+
columns: list[list[str | None]] = [[] for _ in range(width)]
|
|
291
|
+
size = 0
|
|
292
|
+
for row in rows:
|
|
293
|
+
for index, value in enumerate(row):
|
|
294
|
+
columns[index].append(value)
|
|
295
|
+
if value is not None:
|
|
296
|
+
size += len(value)
|
|
297
|
+
if size >= _BATCH_BYTES:
|
|
298
|
+
yield pa.record_batch(columns, schema=schema)
|
|
299
|
+
columns = [[] for _ in range(width)]
|
|
300
|
+
size = 0
|
|
301
|
+
# Always emit the tail, even when empty: an empty parquet with the right
|
|
302
|
+
# columns is the correct artifact for a session with no tool calls.
|
|
303
|
+
yield pa.record_batch(columns, schema=schema)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _string_schema(names: Iterable[str]) -> Any:
|
|
307
|
+
import pyarrow as pa
|
|
308
|
+
|
|
309
|
+
return pa.schema([pa.field(name, pa.string()) for name in names])
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _struct_members(struct_type: str) -> tuple[tuple[str, str], ...]:
|
|
313
|
+
"""``(name, type)`` members of a flat DuckDB ``STRUCT(a T, b U)`` type string.
|
|
314
|
+
|
|
315
|
+
Only the two flat struct shapes in :data:`SESSION_COLUMNS` reach here, so
|
|
316
|
+
a comma-split over the member list is sufficient.
|
|
317
|
+
"""
|
|
318
|
+
inner = struct_type[len("STRUCT(") : -1]
|
|
319
|
+
members: list[tuple[str, str]] = []
|
|
320
|
+
for part in inner.split(","):
|
|
321
|
+
name, sql_type = part.strip().split(" ", 1)
|
|
322
|
+
members.append((name, sql_type))
|
|
323
|
+
return tuple(members)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _struct_spec(struct_type: str) -> str:
|
|
327
|
+
"""Render a DuckDB ``STRUCT`` type as ``json_transform``'s JSON spec."""
|
|
328
|
+
return json.dumps(dict(_struct_members(struct_type)))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _session_row_sql(session_id_literal: SqlFragment) -> SqlFragment:
|
|
332
|
+
"""The one-row ``session.parquet`` projection over the registered batch.
|
|
333
|
+
|
|
334
|
+
Each trajectory member arrives as JSON text; ``json_transform`` types the
|
|
335
|
+
two structs with the same shape the JSON reader projects them to, so the
|
|
336
|
+
``sessions`` view reads identical struct fields either way.
|
|
337
|
+
"""
|
|
338
|
+
typed: list[str] = [f"{session_id_literal} AS session_id_path"]
|
|
339
|
+
for name, sql_type in SESSION_COLUMNS[1:]:
|
|
340
|
+
if sql_type.startswith("STRUCT"):
|
|
341
|
+
typed.append(
|
|
342
|
+
f"CAST(json_transform({name}, {sql_literal(_struct_spec(sql_type))}) AS {sql_type}) AS {name}"
|
|
343
|
+
)
|
|
344
|
+
elif sql_type == "JSON":
|
|
345
|
+
typed.append(f"json_extract({name}, '$') AS {name}")
|
|
346
|
+
else:
|
|
347
|
+
typed.append(f"json_extract_string({name}, '$')::{sql_type} AS {name}")
|
|
348
|
+
# Every name and type comes from SESSION_COLUMNS; the only literal is the
|
|
349
|
+
# sql_literal-escaped session id.
|
|
350
|
+
return SqlFragment("SELECT " + ", ".join(typed) + f" FROM {_SOURCE}") # noqa: S608 # nosec B608 - SESSION_COLUMNS constants; the id came through sql_literal
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _arrow_type(sql_type: str) -> Any:
|
|
354
|
+
"""The Arrow type DuckDB's ``read_parquet`` maps back to exactly ``sql_type``.
|
|
355
|
+
|
|
356
|
+
JSON is the canonical ``arrow.json`` extension (parquet's JSON logical
|
|
357
|
+
type, which DuckDB reads as JSON), TIMESTAMP is microseconds without a
|
|
358
|
+
zone (DuckDB's own TIMESTAMP), and a STRUCT is built member by member.
|
|
359
|
+
Anything else the catalog might grow is a hard error here rather than a
|
|
360
|
+
silently wrong file.
|
|
361
|
+
"""
|
|
362
|
+
import pyarrow as pa
|
|
363
|
+
|
|
364
|
+
if sql_type == "JSON":
|
|
365
|
+
return pa.json_(pa.string())
|
|
366
|
+
if sql_type == "TIMESTAMP":
|
|
367
|
+
return pa.timestamp("us")
|
|
368
|
+
if sql_type.startswith("STRUCT("):
|
|
369
|
+
return pa.struct([pa.field(name, _arrow_type(t)) for name, t in _struct_members(sql_type)])
|
|
370
|
+
if sql_type in _FLAT_ARROW_TYPES:
|
|
371
|
+
flat = {
|
|
372
|
+
"VARCHAR": pa.string(),
|
|
373
|
+
"BIGINT": pa.int64(),
|
|
374
|
+
"DOUBLE": pa.float64(),
|
|
375
|
+
"BOOLEAN": pa.bool_(),
|
|
376
|
+
}
|
|
377
|
+
return flat[sql_type]
|
|
378
|
+
msg = f"columnar producer has no Arrow mapping for DuckDB type {sql_type!r}"
|
|
379
|
+
raise ValueError(msg)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _arrow_schema(columns: Sequence[tuple[str, str]]) -> Any:
|
|
383
|
+
import pyarrow as pa
|
|
384
|
+
|
|
385
|
+
return pa.schema([pa.field(name, _arrow_type(sql_type)) for name, sql_type in columns])
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _fetch_select(columns: Sequence[tuple[str, str]], inner_sql: SqlFragment) -> SqlFragment:
|
|
389
|
+
"""Wrap a projection so its rows cross into Python losslessly.
|
|
390
|
+
|
|
391
|
+
Columns are picked by name in catalog order, so the file's column order is
|
|
392
|
+
the catalog's whatever order the projection spells them in. TIMESTAMP
|
|
393
|
+
columns travel as ``epoch_us`` integers (see the module docstring); a
|
|
394
|
+
STRUCT member typed TIMESTAMP would need the same treatment and has no
|
|
395
|
+
caller today, so it is refused rather than mis-typed.
|
|
396
|
+
"""
|
|
397
|
+
picks: list[str] = []
|
|
398
|
+
for name, sql_type in columns:
|
|
399
|
+
if sql_type == "TIMESTAMP":
|
|
400
|
+
picks.append(f'epoch_us("{name}") AS "{name}"')
|
|
401
|
+
continue
|
|
402
|
+
if sql_type.startswith("STRUCT(") and any(
|
|
403
|
+
member_type == "TIMESTAMP" for _, member_type in _struct_members(sql_type)
|
|
404
|
+
):
|
|
405
|
+
msg = f"columnar producer cannot fetch a TIMESTAMP struct member: {name} {sql_type}"
|
|
406
|
+
raise ValueError(msg)
|
|
407
|
+
picks.append(f'"{name}"')
|
|
408
|
+
# Column names are catalog constants; inner_sql is built from constants.
|
|
409
|
+
return SqlFragment(f"SELECT {', '.join(picks)} FROM ({inner_sql})") # noqa: S608 # nosec B608 - catalog column names over a constant-built inner SELECT
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _arrow_array(values: list[Any], sql_type: str) -> Any:
|
|
413
|
+
"""Type one fetched column (Python objects from DuckDB) as :func:`_arrow_type` says."""
|
|
414
|
+
import pyarrow as pa
|
|
415
|
+
|
|
416
|
+
if sql_type == "JSON":
|
|
417
|
+
# DuckDB hands JSON to Python as its normalized text.
|
|
418
|
+
return pa.ExtensionArray.from_storage(pa.json_(pa.string()), pa.array(values, pa.string()))
|
|
419
|
+
if sql_type == "TIMESTAMP":
|
|
420
|
+
return pa.array(values, pa.int64()).cast(pa.timestamp("us"))
|
|
421
|
+
if sql_type.startswith("STRUCT("):
|
|
422
|
+
# DuckDB hands a STRUCT to Python as a dict (or None for a NULL struct).
|
|
423
|
+
members = _struct_members(sql_type)
|
|
424
|
+
arrays = [
|
|
425
|
+
_arrow_array([None if value is None else value[name] for value in values], member_type)
|
|
426
|
+
for name, member_type in members
|
|
427
|
+
]
|
|
428
|
+
fields = [pa.field(name, _arrow_type(member_type)) for name, member_type in members]
|
|
429
|
+
mask = pa.array([value is None for value in values], pa.bool_())
|
|
430
|
+
return pa.StructArray.from_arrays(arrays, fields=fields, mask=mask)
|
|
431
|
+
return pa.array(values, _arrow_type(sql_type))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _arrow_table(
|
|
435
|
+
rows: list[tuple[Any, ...]], columns: Sequence[tuple[str, str]], schema: Any
|
|
436
|
+
) -> Any:
|
|
437
|
+
import pyarrow as pa
|
|
438
|
+
|
|
439
|
+
fetched: list[list[Any]] = [[] for _ in columns]
|
|
440
|
+
for row in rows:
|
|
441
|
+
for index, value in enumerate(row):
|
|
442
|
+
fetched[index].append(value)
|
|
443
|
+
arrays = [
|
|
444
|
+
_arrow_array(values, sql_type)
|
|
445
|
+
for values, (_, sql_type) in zip(fetched, columns, strict=True)
|
|
446
|
+
]
|
|
447
|
+
return pa.table(arrays, schema=schema)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class ColumnarArtifactProducer:
|
|
451
|
+
"""Write a session's typed columnar artifacts into its staged directory.
|
|
452
|
+
|
|
453
|
+
Satisfies atif-corpus's ``ArtifactProducer`` port structurally (the two
|
|
454
|
+
packages may not import each other). Each call opens its own in-memory
|
|
455
|
+
DuckDB connection and closes it before returning: DuckDB's allocator
|
|
456
|
+
keeps a connection's freed memory resident, and over a pass of hundreds
|
|
457
|
+
of sessions a shared connection measured 50 MB higher at peak and 140 MB
|
|
458
|
+
higher between sessions than a fresh one, against 9 ms to open one.
|
|
459
|
+
``duckdb`` and ``pyarrow`` are imported on first use so importing this
|
|
460
|
+
module stays cheap for the commands that only detect artifacts.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
def __init__(self, *, threads: int = 2, durable: bool = True) -> None:
|
|
464
|
+
#: DuckDB worker threads for the per-batch projections. Two is enough
|
|
465
|
+
#: to overlap Python's batch production with DuckDB's parsing; more
|
|
466
|
+
#: only adds in-flight allocations to the peak.
|
|
467
|
+
self.threads = threads
|
|
468
|
+
#: fsync each file before returning. The lake loader stages files it
|
|
469
|
+
#: deletes a moment later and turns this off.
|
|
470
|
+
self.durable = durable
|
|
471
|
+
|
|
472
|
+
def _connection(self) -> duckdb.DuckDBPyConnection:
|
|
473
|
+
import duckdb
|
|
474
|
+
|
|
475
|
+
con = duckdb.connect()
|
|
476
|
+
con.execute(f"SET threads={int(self.threads)}")
|
|
477
|
+
return con
|
|
478
|
+
|
|
479
|
+
def produce(
|
|
480
|
+
self,
|
|
481
|
+
session_dir: Path,
|
|
482
|
+
*,
|
|
483
|
+
session_id: str,
|
|
484
|
+
trajectory: Mapping[str, Any],
|
|
485
|
+
events_path: Path | None = None,
|
|
486
|
+
) -> Mapping[str, Any]:
|
|
487
|
+
"""Write the five parquet files into ``session_dir``; return the meta extras.
|
|
488
|
+
|
|
489
|
+
Parameters
|
|
490
|
+
----------
|
|
491
|
+
session_dir
|
|
492
|
+
The STAGED session directory (the corpus writer swaps it into
|
|
493
|
+
``sessions/<session_id>/`` afterwards, so no path inside the
|
|
494
|
+
files depends on where the directory ends up).
|
|
495
|
+
session_id
|
|
496
|
+
The canonical session key (the directory name the corpus writer
|
|
497
|
+
will publish under), stamped into every row as ``session_id``.
|
|
498
|
+
trajectory
|
|
499
|
+
The ATIF trajectory dict the corpus writer just serialized.
|
|
500
|
+
events_path
|
|
501
|
+
The session's events file (plain or compressed). Default: the
|
|
502
|
+
``session_events.jsonl`` staged in ``session_dir``.
|
|
503
|
+
|
|
504
|
+
Returns
|
|
505
|
+
-------
|
|
506
|
+
Mapping[str, Any]
|
|
507
|
+
``{"columnar_schema": COLUMNAR_SCHEMA_VERSION}`` for ``meta.json``.
|
|
508
|
+
|
|
509
|
+
Raises
|
|
510
|
+
------
|
|
511
|
+
duckdb.Error, OSError, pyarrow.ArrowException
|
|
512
|
+
Propagated: the corpus writer records the session as failed and
|
|
513
|
+
the staged directory never publishes.
|
|
514
|
+
"""
|
|
515
|
+
con = self._connection()
|
|
516
|
+
events = session_dir / SESSION_EVENTS_JSONL if events_path is None else events_path
|
|
517
|
+
try:
|
|
518
|
+
self._produce(con, session_dir, session_id, trajectory, events, durable=self.durable)
|
|
519
|
+
finally:
|
|
520
|
+
con.close()
|
|
521
|
+
logger.debug("columnar: wrote {} for session {}", COLUMNAR_FILENAMES, session_id)
|
|
522
|
+
return {META_COLUMNAR_KEY: COLUMNAR_SCHEMA_VERSION}
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
def _produce(
|
|
526
|
+
con: duckdb.DuckDBPyConnection,
|
|
527
|
+
session_dir: Path,
|
|
528
|
+
session_id: str,
|
|
529
|
+
trajectory: Mapping[str, Any],
|
|
530
|
+
events_path: Path,
|
|
531
|
+
*,
|
|
532
|
+
durable: bool,
|
|
533
|
+
) -> None:
|
|
534
|
+
steps = trajectory.get("steps")
|
|
535
|
+
step_list: list[Any] = steps if isinstance(steps, list) else []
|
|
536
|
+
session_literal = sql_literal(session_id)
|
|
537
|
+
|
|
538
|
+
session_schema = _string_schema(name for name, _ in SESSION_COLUMNS[1:])
|
|
539
|
+
session_row = tuple(_member(trajectory, name) for name, _ in SESSION_COLUMNS[1:])
|
|
540
|
+
ColumnarArtifactProducer._write(
|
|
541
|
+
con,
|
|
542
|
+
_batches([session_row], session_schema),
|
|
543
|
+
_session_row_sql(session_literal),
|
|
544
|
+
COLUMNAR_SCHEMAS[SESSION_PARQUET],
|
|
545
|
+
session_dir / SESSION_PARQUET,
|
|
546
|
+
durable=durable,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
steps_schema = _string_schema(("session_id", *STEP_MEMBERS))
|
|
550
|
+
ColumnarArtifactProducer._write(
|
|
551
|
+
con,
|
|
552
|
+
_batches(_step_rows(session_id, step_list), steps_schema),
|
|
553
|
+
SqlFragment(
|
|
554
|
+
f"SELECT session_id, {render(step_columns(MEMBER_COLUMNS))} FROM {_SOURCE}" # noqa: S608 # nosec B608 - projections constants
|
|
555
|
+
),
|
|
556
|
+
COLUMNAR_SCHEMAS[STEPS_PARQUET],
|
|
557
|
+
session_dir / STEPS_PARQUET,
|
|
558
|
+
durable=durable,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
calls_schema = _string_schema(("session_id", "step_id", "timestamp", "call"))
|
|
562
|
+
ColumnarArtifactProducer._write(
|
|
563
|
+
con,
|
|
564
|
+
_batches(_call_rows(session_id, step_list), calls_schema),
|
|
565
|
+
SqlFragment(
|
|
566
|
+
"SELECT session_id, " # noqa: S608 # nosec B608 - projections constants only
|
|
567
|
+
f"{render(step_key_columns(MEMBER_COLUMNS))}, {render(CALL_COLUMNS)} FROM {_SOURCE}"
|
|
568
|
+
),
|
|
569
|
+
COLUMNAR_SCHEMAS[TOOL_CALLS_PARQUET],
|
|
570
|
+
session_dir / TOOL_CALLS_PARQUET,
|
|
571
|
+
durable=durable,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
results_schema = _string_schema(("session_id", "step_id", "timestamp", "res"))
|
|
575
|
+
ColumnarArtifactProducer._write(
|
|
576
|
+
con,
|
|
577
|
+
_batches(_result_rows(session_id, step_list), results_schema),
|
|
578
|
+
SqlFragment(
|
|
579
|
+
"SELECT session_id, " # noqa: S608 # nosec B608 - projections constants only
|
|
580
|
+
f"{render(step_key_columns(MEMBER_COLUMNS))}, {render(RESULT_COLUMNS)} FROM {_SOURCE}"
|
|
581
|
+
),
|
|
582
|
+
COLUMNAR_SCHEMAS[TOOL_RESULTS_PARQUET],
|
|
583
|
+
session_dir / TOOL_RESULTS_PARQUET,
|
|
584
|
+
durable=durable,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
events_schema = _string_schema(("session_id", "ev"))
|
|
588
|
+
ColumnarArtifactProducer._write(
|
|
589
|
+
con,
|
|
590
|
+
_batches(_event_rows(session_id, events_path), events_schema),
|
|
591
|
+
SqlFragment(
|
|
592
|
+
f"SELECT session_id, {render(EVENT_COLUMNS)} FROM {_SOURCE}" # noqa: S608 # nosec B608 - projections constants only
|
|
593
|
+
),
|
|
594
|
+
COLUMNAR_SCHEMAS[SESSION_EVENTS_PARQUET],
|
|
595
|
+
session_dir / SESSION_EVENTS_PARQUET,
|
|
596
|
+
durable=durable,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def _write(
|
|
601
|
+
con: duckdb.DuckDBPyConnection,
|
|
602
|
+
batches: Iterator[Any],
|
|
603
|
+
select_sql: SqlFragment,
|
|
604
|
+
columns: Sequence[tuple[str, str]],
|
|
605
|
+
target: Path,
|
|
606
|
+
*,
|
|
607
|
+
durable: bool = True,
|
|
608
|
+
) -> None:
|
|
609
|
+
"""Project each batch through DuckDB and append it to ``target`` as one row group."""
|
|
610
|
+
import pyarrow.parquet as pq
|
|
611
|
+
|
|
612
|
+
schema = _arrow_schema(columns)
|
|
613
|
+
fetch_sql = _fetch_select(columns, select_sql)
|
|
614
|
+
writer = pq.ParquetWriter(str(target), schema, compression=_COMPRESSION)
|
|
615
|
+
try:
|
|
616
|
+
for batch in batches:
|
|
617
|
+
con.register(_SOURCE, batch)
|
|
618
|
+
try:
|
|
619
|
+
rows = con.execute(fetch_sql).fetchall()
|
|
620
|
+
finally:
|
|
621
|
+
con.unregister(_SOURCE)
|
|
622
|
+
writer.write_table(_arrow_table(rows, columns, schema))
|
|
623
|
+
finally:
|
|
624
|
+
writer.close()
|
|
625
|
+
# Durability matches the JSON artifacts: bytes on stable storage before
|
|
626
|
+
# meta.json publishes them. Then read-only, per the module docstring.
|
|
627
|
+
if durable:
|
|
628
|
+
fd = os.open(target, os.O_RDONLY)
|
|
629
|
+
try:
|
|
630
|
+
os.fsync(fd)
|
|
631
|
+
finally:
|
|
632
|
+
os.close(fd)
|
|
633
|
+
target.chmod(_ARTIFACT_MODE)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
__all__ = [
|
|
637
|
+
"ColumnarArtifactProducer",
|
|
638
|
+
"ColumnarCoverage",
|
|
639
|
+
"columnar_coverage",
|
|
640
|
+
"session_has_columnar",
|
|
641
|
+
]
|