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,546 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The corpus reader the pipelines share, and the per-session file source under it.
|
|
4
|
+
|
|
5
|
+
:class:`CorpusReader` is what every pipeline walks: the window filter, the
|
|
6
|
+
newest-first order, the bounded memos and the transcript renderer, over one
|
|
7
|
+
:class:`~atif_analytics.domain.ports.SessionSource`. The default source is
|
|
8
|
+
:class:`TrajectoryFileSource`, which parses each session directory's
|
|
9
|
+
``trajectory.json`` steps and scans its ``edges.jsonl`` (CONTRACT-V2: the
|
|
10
|
+
pipelines read the MATERIALIZED corpus, not raw JSONL). atif-cli plugs in the
|
|
11
|
+
lake source instead when the lake holds the corpus.
|
|
12
|
+
|
|
13
|
+
Torn-set guard: like atif-duck, a session directory is visible only when
|
|
14
|
+
``meta.json`` exists (the corpus writer lands it last), so a crashed
|
|
15
|
+
writer's partial dir contributes nothing.
|
|
16
|
+
|
|
17
|
+
Stored names: materialize stores ``trajectory.json`` and ``edges.jsonl``
|
|
18
|
+
zstd-compressed, as ``<name>.zst``, and a corpus written before that keeps
|
|
19
|
+
the plain files until ``atif-sql corpus slim`` compresses them. Every reader
|
|
20
|
+
here resolves the stored file per session (:func:`stored_file`, compressed
|
|
21
|
+
first) and reads the same bytes either way. ``corpus slim`` keeps the plain
|
|
22
|
+
file's mtime on the compressed one, so the mtime bound doesn't move.
|
|
23
|
+
|
|
24
|
+
Session enumeration reads timestamps from ``edges.jsonl``, never from the
|
|
25
|
+
parsed steps: :meth:`CorpusReader.session_bounds` runs over every session
|
|
26
|
+
in the window on every tick, and parsing a trajectory (tool_result bodies
|
|
27
|
+
included) to learn one timestamp makes the cheap checkpoint skip cost as
|
|
28
|
+
much as the work it avoids.
|
|
29
|
+
|
|
30
|
+
Semantic notes (mirrors atif-duck's ``steps`` view; the shared projection is
|
|
31
|
+
:func:`~atif_analytics.domain.transcript.step_event`):
|
|
32
|
+
|
|
33
|
+
* ``Step.message`` is ``str | ContentPart[]``; the list branch joins the
|
|
34
|
+
parts' ``text`` fields with blank lines.
|
|
35
|
+
* the step uuid is ``extra.source_uuids[0]`` — the FIRST source uuid, the
|
|
36
|
+
documented primary raw-record key (same choice the VSS branch embeds on).
|
|
37
|
+
* ATIF ``source`` maps ``agent`` → ``assistant`` for the prompt surface, and
|
|
38
|
+
every user step carries its ``author``
|
|
39
|
+
(:mod:`atif_analytics.domain.authorship`).
|
|
40
|
+
* a result is an error when the converter's typed ``extra.is_error`` says so
|
|
41
|
+
(the flag the lake and the ``tool_results`` view carry), or when harbor's
|
|
42
|
+
``extra.tool_result_metadata.is_error`` does (the raw Claude Code flag, the
|
|
43
|
+
one this reader read before the typed flag existed; the two agree on every
|
|
44
|
+
Claude Code result, and Codex results carry only the typed one).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import io
|
|
50
|
+
import json
|
|
51
|
+
from collections import OrderedDict
|
|
52
|
+
from datetime import UTC, datetime
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import TYPE_CHECKING, Any
|
|
55
|
+
|
|
56
|
+
from loguru import logger
|
|
57
|
+
|
|
58
|
+
from atif_analytics.domain.config import TranscriptCaps
|
|
59
|
+
from atif_analytics.domain.transcript import (
|
|
60
|
+
StepEvent,
|
|
61
|
+
render_session_text,
|
|
62
|
+
session_kind,
|
|
63
|
+
step_event,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if TYPE_CHECKING:
|
|
67
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
68
|
+
|
|
69
|
+
from atif_analytics.domain.authorship import SessionKind
|
|
70
|
+
from atif_analytics.domain.ports import SessionBounds, SessionSource
|
|
71
|
+
|
|
72
|
+
TRAJECTORY_FILENAME = "trajectory.json"
|
|
73
|
+
EDGES_FILENAME = "edges.jsonl"
|
|
74
|
+
META_FILENAME = "meta.json"
|
|
75
|
+
#: The suffix a compressed artifact carries (twin of atif-corpus's, pinned in
|
|
76
|
+
#: atif-cli's tests).
|
|
77
|
+
COMPRESSED_SUFFIX = ".zst"
|
|
78
|
+
|
|
79
|
+
#: Parsed-step memo capacity. A single trajectory can decode to tens of MB
|
|
80
|
+
#: of tool_result strings, so this is an LRU rather than a grow-forever
|
|
81
|
+
#: dict: an unbounded memo pins the whole admitted corpus in RSS for the
|
|
82
|
+
#: run. The window only has to cover ONE session's intra-stage re-reads
|
|
83
|
+
#: (eligibility probe → render → uuid re-walk), not the whole batch.
|
|
84
|
+
DEFAULT_STEPS_CACHE_SIZE = 8
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_ts(raw: str | None) -> datetime | None:
|
|
88
|
+
"""Parse an ATIF ISO-8601 timestamp (``...Z``) to an aware UTC datetime."""
|
|
89
|
+
if not raw:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
dt = datetime.fromisoformat(raw)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return None
|
|
95
|
+
if dt.tzinfo is None:
|
|
96
|
+
dt = dt.replace(tzinfo=UTC)
|
|
97
|
+
return dt.astimezone(UTC)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _flatten_message(message: Any) -> str:
|
|
101
|
+
"""Flatten ATIF ``Step.message`` (str | ContentPart[]) to one text body."""
|
|
102
|
+
if isinstance(message, str):
|
|
103
|
+
return message
|
|
104
|
+
if isinstance(message, list):
|
|
105
|
+
parts: list[str] = []
|
|
106
|
+
for part in message:
|
|
107
|
+
if isinstance(part, dict):
|
|
108
|
+
text = part.get("text")
|
|
109
|
+
if isinstance(text, str) and text:
|
|
110
|
+
parts.append(text)
|
|
111
|
+
return "\n\n".join(parts)
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_trajectory_step(step: dict[str, Any]) -> StepEvent:
|
|
116
|
+
"""Project one raw ``trajectory.json`` step dict into a :class:`StepEvent`."""
|
|
117
|
+
extra = step.get("extra") or {}
|
|
118
|
+
calls: list[tuple[str | None, object]] = [
|
|
119
|
+
(call.get("function_name"), call.get("arguments"))
|
|
120
|
+
for call in step.get("tool_calls") or []
|
|
121
|
+
if isinstance(call, dict)
|
|
122
|
+
]
|
|
123
|
+
results: list[tuple[str | None, object, bool]] = []
|
|
124
|
+
observation = step.get("observation") or {}
|
|
125
|
+
for res in observation.get("results") or []:
|
|
126
|
+
if not isinstance(res, dict):
|
|
127
|
+
continue
|
|
128
|
+
res_extra = res.get("extra") or {}
|
|
129
|
+
meta = res_extra.get("tool_result_metadata") or {}
|
|
130
|
+
is_error = res_extra.get("is_error") is True or meta.get("is_error") is True
|
|
131
|
+
results.append((res.get("source_call_id"), res.get("content"), is_error))
|
|
132
|
+
return step_event(
|
|
133
|
+
ts=parse_ts(str(step.get("timestamp") or "")),
|
|
134
|
+
source=str(step.get("source") or ""),
|
|
135
|
+
text=_flatten_message(step.get("message")),
|
|
136
|
+
source_uuids=extra.get("source_uuids"),
|
|
137
|
+
is_sidechain=bool(extra.get("is_sidechain") or False),
|
|
138
|
+
is_compact_summary=bool(extra.get("is_compact_summary") or False),
|
|
139
|
+
tool_calls=calls,
|
|
140
|
+
tool_results=results,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def stored_file(session_dir: Path, name: str) -> Path | None:
|
|
145
|
+
"""The file ``name`` is stored under (``<name>.zst`` first, then ``<name>``), or ``None``."""
|
|
146
|
+
for candidate in (f"{name}{COMPRESSED_SUFFIX}", name):
|
|
147
|
+
path = session_dir / candidate
|
|
148
|
+
if path.is_file():
|
|
149
|
+
return path
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _read_bytes(path: Path) -> bytes:
|
|
154
|
+
if not path.name.endswith(COMPRESSED_SUFFIX):
|
|
155
|
+
return path.read_bytes()
|
|
156
|
+
import zstandard
|
|
157
|
+
|
|
158
|
+
with path.open("rb") as handle, zstandard.ZstdDecompressor().stream_reader(handle) as reader:
|
|
159
|
+
return reader.readall()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _lines(path: Path) -> Iterable[str]:
|
|
163
|
+
"""``path``'s decompressed text, line by line."""
|
|
164
|
+
with path.open("rb") as raw:
|
|
165
|
+
if path.name.endswith(COMPRESSED_SUFFIX):
|
|
166
|
+
import zstandard
|
|
167
|
+
|
|
168
|
+
with (
|
|
169
|
+
zstandard.ZstdDecompressor().stream_reader(raw) as reader,
|
|
170
|
+
io.TextIOWrapper(reader, encoding="utf-8") as text,
|
|
171
|
+
):
|
|
172
|
+
yield from text
|
|
173
|
+
else:
|
|
174
|
+
with io.TextIOWrapper(raw, encoding="utf-8") as text:
|
|
175
|
+
yield from text
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def complete_session_dirs(sessions_dir: Path) -> list[Path]:
|
|
179
|
+
"""Session dirs carrying meta.json + a stored trajectory (torn-set gate), sorted."""
|
|
180
|
+
if not sessions_dir.is_dir():
|
|
181
|
+
return []
|
|
182
|
+
out: list[Path] = []
|
|
183
|
+
for d in sorted(sessions_dir.iterdir()):
|
|
184
|
+
if not d.is_dir():
|
|
185
|
+
continue
|
|
186
|
+
if not (d / META_FILENAME).exists():
|
|
187
|
+
logger.warning("Skipping incomplete session dir {} (no meta.json)", d)
|
|
188
|
+
continue
|
|
189
|
+
if stored_file(d, TRAJECTORY_FILENAME) is not None:
|
|
190
|
+
out.append(d)
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def last_edge_ts(session_dir: Path) -> datetime | None:
|
|
195
|
+
"""Newest record timestamp from one session's ``edges.jsonl``.
|
|
196
|
+
|
|
197
|
+
The edges file is one small JSON object per raw record — no message
|
|
198
|
+
bodies, no tool_result content — so this is the cheap bound.
|
|
199
|
+
atif-converter sorts edges by ``(ts, uuid)``, but the scan takes the
|
|
200
|
+
max rather than the last line so a hand-written or re-ordered file
|
|
201
|
+
still yields the true newest timestamp.
|
|
202
|
+
"""
|
|
203
|
+
path = stored_file(session_dir, EDGES_FILENAME) or session_dir / EDGES_FILENAME
|
|
204
|
+
newest: datetime | None = None
|
|
205
|
+
try:
|
|
206
|
+
for raw_line in _lines(path):
|
|
207
|
+
line = raw_line.strip()
|
|
208
|
+
if not line:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
rec = json.loads(line)
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
continue
|
|
214
|
+
ts = parse_ts(rec.get("ts")) if isinstance(rec, dict) else None
|
|
215
|
+
if ts is not None and (newest is None or ts > newest):
|
|
216
|
+
newest = ts
|
|
217
|
+
except OSError as exc:
|
|
218
|
+
logger.warning(
|
|
219
|
+
"corpus_reader: unreadable edges for {} ({}); no last_ts bound",
|
|
220
|
+
session_dir.name,
|
|
221
|
+
exc,
|
|
222
|
+
)
|
|
223
|
+
return newest
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def trajectory_mtime(session_dir: Path) -> datetime | None:
|
|
227
|
+
"""The stored trajectory's mtime: the bound that moves when materialize rewrites a session."""
|
|
228
|
+
path = stored_file(session_dir, TRAJECTORY_FILENAME)
|
|
229
|
+
if path is None:
|
|
230
|
+
return None
|
|
231
|
+
try:
|
|
232
|
+
st = path.stat()
|
|
233
|
+
except OSError:
|
|
234
|
+
return None
|
|
235
|
+
return datetime.fromtimestamp(st.st_mtime, tz=UTC)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def read_trajectory_steps(session_dir: Path) -> list[StepEvent]:
|
|
239
|
+
"""Parse one session's stored trajectory steps (empty when unreadable)."""
|
|
240
|
+
path = stored_file(session_dir, TRAJECTORY_FILENAME) or session_dir / TRAJECTORY_FILENAME
|
|
241
|
+
try:
|
|
242
|
+
doc = json.loads(_read_bytes(path))
|
|
243
|
+
except (OSError, ValueError) as exc:
|
|
244
|
+
logger.warning("corpus_reader: unreadable trajectory for {} ({})", session_dir.name, exc)
|
|
245
|
+
return []
|
|
246
|
+
return [parse_trajectory_step(s) for s in (doc.get("steps") or []) if isinstance(s, dict)]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def read_edges_uuids(session_dir: Path) -> set[str] | None:
|
|
250
|
+
"""Non-null raw-record uuids from ``edges.jsonl``, or ``None`` if unreadable."""
|
|
251
|
+
path = stored_file(session_dir, EDGES_FILENAME) or session_dir / EDGES_FILENAME
|
|
252
|
+
out: set[str] = set()
|
|
253
|
+
try:
|
|
254
|
+
for raw_line in _lines(path):
|
|
255
|
+
line = raw_line.strip()
|
|
256
|
+
if not line:
|
|
257
|
+
continue
|
|
258
|
+
try:
|
|
259
|
+
rec = json.loads(line)
|
|
260
|
+
except json.JSONDecodeError:
|
|
261
|
+
continue
|
|
262
|
+
uuid = rec.get("uuid")
|
|
263
|
+
if isinstance(uuid, str) and uuid:
|
|
264
|
+
out.add(uuid)
|
|
265
|
+
except OSError as exc:
|
|
266
|
+
logger.warning("corpus_reader: unreadable edges for {} ({})", session_dir.name, exc)
|
|
267
|
+
return None
|
|
268
|
+
return out
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class TrajectoryFileSource:
|
|
272
|
+
"""The per-session file source: ``trajectory.json`` steps, ``edges.jsonl`` bounds.
|
|
273
|
+
|
|
274
|
+
Parsing costs per session, so it never reads ahead (batch size 1), and a
|
|
275
|
+
trajectory parse yields the tool payloads anyway, so :meth:`load_turns`
|
|
276
|
+
is :meth:`load_steps`.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
name: str = "files"
|
|
280
|
+
turns_batch_size: int = 1
|
|
281
|
+
steps_batch_size: int = 1
|
|
282
|
+
#: :meth:`load_turns` already carries the tool payloads, so the reader
|
|
283
|
+
#: keeps one memo for both and parses a session once.
|
|
284
|
+
turns_are_complete: bool = True
|
|
285
|
+
|
|
286
|
+
def __init__(self, corpus_root: Path) -> None:
|
|
287
|
+
self._sessions_dir = corpus_root / "sessions"
|
|
288
|
+
|
|
289
|
+
def session_bounds(self) -> SessionBounds:
|
|
290
|
+
"""``{session_id: (last_record_ts, trajectory_mtime)}`` over every complete dir."""
|
|
291
|
+
return {
|
|
292
|
+
d.name: (last_edge_ts(d), trajectory_mtime(d))
|
|
293
|
+
for d in complete_session_dirs(self._sessions_dir)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
def batchable(self, session_id: str) -> bool:
|
|
297
|
+
"""Never: a parse costs per session, so nothing is read ahead."""
|
|
298
|
+
del session_id
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
def load_steps(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
|
|
302
|
+
"""Parse each session's trajectory."""
|
|
303
|
+
return {sid: read_trajectory_steps(self._sessions_dir / sid) for sid in session_ids}
|
|
304
|
+
|
|
305
|
+
def load_turns(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
|
|
306
|
+
"""The same as :meth:`load_steps` (a parse yields the payloads anyway)."""
|
|
307
|
+
return self.load_steps(session_ids)
|
|
308
|
+
|
|
309
|
+
def edges_uuids(self, session_id: str) -> set[str] | None:
|
|
310
|
+
"""Non-null raw-record uuids from the session's ``edges.jsonl``."""
|
|
311
|
+
return read_edges_uuids(self._sessions_dir / session_id)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class CorpusReader:
|
|
315
|
+
"""The pipelines' view of one corpus, over a :class:`~atif_analytics.domain.ports.SessionSource`.
|
|
316
|
+
|
|
317
|
+
Two bounded memos. The steps memo holds sessions with their tool payloads
|
|
318
|
+
(what the renderer reads) so the repeated reads WITHIN one session's
|
|
319
|
+
admission (eligibility probe, transcript render, uuid re-walk) each pay
|
|
320
|
+
one load. The turns memo holds the gate-level steps a source can load
|
|
321
|
+
without the payloads. The bound is the point: a trajectory can decode to
|
|
322
|
+
tens of MB, one reader is shared by every stage, and an unbounded memo
|
|
323
|
+
would hold the whole admitted batch resident for the run. A stage that
|
|
324
|
+
revisits a session evicted from the window loads it again.
|
|
325
|
+
|
|
326
|
+
Every :meth:`session_bounds` call drops the memoized sessions whose
|
|
327
|
+
bounds moved since the last one. The stages share one reader, and
|
|
328
|
+
materialize can rewrite a session between two of them, so without this a
|
|
329
|
+
later stage would gate on the old steps and then checkpoint the session
|
|
330
|
+
at its new bounds, never looking at the new ones.
|
|
331
|
+
|
|
332
|
+
Read-ahead: a source whose one call costs about the same for many
|
|
333
|
+
sessions as for one (the lake) sets a batch size, and a turns miss then
|
|
334
|
+
loads that many sessions from the miss onward in the newest-first order of
|
|
335
|
+
the last :meth:`session_bounds`, which is the order every gate walks. The
|
|
336
|
+
file source's batch size is 1, so it loads exactly what is asked for.
|
|
337
|
+
Only sessions the source calls :meth:`~atif_analytics.domain.ports.SessionSource.batchable`
|
|
338
|
+
ride along, so a session the lake source would read from its files is
|
|
339
|
+
parsed only when a gate asks for it.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def __init__(
|
|
343
|
+
self,
|
|
344
|
+
corpus_root: Path,
|
|
345
|
+
*,
|
|
346
|
+
caps: TranscriptCaps | None = None,
|
|
347
|
+
source: SessionSource | None = None,
|
|
348
|
+
steps_cache_size: int = DEFAULT_STEPS_CACHE_SIZE,
|
|
349
|
+
) -> None:
|
|
350
|
+
self._root = corpus_root
|
|
351
|
+
self._caps = caps if caps is not None else TranscriptCaps()
|
|
352
|
+
self._source: SessionSource = (
|
|
353
|
+
source if source is not None else TrajectoryFileSource(corpus_root)
|
|
354
|
+
)
|
|
355
|
+
self._steps_cache: OrderedDict[str, list[StepEvent]] = OrderedDict()
|
|
356
|
+
self._steps_cache_size = max(1, steps_cache_size, self._source.steps_batch_size)
|
|
357
|
+
self._turns_cache: OrderedDict[str, list[StepEvent]] = OrderedDict()
|
|
358
|
+
self._turns_cache_size = 2 * max(1, self._source.turns_batch_size)
|
|
359
|
+
self._order: dict[str, int] = {}
|
|
360
|
+
self._ordered: list[str] = []
|
|
361
|
+
self._bounds: SessionBounds = {}
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def source_name(self) -> str:
|
|
365
|
+
"""Which source this reader reads (``files`` or ``lake``)."""
|
|
366
|
+
return self._source.name
|
|
367
|
+
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
# Session enumeration
|
|
370
|
+
# ------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
def session_bounds(
|
|
373
|
+
self, *, since_days: int | None = None, limit: int | None = None
|
|
374
|
+
) -> dict[str, tuple[datetime | None, datetime | None]]:
|
|
375
|
+
"""``{session_id: (last_record_ts, trajectory_mtime)}``, newest-first.
|
|
376
|
+
|
|
377
|
+
Drives the mtime-based checkpoint skip: either bound advancing
|
|
378
|
+
re-admits the session. ``since_days`` filters on the last record
|
|
379
|
+
timestamp; ``limit`` caps the newest-first list.
|
|
380
|
+
|
|
381
|
+
Both bounds come from cheap reads (the source never parses a
|
|
382
|
+
trajectory for them), because this runs once per stage per tick over
|
|
383
|
+
EVERY session in the window, including the ones the checkpoint is
|
|
384
|
+
about to skip. The order is also the read-ahead order.
|
|
385
|
+
"""
|
|
386
|
+
bounds = self._source.session_bounds()
|
|
387
|
+
self._forget_moved(bounds)
|
|
388
|
+
rows = [(sid, last_ts, mtime) for sid, (last_ts, mtime) in bounds.items()]
|
|
389
|
+
rows.sort(key=lambda r: r[0])
|
|
390
|
+
if since_days is not None:
|
|
391
|
+
cutoff = datetime.now(UTC).timestamp() - since_days * 86_400
|
|
392
|
+
rows = [r for r in rows if r[1] is not None and r[1].timestamp() >= cutoff]
|
|
393
|
+
epoch = datetime.min.replace(tzinfo=UTC)
|
|
394
|
+
rows.sort(key=lambda r: (r[1] is not None, r[1] or epoch), reverse=True)
|
|
395
|
+
if limit is not None:
|
|
396
|
+
rows = rows[: int(limit)]
|
|
397
|
+
self._ordered = [sid for sid, _, _ in rows]
|
|
398
|
+
self._order = {sid: i for i, sid in enumerate(self._ordered)}
|
|
399
|
+
return {sid: (last_ts, mtime) for sid, last_ts, mtime in rows}
|
|
400
|
+
|
|
401
|
+
def _forget_moved(self, bounds: SessionBounds) -> None:
|
|
402
|
+
"""Drop every memoized session whose bounds differ from the last enumeration's."""
|
|
403
|
+
for cache in (self._turns_cache, self._steps_cache):
|
|
404
|
+
for sid in [sid for sid in cache if bounds.get(sid) != self._bounds.get(sid)]:
|
|
405
|
+
del cache[sid]
|
|
406
|
+
self._bounds = bounds
|
|
407
|
+
|
|
408
|
+
def session_ids(self, *, since_days: int | None = None, limit: int | None = None) -> list[str]:
|
|
409
|
+
"""Newest-first session ids matching the window."""
|
|
410
|
+
return list(self.session_bounds(since_days=since_days, limit=limit))
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
# Per-session artifacts
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
@staticmethod
|
|
417
|
+
def _memoize(
|
|
418
|
+
cache: OrderedDict[str, list[StepEvent]], size: int, session_id: str, steps: list[StepEvent]
|
|
419
|
+
) -> list[StepEvent]:
|
|
420
|
+
"""Insert into an LRU, evicting the least-recently-used entry."""
|
|
421
|
+
cache[session_id] = steps
|
|
422
|
+
cache.move_to_end(session_id)
|
|
423
|
+
while len(cache) > size:
|
|
424
|
+
cache.popitem(last=False)
|
|
425
|
+
return steps
|
|
426
|
+
|
|
427
|
+
def _full_steps(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
|
|
428
|
+
"""Each session's steps with tool payloads, through the steps memo."""
|
|
429
|
+
out: dict[str, list[StepEvent]] = {}
|
|
430
|
+
missing: list[str] = []
|
|
431
|
+
for sid in session_ids:
|
|
432
|
+
cached = self._steps_cache.get(sid)
|
|
433
|
+
if cached is None:
|
|
434
|
+
missing.append(sid)
|
|
435
|
+
else:
|
|
436
|
+
self._steps_cache.move_to_end(sid)
|
|
437
|
+
out[sid] = cached
|
|
438
|
+
if missing:
|
|
439
|
+
loaded = self._source.load_steps(missing)
|
|
440
|
+
for sid in missing:
|
|
441
|
+
out[sid] = self._memoize(
|
|
442
|
+
self._steps_cache, self._steps_cache_size, sid, list(loaded.get(sid, []))
|
|
443
|
+
)
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
def _read_ahead(self, session_id: str) -> list[str]:
|
|
447
|
+
"""``session_id`` plus the next uncached sessions in the walk order, up to the batch."""
|
|
448
|
+
batch = [session_id]
|
|
449
|
+
size = self._source.turns_batch_size
|
|
450
|
+
start = self._order.get(session_id)
|
|
451
|
+
if size <= 1 or start is None:
|
|
452
|
+
return batch
|
|
453
|
+
for sid in self._ordered[start + 1 :]:
|
|
454
|
+
if len(batch) >= size:
|
|
455
|
+
break
|
|
456
|
+
if (
|
|
457
|
+
sid not in self._turns_cache
|
|
458
|
+
and sid not in self._steps_cache
|
|
459
|
+
and self._source.batchable(sid)
|
|
460
|
+
):
|
|
461
|
+
batch.append(sid)
|
|
462
|
+
return batch
|
|
463
|
+
|
|
464
|
+
def load_steps(self, session_id: str) -> list[StepEvent]:
|
|
465
|
+
"""One session's steps for the gates, memoized.
|
|
466
|
+
|
|
467
|
+
Every step with its text, flags, uuid, author and error flag; the
|
|
468
|
+
tool payload lists may be empty (the gates never read them, and the
|
|
469
|
+
lake source leaves them out). :meth:`session_text` loads the payloads.
|
|
470
|
+
"""
|
|
471
|
+
if self._source.turns_are_complete:
|
|
472
|
+
return self._full_steps([session_id])[session_id]
|
|
473
|
+
for cache in (self._turns_cache, self._steps_cache):
|
|
474
|
+
cached = cache.get(session_id)
|
|
475
|
+
if cached is not None:
|
|
476
|
+
cache.move_to_end(session_id)
|
|
477
|
+
return cached
|
|
478
|
+
batch = self._read_ahead(session_id)
|
|
479
|
+
loaded = self._source.load_turns(batch)
|
|
480
|
+
for sid in batch:
|
|
481
|
+
self._memoize(self._turns_cache, self._turns_cache_size, sid, list(loaded.get(sid, [])))
|
|
482
|
+
return self._turns_cache[session_id]
|
|
483
|
+
|
|
484
|
+
def session_text(self, session_id: str, *, include_uuids: bool = False) -> str:
|
|
485
|
+
"""One assembled transcript per the documented byte-shape contract."""
|
|
486
|
+
return self._render(self._full_steps([session_id])[session_id], include_uuids=include_uuids)
|
|
487
|
+
|
|
488
|
+
def session_texts(
|
|
489
|
+
self, session_ids: Sequence[str], *, include_uuids: bool = False
|
|
490
|
+
) -> Iterator[str]:
|
|
491
|
+
""":meth:`session_text` for each session in order, loading a source batch at a time.
|
|
492
|
+
|
|
493
|
+
Holds at most one batch of loaded sessions (the steps memo is at
|
|
494
|
+
least that large), so rendering a capped list costs a few source
|
|
495
|
+
calls instead of one per session.
|
|
496
|
+
"""
|
|
497
|
+
size = max(1, self._source.steps_batch_size)
|
|
498
|
+
for start in range(0, len(session_ids), size):
|
|
499
|
+
chunk = list(session_ids[start : start + size])
|
|
500
|
+
loaded = self._full_steps(chunk)
|
|
501
|
+
for sid in chunk:
|
|
502
|
+
yield self._render(loaded[sid], include_uuids=include_uuids)
|
|
503
|
+
|
|
504
|
+
def _render(self, steps: list[StepEvent], *, include_uuids: bool) -> str:
|
|
505
|
+
return render_session_text(
|
|
506
|
+
steps,
|
|
507
|
+
total_max_chars=self._caps.session_text_total_max_chars,
|
|
508
|
+
tool_result_max_chars=self._caps.session_text_tool_result_max_chars,
|
|
509
|
+
include_uuids=include_uuids,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def session_kind(self, session_id: str) -> SessionKind:
|
|
513
|
+
"""``interactive`` | ``one_shot_job`` | ``turn_audit`` (atif-duck's ``session_outcomes.kind``)."""
|
|
514
|
+
return session_kind(self.load_steps(session_id))
|
|
515
|
+
|
|
516
|
+
def edges_uuids(self, session_id: str) -> set[str] | None:
|
|
517
|
+
"""Non-null raw-record uuids, or ``None`` if unknown.
|
|
518
|
+
|
|
519
|
+
The conflicts pipeline validates the model's returned
|
|
520
|
+
``turn_*_uuid`` values against this set. ``None`` and an empty set
|
|
521
|
+
are DIFFERENT answers and callers must keep them apart: ``None``
|
|
522
|
+
means the uuid universe is unknown, so no returned uuid can be
|
|
523
|
+
confirmed or refuted, while an empty set means the session
|
|
524
|
+
genuinely has no raw-record uuids. Neither can validate anything,
|
|
525
|
+
so neither may be read as "the guard passes" — a model-returned
|
|
526
|
+
uuid that reaches the parquet unverified is indistinguishable from
|
|
527
|
+
a real one to every downstream view.
|
|
528
|
+
"""
|
|
529
|
+
return self._source.edges_uuids(session_id)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
__all__ = [
|
|
533
|
+
"DEFAULT_STEPS_CACHE_SIZE",
|
|
534
|
+
"EDGES_FILENAME",
|
|
535
|
+
"META_FILENAME",
|
|
536
|
+
"TRAJECTORY_FILENAME",
|
|
537
|
+
"CorpusReader",
|
|
538
|
+
"TrajectoryFileSource",
|
|
539
|
+
"complete_session_dirs",
|
|
540
|
+
"last_edge_ts",
|
|
541
|
+
"parse_trajectory_step",
|
|
542
|
+
"parse_ts",
|
|
543
|
+
"read_edges_uuids",
|
|
544
|
+
"read_trajectory_steps",
|
|
545
|
+
"trajectory_mtime",
|
|
546
|
+
]
|