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,9 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Pure domain layer for corpus materialization.
|
|
4
|
+
|
|
5
|
+
Value objects and pure functions only: no filesystem, no env, no clock.
|
|
6
|
+
Every timestamp is passed IN (epoch nanoseconds from ``os.stat().st_mtime_ns``
|
|
7
|
+
or ``time.time_ns()``) so the materialization decision is a deterministic
|
|
8
|
+
function of its inputs — the property the plan-determinism tests pin.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Which agent produced a transcript — atif-corpus's copy of the one enum.
|
|
4
|
+
|
|
5
|
+
DELIBERATE TWIN of :mod:`atif_converter.domain.agents`, and the same kind of
|
|
6
|
+
twin as ``sql_literal`` and ``embedding_guard`` elsewhere in this workspace:
|
|
7
|
+
the import-linter independence contract forbids atif-corpus from importing
|
|
8
|
+
atif-converter, and the agent identity is needed on both sides of that
|
|
9
|
+
boundary — the converter picks an adapter with it, the corpus picks a discovery
|
|
10
|
+
layout and a corpus slug with it.
|
|
11
|
+
|
|
12
|
+
``packages/atif-corpus/tests/test_agents_twin_pin.py`` reads the atif-converter
|
|
13
|
+
copy as SOURCE TEXT (AST, never imported) and fails if the two disagree on a
|
|
14
|
+
member name or a value, so the twin cannot drift silently.
|
|
15
|
+
|
|
16
|
+
The string values are WIRE CONTRACT: the ``--agent`` flag's spellings, harbor's
|
|
17
|
+
``Trajectory.agent.name``, and the ``agent`` key in ``meta.json``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentSource(StrEnum):
|
|
26
|
+
"""A coding agent whose transcripts this workspace can materialize."""
|
|
27
|
+
|
|
28
|
+
CLAUDE_CODE = "claude-code"
|
|
29
|
+
CODEX = "codex"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
#: The agent assumed when a caller names none.
|
|
33
|
+
DEFAULT_AGENT: AgentSource = AgentSource.CLAUDE_CODE
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Pure rule: does an artifact set's recorded provenance match what this pass writes.
|
|
4
|
+
|
|
5
|
+
The watermark answers "did the SOURCE move?". It cannot answer "would this
|
|
6
|
+
version of the code write the same artifacts?", so a converter upgrade or a
|
|
7
|
+
columnar schema bump used to leave every existing session on the old output
|
|
8
|
+
forever. The *generation* is the second question: a small mapping of
|
|
9
|
+
``meta.json`` keys to the values this pass stamps (``converter_schema``, the
|
|
10
|
+
converter's own output version, and ``columnar_schema`` when the columnar
|
|
11
|
+
producer runs), and a session whose
|
|
12
|
+
recorded values differ is stale even when no source byte changed.
|
|
13
|
+
|
|
14
|
+
Only the EXPECTED keys are compared. A pass that does not write columnar
|
|
15
|
+
artifacts expects no ``columnar_schema``, so it neither re-converts a session
|
|
16
|
+
that has one nor one that lacks one.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generation_matches(recorded: Mapping[str, object], expected: Mapping[str, object]) -> bool:
|
|
28
|
+
"""True when every expected key is recorded with exactly the expected value.
|
|
29
|
+
|
|
30
|
+
A missing key does not match: a session materialized before a key existed
|
|
31
|
+
(``columnar_schema`` on a ``--no-columnar`` session) is exactly the one the
|
|
32
|
+
new generation has to reach.
|
|
33
|
+
"""
|
|
34
|
+
return all(key in recorded and recorded[key] == value for key, value in expected.items())
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The contract's materialized-corpus directory shape, as one value object.
|
|
4
|
+
|
|
5
|
+
CONTRACT.md §Materialized corpus layout is the ONLY specification of where
|
|
6
|
+
artifacts live; atif-duck reads this same shape without importing us, so the
|
|
7
|
+
paths must be computed in exactly one place on the writer side. This module
|
|
8
|
+
is that place — every writer path in the application layer goes through
|
|
9
|
+
:class:`CorpusLayout`, and the layout tests pin the contract strings.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
#: Per-session artifact filenames fixed by CONTRACT.md.
|
|
19
|
+
TRAJECTORY_FILENAME = "trajectory.json"
|
|
20
|
+
LOSS_REPORT_FILENAME = "loss_report.json"
|
|
21
|
+
EDGES_FILENAME = "edges.jsonl"
|
|
22
|
+
#: One line per kept non-message record (hooks, API errors, cost-state, ...).
|
|
23
|
+
SESSION_EVENTS_FILENAME = "session_events.jsonl"
|
|
24
|
+
META_FILENAME = "meta.json"
|
|
25
|
+
WATERMARK_FILENAME = "watermark.json"
|
|
26
|
+
#: Corpus-level record of sessions whose source converted to nothing.
|
|
27
|
+
EMPTY_SESSIONS_FILENAME = "empty_sessions.json"
|
|
28
|
+
#: Corpus-level record of published sessions a ``SessionSink`` has not yet
|
|
29
|
+
#: taken (a failed or interrupted sync); retried on the next pass.
|
|
30
|
+
SINK_PENDING_FILENAME = "sink_pending.json"
|
|
31
|
+
#: Per-session directory holding the zstd copy of the raw source files.
|
|
32
|
+
SOURCE_ARCHIVE_DIRNAME = "source"
|
|
33
|
+
|
|
34
|
+
#: The suffix a compressed artifact carries after its logical name.
|
|
35
|
+
COMPRESSED_SUFFIX = ".zst"
|
|
36
|
+
#: The artifacts materialize stores zstd-compressed, as ``<name>.zst``: the
|
|
37
|
+
#: bulk of a corpus's bytes. ``meta.json`` and ``loss_report.json`` stay plain
|
|
38
|
+
#: (small, and ``meta.json`` is what every reader and the writer check first).
|
|
39
|
+
#: A session written before this keeps its plain files until ``atif-sql corpus
|
|
40
|
+
#: slim`` compresses them; readers accept either spelling. atif-duck carries
|
|
41
|
+
#: the twin names (``atif_duck.domain.artifacts``), pinned by atif-cli's tests.
|
|
42
|
+
COMPRESSED_ARTIFACT_FILENAMES: tuple[str, ...] = (
|
|
43
|
+
TRAJECTORY_FILENAME,
|
|
44
|
+
EDGES_FILENAME,
|
|
45
|
+
SESSION_EVENTS_FILENAME,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def stored_filename(name: str) -> str:
|
|
50
|
+
"""The name a NEW session stores the artifact ``name`` under."""
|
|
51
|
+
return f"{name}{COMPRESSED_SUFFIX}" if name in COMPRESSED_ARTIFACT_FILENAMES else name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
#: A blob's name: the lowercase hex SHA-256 of its bytes.
|
|
55
|
+
_BLOB_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
56
|
+
#: A blob's extension: short, lowercase alphanumeric (``png``, ``jpg``, ``bin``).
|
|
57
|
+
_BLOB_EXTENSION_RE = re.compile(r"^[a-z0-9]{1,8}$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class InvalidBlobNameError(ValueError):
|
|
61
|
+
"""A blob hash or extension that must not become part of a corpus path."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class CorpusLayout:
|
|
66
|
+
"""Computes every contract path under one ``corpus_root``.
|
|
67
|
+
|
|
68
|
+
Pure path arithmetic — nothing here touches the filesystem, so the
|
|
69
|
+
layout can be asserted against the contract without a tmpdir.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
#: Root of the materialized corpus (default ``~/.atif-sql/corpus/<slug>``).
|
|
73
|
+
corpus_root: Path
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def sessions_dir(self) -> Path:
|
|
77
|
+
"""Parent directory of every per-session artifact directory."""
|
|
78
|
+
return self.corpus_root / "sessions"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def watermark_path(self) -> Path:
|
|
82
|
+
"""``{path: mtime_ns}`` across the source corpus, updated per pass."""
|
|
83
|
+
return self.corpus_root / WATERMARK_FILENAME
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def staging_dir(self) -> Path:
|
|
87
|
+
"""Scratch area for whole-session-dir atomic swaps.
|
|
88
|
+
|
|
89
|
+
Deliberately OUTSIDE ``sessions/`` so no reader glob (DuckDB's
|
|
90
|
+
``read_json`` matches dot-dirs) and no source-removal walk can ever
|
|
91
|
+
observe a half-written session dir; same filesystem as
|
|
92
|
+
``sessions/`` so ``os.replace`` of the staged dir stays atomic.
|
|
93
|
+
"""
|
|
94
|
+
return self.corpus_root / ".staging"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def empty_sessions_path(self) -> Path:
|
|
98
|
+
"""``{session_id: generation}`` for sessions whose source held nothing to convert.
|
|
99
|
+
|
|
100
|
+
Kept apart from ``watermark.json`` on purpose: that file's shape is
|
|
101
|
+
``{path: mtime_ns}`` and every reader coerces its values to ``int``, so
|
|
102
|
+
a structured entry there would read as corruption and cost a full
|
|
103
|
+
re-materialization on any version that predates this file.
|
|
104
|
+
"""
|
|
105
|
+
return self.corpus_root / EMPTY_SESSIONS_FILENAME
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def sink_pending_path(self) -> Path:
|
|
109
|
+
"""``{"session_ids": [...]}``: published sessions the session sink still owes.
|
|
110
|
+
|
|
111
|
+
Written BEFORE a pass publishes anything (every session it may
|
|
112
|
+
publish, plus what was already pending) and rewritten after the sink
|
|
113
|
+
ran, so a pass that dies between a session's swap and its sync still
|
|
114
|
+
leaves the session recorded.
|
|
115
|
+
"""
|
|
116
|
+
return self.corpus_root / SINK_PENDING_FILENAME
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def blobs_dir(self) -> Path:
|
|
120
|
+
"""The content-addressed attachment store shared by every session.
|
|
121
|
+
|
|
122
|
+
Outside ``sessions/`` on purpose: no reader glob and no per-session
|
|
123
|
+
swap touches it, retention never deletes from it, and a blob two
|
|
124
|
+
sessions share is stored once.
|
|
125
|
+
"""
|
|
126
|
+
return self.corpus_root / "blobs"
|
|
127
|
+
|
|
128
|
+
def blob_path(self, sha256: str, extension: str) -> Path:
|
|
129
|
+
"""``<corpus_root>/blobs/sha256/<first two hex>/<sha256>.<extension>``.
|
|
130
|
+
|
|
131
|
+
Raises
|
|
132
|
+
------
|
|
133
|
+
InvalidBlobNameError
|
|
134
|
+
``sha256`` is not 64 lowercase hex digits or ``extension`` is not
|
|
135
|
+
one to eight lowercase letters and digits.
|
|
136
|
+
"""
|
|
137
|
+
if not _BLOB_HASH_RE.match(sha256) or not _BLOB_EXTENSION_RE.match(extension):
|
|
138
|
+
msg = f"invalid blob name {sha256!r}.{extension!r}"
|
|
139
|
+
raise InvalidBlobNameError(msg)
|
|
140
|
+
return self.blobs_dir / "sha256" / sha256[:2] / f"{sha256}.{extension}"
|
|
141
|
+
|
|
142
|
+
def session_dir(self, session_id: str) -> Path:
|
|
143
|
+
"""``<corpus_root>/sessions/<session_id>/``."""
|
|
144
|
+
return self.sessions_dir / session_id
|
|
145
|
+
|
|
146
|
+
def trajectory_path(self, session_id: str) -> Path:
|
|
147
|
+
"""Compact ATIF-v1.7 trajectory JSON for one session, as materialize stores it (zstd)."""
|
|
148
|
+
return self.session_dir(session_id) / stored_filename(TRAJECTORY_FILENAME)
|
|
149
|
+
|
|
150
|
+
def loss_report_path(self, session_id: str) -> Path:
|
|
151
|
+
"""``atif_converter`` LossReport JSON for one session."""
|
|
152
|
+
return self.session_dir(session_id) / LOSS_REPORT_FILENAME
|
|
153
|
+
|
|
154
|
+
def edges_path(self, session_id: str) -> Path:
|
|
155
|
+
"""One line per RAW record: uuid/parent_uuid edge list, as materialize stores it (zstd)."""
|
|
156
|
+
return self.session_dir(session_id) / stored_filename(EDGES_FILENAME)
|
|
157
|
+
|
|
158
|
+
def session_events_path(self, session_id: str) -> Path:
|
|
159
|
+
"""One line per kept non-message record, as materialize stores it (zstd)."""
|
|
160
|
+
return self.session_dir(session_id) / stored_filename(SESSION_EVENTS_FILENAME)
|
|
161
|
+
|
|
162
|
+
def meta_path(self, session_id: str) -> Path:
|
|
163
|
+
"""Provenance record: source files, mtimes, versions, materialized_at."""
|
|
164
|
+
return self.session_dir(session_id) / META_FILENAME
|
|
165
|
+
|
|
166
|
+
def source_archive_dir(self, session_id: str) -> Path:
|
|
167
|
+
"""The raw source archive: one zstd ``<relative path>.zst`` per source file."""
|
|
168
|
+
return self.session_dir(session_id) / SOURCE_ARCHIVE_DIRNAME
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The ports materialization needs filled: a converter and, optionally, an artifact producer and a session sink.
|
|
4
|
+
|
|
5
|
+
atif-corpus may never import atif-converter or atif-duck (import-linter
|
|
6
|
+
independence contract), so both Protocols are typed to CONTRACT.md's artifact
|
|
7
|
+
shapes rather than to either package's internals. atif-cli adapts the real
|
|
8
|
+
converter and the real columnar producer to these ports; tests use
|
|
9
|
+
:class:`atif_corpus.infrastructure.fake_converter.FakeConverter` and a fake
|
|
10
|
+
producer of their own.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EmptySourceError(Exception):
|
|
24
|
+
"""The session holds nothing to convert (no user or assistant record).
|
|
25
|
+
|
|
26
|
+
Not a failure: the transcript is well-formed and simply empty, and it stays
|
|
27
|
+
empty until something writes to it. An adapter translates its converter's
|
|
28
|
+
own "empty" verdict into this so the use case can record the session as
|
|
29
|
+
empty, keyed on its source mtimes, instead of retrying it every pass. Any
|
|
30
|
+
OTHER exception is a real failure.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ArchivedSource:
|
|
36
|
+
"""One file of a session's raw source archive, as the converter wrote it.
|
|
37
|
+
|
|
38
|
+
The compressed copy lives at ``<archive_dir>/<relative_path>.zst``;
|
|
39
|
+
``size`` and ``sha256`` describe the UNCOMPRESSED bytes, so a restore can
|
|
40
|
+
prove it got the original back.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
#: POSIX path relative to the main transcript's parent directory.
|
|
44
|
+
relative_path: str
|
|
45
|
+
#: Uncompressed size in bytes.
|
|
46
|
+
size: int
|
|
47
|
+
#: Hex sha256 of the uncompressed bytes.
|
|
48
|
+
sha256: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class BlobOutput:
|
|
53
|
+
"""One attachment's bytes, content-addressed, for the corpus blob store.
|
|
54
|
+
|
|
55
|
+
``sha256`` is the lowercase hex digest of ``data`` and ``extension`` the
|
|
56
|
+
file extension its media type maps to (``png``, ``jpg``, ``pdf``, ``bin``);
|
|
57
|
+
the writer validates both before either becomes part of a path.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
sha256: str
|
|
61
|
+
extension: str
|
|
62
|
+
data: bytes
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class ConversionOutput:
|
|
67
|
+
"""Everything one conversion yields that the corpus writes to disk.
|
|
68
|
+
|
|
69
|
+
Shapes are the contract's, pre-serialized by the adapter:
|
|
70
|
+
|
|
71
|
+
* ``trajectory_dict`` — the ATIF-v1.7 trajectory, ready for compact
|
|
72
|
+
``json.dumps(separators=(",", ":"))``.
|
|
73
|
+
* ``loss_report_dict`` — ``LossReport.to_json()``-shaped dict.
|
|
74
|
+
* ``edges_lines`` — one already-serialized JSON line per RAW record
|
|
75
|
+
(uuid, parent_uuid, message_id, type, ts, is_sidechain,
|
|
76
|
+
is_compact_summary, source_file, tool_use_ids), WITHOUT trailing
|
|
77
|
+
newlines; the writer owns line termination.
|
|
78
|
+
* ``events_lines`` — one already-serialized JSON line per kept
|
|
79
|
+
NON-MESSAGE record (``session_events.jsonl``: seq, ts, event_type,
|
|
80
|
+
subtype, uuid, parent_uuid, tool_use_id, is_sidechain, source_file,
|
|
81
|
+
payload, payload_bytes, payload_truncated), same termination rule.
|
|
82
|
+
Empty by default, so a converter that has none writes an empty file.
|
|
83
|
+
* ``source_archive`` — the files the converter archived into the
|
|
84
|
+
``archive_dir`` it was handed (empty when it was handed none, or when
|
|
85
|
+
the adapter does not archive). Every entry must exist on disk as
|
|
86
|
+
``<archive_dir>/<relative_path>.zst``; the use case checks that.
|
|
87
|
+
* ``blobs`` — the inline attachments the converter replaced with a
|
|
88
|
+
``[image sha256:<hash> ...]`` placeholder, one per distinct hash. The
|
|
89
|
+
writer stores each under ``<corpus_root>/blobs/`` BEFORE it publishes
|
|
90
|
+
the session that references it.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
trajectory_dict: dict[str, Any]
|
|
94
|
+
loss_report_dict: dict[str, Any]
|
|
95
|
+
edges_lines: list[str]
|
|
96
|
+
events_lines: list[str] = field(default_factory=list)
|
|
97
|
+
source_archive: tuple[ArchivedSource, ...] = ()
|
|
98
|
+
blobs: tuple[BlobOutput, ...] = ()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ConverterPort(Protocol):
|
|
102
|
+
"""Anything that can turn one session JSONL into corpus artifacts.
|
|
103
|
+
|
|
104
|
+
Implementations may raise any exception: the materialize use case
|
|
105
|
+
records the failure against the session and continues — one broken
|
|
106
|
+
transcript must never abort a corpus sync. :class:`EmptySourceError` is
|
|
107
|
+
the one exception with its own meaning: the session is empty, not broken.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def convert(self, session_jsonl: Path, *, archive_dir: Path | None = None) -> ConversionOutput:
|
|
111
|
+
"""Convert one session (main JSONL + its side-files) to artifacts.
|
|
112
|
+
|
|
113
|
+
With ``archive_dir``, also write a zstd copy of every source file of the
|
|
114
|
+
session under it, named by its path relative to ``session_jsonl``'s
|
|
115
|
+
parent plus ``.zst``, and list them in
|
|
116
|
+
:attr:`ConversionOutput.source_archive`. The copy must be of the bytes
|
|
117
|
+
the artifacts were built from; an adapter that cannot promise that
|
|
118
|
+
should archive nothing rather than something else.
|
|
119
|
+
"""
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ArtifactProducer(Protocol):
|
|
124
|
+
"""Anything that writes EXTRA per-session artifacts beside the contract ones.
|
|
125
|
+
|
|
126
|
+
The materialize use case calls :meth:`produce` once per session, inside
|
|
127
|
+
the staged session directory, after ``trajectory.json`` /
|
|
128
|
+
``loss_report.json`` / ``edges.jsonl`` / ``session_events.jsonl`` are
|
|
129
|
+
written (so a producer may read those staged files) and before
|
|
130
|
+
``meta.json`` is. Whatever the producer writes therefore publishes
|
|
131
|
+
atomically with the contract artifacts (the whole directory is
|
|
132
|
+
swapped into place) and is covered by the same completeness marker.
|
|
133
|
+
|
|
134
|
+
The producer returns extra keys for ``meta.json``. They must not collide
|
|
135
|
+
with the contract's own keys; the use case treats a collision as a
|
|
136
|
+
programming error and fails the session.
|
|
137
|
+
|
|
138
|
+
Implementations may raise any exception: the use case records the
|
|
139
|
+
failure against the session, never publishes the staged directory, and
|
|
140
|
+
retries the session next pass, exactly as it does for a converter
|
|
141
|
+
failure.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def produce(
|
|
145
|
+
self,
|
|
146
|
+
session_dir: Path,
|
|
147
|
+
*,
|
|
148
|
+
session_id: str,
|
|
149
|
+
trajectory: Mapping[str, Any],
|
|
150
|
+
) -> Mapping[str, Any]:
|
|
151
|
+
"""Write extra artifacts for ``session_id`` into ``session_dir``; return meta extras."""
|
|
152
|
+
...
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SessionSink(Protocol):
|
|
156
|
+
"""Anything that keeps a copy of published sessions in step with the corpus.
|
|
157
|
+
|
|
158
|
+
atif-cli plugs atif-duck's DuckLake writer in here. The materialize use
|
|
159
|
+
case calls :meth:`sync_sessions` in the PARENT process only (never in a
|
|
160
|
+
pool worker, so an implementation need not pickle), after every session
|
|
161
|
+
in ``session_ids`` has been swapped into ``sessions/<id>/`` (or had its
|
|
162
|
+
``meta.json`` rewritten as source-removed), one batch at a time, in plan
|
|
163
|
+
order.
|
|
164
|
+
|
|
165
|
+
Returning normally means the copy now matches those sessions' published
|
|
166
|
+
artifacts (an implementation with no copy to keep, say because none has
|
|
167
|
+
been created yet, may simply return). Raising means it may not: the use
|
|
168
|
+
case records every session of the batch, and every later batch, in
|
|
169
|
+
``<corpus_root>/sink_pending.json`` and hands them to the sink again next
|
|
170
|
+
pass, so the copy never silently diverges from the corpus.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def sync_sessions(self, *, corpus_root: Path, agent: str, session_ids: Sequence[str]) -> None:
|
|
174
|
+
"""Make the copy's rows for ``session_ids`` equal their published artifacts."""
|
|
175
|
+
...
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The session id boundary: which directory names may become a session.
|
|
4
|
+
|
|
5
|
+
A session id is the one piece of text from OUTSIDE this workspace that ends up
|
|
6
|
+
inside a corpus path: it is derived from a transcript filename under the
|
|
7
|
+
source root, becomes the ``sessions/<session_id>/`` directory name, and from
|
|
8
|
+
there names the files atif-duck opens. Everything else in a statement is a
|
|
9
|
+
constant. So the id is checked once, at the boundary, and a name that fails
|
|
10
|
+
the check is skipped and reported rather than carried any further.
|
|
11
|
+
|
|
12
|
+
The rule is deliberately narrow: an ASCII letter or digit first, then letters,
|
|
13
|
+
digits, ``.``, ``_`` and ``-``, at most 255 characters (the longest name most
|
|
14
|
+
filesystems accept). Every id both supported agents produce passes: Claude
|
|
15
|
+
Code names a session with a version 4 UUID, Codex with a version 7 UUID
|
|
16
|
+
(``019e9e38-39a6-7170-86fb-46e6f1cb1931``). Quotes, whitespace, path
|
|
17
|
+
separators, ``;``, ``$``, ``?`` and anything outside ASCII fail.
|
|
18
|
+
|
|
19
|
+
DELIBERATE TWIN of :mod:`atif_duck.domain.session_id`, the same way
|
|
20
|
+
:mod:`atif_corpus.domain.agents` twins the converter's enum: the independence
|
|
21
|
+
contract forbids the two packages from importing each other, and both sides
|
|
22
|
+
must apply the SAME rule (the corpus writer before it writes, the registry
|
|
23
|
+
before it reads a corpus an older version wrote). Each package's tests read
|
|
24
|
+
the other's copy as source text and fail if the pattern or the limit drifts.
|
|
25
|
+
|
|
26
|
+
Pure module: stdlib only.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
|
|
33
|
+
#: The accepted shape, as a pattern string so the twin pin can compare it.
|
|
34
|
+
SESSION_ID_PATTERN: str = r"^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
|
35
|
+
|
|
36
|
+
#: Longest accepted id, in characters.
|
|
37
|
+
SESSION_ID_MAX_CHARS: int = 255
|
|
38
|
+
|
|
39
|
+
_SESSION_ID_RE = re.compile(SESSION_ID_PATTERN)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def session_id_rejection(candidate: str) -> str | None:
|
|
43
|
+
"""Why ``candidate`` cannot be a session id, or ``None`` when it can.
|
|
44
|
+
|
|
45
|
+
The reason is one short phrase meant for a log line; a caller that only
|
|
46
|
+
needs the verdict uses :func:`is_valid_session_id`.
|
|
47
|
+
"""
|
|
48
|
+
if len(candidate) > SESSION_ID_MAX_CHARS:
|
|
49
|
+
return f"longer than {SESSION_ID_MAX_CHARS} characters"
|
|
50
|
+
# fullmatch, not match: with ``$`` alone a trailing newline would pass.
|
|
51
|
+
if _SESSION_ID_RE.fullmatch(candidate) is None:
|
|
52
|
+
return (
|
|
53
|
+
"contains a character outside [A-Za-z0-9._-] or does not start with a letter or digit"
|
|
54
|
+
)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_valid_session_id(candidate: str) -> bool:
|
|
59
|
+
"""Whether ``candidate`` passes the boundary."""
|
|
60
|
+
return session_id_rejection(candidate) is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"SESSION_ID_MAX_CHARS",
|
|
65
|
+
"SESSION_ID_PATTERN",
|
|
66
|
+
"is_valid_session_id",
|
|
67
|
+
"session_id_rejection",
|
|
68
|
+
]
|