atif-sql 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
atif_cli/lake.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The ``atif-sql lake`` subcommand group: rebuild, verify, status, compact.
|
|
4
|
+
|
|
5
|
+
The lake is the one DuckLake every corpus is queried through
|
|
6
|
+
(:mod:`atif_duck.infrastructure.lake`). ``materialize`` keeps it current one
|
|
7
|
+
batch of published sessions at a time; these commands build it, check it,
|
|
8
|
+
report on it, and keep its file count down.
|
|
9
|
+
|
|
10
|
+
Lean by construction like :mod:`atif_cli.cron`: this module loads eagerly with
|
|
11
|
+
``atif_cli.app`` (sub-apps must exist at registration time), so everything
|
|
12
|
+
beyond cyclopts and the output helpers is imported inside the command bodies.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Annotated
|
|
20
|
+
|
|
21
|
+
import cyclopts
|
|
22
|
+
|
|
23
|
+
from atif_cli.errors import EXIT_CODES, ClassifiedError
|
|
24
|
+
from atif_cli.output import OutputFormat, emit_error, emit_json, resolve_format
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from atif_duck.infrastructure.lake import LakeCorpus, LakeLayout
|
|
28
|
+
from atif_duck.infrastructure.lake_settings import LakeSettings
|
|
29
|
+
|
|
30
|
+
lake_app = cyclopts.App(
|
|
31
|
+
name="lake",
|
|
32
|
+
help="Build, check and maintain the DuckLake every corpus is queried through.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def lake_layout(lake_root: Path | None) -> tuple[LakeLayout, LakeSettings]:
|
|
37
|
+
"""The lake at ``lake_root`` (default ``ATIF_SQL_LAKE_ROOT``), and the settings it came from."""
|
|
38
|
+
from atif_duck.infrastructure.lake import LakeLayout
|
|
39
|
+
from atif_duck.infrastructure.lake_settings import LakeSettings
|
|
40
|
+
|
|
41
|
+
settings = LakeSettings()
|
|
42
|
+
return LakeLayout(lake_root if lake_root is not None else settings.lake_root), settings
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def writer_memory_limit() -> int:
|
|
46
|
+
"""The same host- and cgroup-derived cap ``query`` runs under."""
|
|
47
|
+
from atif_cli.app import query_memory_limit_bytes
|
|
48
|
+
|
|
49
|
+
return query_memory_limit_bytes()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fail(kind: str, message: str, hint: str, fmt: OutputFormat) -> None:
|
|
53
|
+
err = ClassifiedError(kind=kind, exit_code=EXIT_CODES[kind], message=message, hint=hint)
|
|
54
|
+
emit_error(err, fmt)
|
|
55
|
+
raise SystemExit(err.exit_code)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def discover_corpora(
|
|
59
|
+
layout: LakeLayout, settings: LakeSettings, roots: list[Path] | None
|
|
60
|
+
) -> list[LakeCorpus]:
|
|
61
|
+
"""The corpora to act on: the named roots, else what the lake holds plus every corpus under the base."""
|
|
62
|
+
from atif_duck.infrastructure.lake import LakeCorpus, corpus_agent, registered_corpora
|
|
63
|
+
|
|
64
|
+
if roots:
|
|
65
|
+
return [LakeCorpus(root=root, agent=corpus_agent(root)) for root in roots]
|
|
66
|
+
found: dict[Path, LakeCorpus] = {
|
|
67
|
+
corpus.root: corpus
|
|
68
|
+
for corpus in registered_corpora(layout).values()
|
|
69
|
+
if (corpus.root / "sessions").is_dir()
|
|
70
|
+
}
|
|
71
|
+
base = settings.corpus_base
|
|
72
|
+
if base.is_dir():
|
|
73
|
+
for child in sorted(base.iterdir()):
|
|
74
|
+
if (child / "sessions").is_dir() and child not in found:
|
|
75
|
+
found[child] = LakeCorpus(root=child, agent=corpus_agent(child))
|
|
76
|
+
return sorted(found.values(), key=lambda corpus: corpus.name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@lake_app.command
|
|
80
|
+
def rebuild(
|
|
81
|
+
*,
|
|
82
|
+
corpus_root: Annotated[list[Path] | None, cyclopts.Parameter(consume_multiple=False)] = None,
|
|
83
|
+
lake_root: Path | None = None,
|
|
84
|
+
fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Load every corpus's per-session artifacts into a fresh lake, then swap it into place.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
corpus_root
|
|
91
|
+
A corpus to load; repeat for more. Default: every corpus the current
|
|
92
|
+
lake holds, plus every directory under ``ATIF_SQL_CORPUS_BASE``
|
|
93
|
+
(default ``~/.atif-sql/corpus``) that holds ``sessions/``.
|
|
94
|
+
lake_root
|
|
95
|
+
The lake to rebuild (default ``ATIF_SQL_LAKE_ROOT``, else
|
|
96
|
+
``~/.atif-sql/lake``).
|
|
97
|
+
fmt
|
|
98
|
+
``auto`` = human lines on a TTY, JSON on a pipe.
|
|
99
|
+
|
|
100
|
+
Installs the ``ducklake`` DuckDB extension first when it is missing (the
|
|
101
|
+
one network fetch the lake needs; ``query`` never installs it). The new
|
|
102
|
+
lake is built beside the old one under the writer lock and renamed into
|
|
103
|
+
place when complete, so a reader sees one lake or the other.
|
|
104
|
+
"""
|
|
105
|
+
from atif_duck.infrastructure.lake import (
|
|
106
|
+
LakeCorpusConflictError,
|
|
107
|
+
install_ducklake_extension,
|
|
108
|
+
rebuild_lake,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
layout, settings = lake_layout(lake_root)
|
|
112
|
+
corpora = discover_corpora(layout, settings, corpus_root)
|
|
113
|
+
if not corpora:
|
|
114
|
+
_fail(
|
|
115
|
+
"invalid_input",
|
|
116
|
+
"no corpus to load",
|
|
117
|
+
"materialize a corpus first, or pass --corpus-root",
|
|
118
|
+
fmt,
|
|
119
|
+
)
|
|
120
|
+
install_ducklake_extension()
|
|
121
|
+
try:
|
|
122
|
+
report = rebuild_lake(
|
|
123
|
+
layout,
|
|
124
|
+
corpora,
|
|
125
|
+
batch_size=settings.lake_load_batch_size,
|
|
126
|
+
lock_timeout_seconds=settings.lake_lock_timeout_seconds,
|
|
127
|
+
memory_limit_bytes=writer_memory_limit(),
|
|
128
|
+
stage_workers=settings.lake_stage_workers,
|
|
129
|
+
)
|
|
130
|
+
except LakeCorpusConflictError as exc:
|
|
131
|
+
_fail("invalid_input", str(exc), "give each corpus a distinct directory name", fmt)
|
|
132
|
+
return
|
|
133
|
+
payload = {
|
|
134
|
+
"lake_root": str(report.root),
|
|
135
|
+
"corpora": [
|
|
136
|
+
{"corpus": name, "agent": agent, "sessions": sessions}
|
|
137
|
+
for name, agent, sessions in report.corpora
|
|
138
|
+
],
|
|
139
|
+
"data_files": report.data_files,
|
|
140
|
+
"seconds": round(report.seconds, 3),
|
|
141
|
+
}
|
|
142
|
+
if resolve_format(fmt) is OutputFormat.TABLE:
|
|
143
|
+
for name, agent, sessions in report.corpora:
|
|
144
|
+
print(f"loaded {name} ({agent}): {sessions} sessions")
|
|
145
|
+
print(f"lake: {report.root} data files: {report.data_files} in {report.seconds:.1f}s")
|
|
146
|
+
else:
|
|
147
|
+
emit_json(payload, fmt)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@lake_app.command
|
|
151
|
+
def verify(
|
|
152
|
+
*,
|
|
153
|
+
corpus_root: Annotated[list[Path] | None, cyclopts.Parameter(consume_multiple=False)] = None,
|
|
154
|
+
lake_root: Path | None = None,
|
|
155
|
+
limit: int = 20,
|
|
156
|
+
fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Compare every session's lake rows with its per-session artifacts; exit 65 on a difference.
|
|
159
|
+
|
|
160
|
+
Per table and session: the row count and an order-free content hash of
|
|
161
|
+
the lake's rows against the same over what the per-session path reads.
|
|
162
|
+
Exits 0 when all match, 65 (``lake_mismatch``) when any differs, 78
|
|
163
|
+
(``lake_unavailable``) when there is no lake or its schema is stale.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
corpus_root
|
|
168
|
+
Verify only this corpus; repeat for more. Default: every corpus the lake holds.
|
|
169
|
+
lake_root
|
|
170
|
+
The lake to verify (default ``ATIF_SQL_LAKE_ROOT``).
|
|
171
|
+
limit
|
|
172
|
+
How many differences to list (all are counted).
|
|
173
|
+
fmt
|
|
174
|
+
``auto`` = human lines on a TTY, JSON on a pipe.
|
|
175
|
+
"""
|
|
176
|
+
from atif_duck.infrastructure.lake import (
|
|
177
|
+
LakeCorpus,
|
|
178
|
+
LakeError,
|
|
179
|
+
corpus_agent,
|
|
180
|
+
verify_lake,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
layout, settings = lake_layout(lake_root)
|
|
184
|
+
corpora = (
|
|
185
|
+
[LakeCorpus(root=root, agent=corpus_agent(root)) for root in corpus_root]
|
|
186
|
+
if corpus_root
|
|
187
|
+
else None
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
report = verify_lake(
|
|
191
|
+
layout,
|
|
192
|
+
corpora,
|
|
193
|
+
memory_limit_bytes=writer_memory_limit(),
|
|
194
|
+
batch_size=settings.lake_load_batch_size,
|
|
195
|
+
stage_workers=settings.lake_stage_workers,
|
|
196
|
+
)
|
|
197
|
+
except LakeError as exc:
|
|
198
|
+
_fail("lake_unavailable", str(exc), "run `atif-sql lake rebuild`", fmt)
|
|
199
|
+
return
|
|
200
|
+
if report.stale:
|
|
201
|
+
_fail(
|
|
202
|
+
"lake_unavailable",
|
|
203
|
+
f"cannot verify {layout.root}: {', '.join(report.stale)}",
|
|
204
|
+
"run `atif-sql lake rebuild`",
|
|
205
|
+
fmt,
|
|
206
|
+
)
|
|
207
|
+
sessions = sorted({(m.corpus, m.session_id) for m in report.mismatches})
|
|
208
|
+
payload = {
|
|
209
|
+
"lake_root": str(layout.root),
|
|
210
|
+
"clean": report.clean,
|
|
211
|
+
"corpora": [{"corpus": name, "sessions": n} for name, n in report.corpora],
|
|
212
|
+
"mismatched_sessions": len(sessions),
|
|
213
|
+
"mismatches": [
|
|
214
|
+
{
|
|
215
|
+
"corpus": m.corpus,
|
|
216
|
+
"table": m.table,
|
|
217
|
+
"session_id": m.session_id,
|
|
218
|
+
"artifact_rows": m.artifact_rows,
|
|
219
|
+
"lake_rows": m.lake_rows,
|
|
220
|
+
}
|
|
221
|
+
for m in report.mismatches[: max(0, limit)]
|
|
222
|
+
],
|
|
223
|
+
}
|
|
224
|
+
if resolve_format(fmt) is OutputFormat.TABLE:
|
|
225
|
+
for name, n in report.corpora:
|
|
226
|
+
print(f"{name}: {n} sessions checked")
|
|
227
|
+
for m in report.mismatches[: max(0, limit)]:
|
|
228
|
+
print(
|
|
229
|
+
f" DIFFERS {m.corpus}/{m.session_id} {m.table}: "
|
|
230
|
+
f"{m.artifact_rows} artifact row(s), {m.lake_rows} lake row(s)",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
print("clean" if report.clean else f"{len(sessions)} session(s) differ")
|
|
234
|
+
else:
|
|
235
|
+
emit_json(payload, fmt)
|
|
236
|
+
if not report.clean:
|
|
237
|
+
raise SystemExit(EXIT_CODES["lake_mismatch"])
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@lake_app.command
|
|
241
|
+
def status(
|
|
242
|
+
*,
|
|
243
|
+
lake_root: Path | None = None,
|
|
244
|
+
fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Report the lake: present, schema current, corpora, snapshots, files, last write.
|
|
247
|
+
|
|
248
|
+
Read from the published reader catalog, so it never waits on a writer.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
lake_root
|
|
253
|
+
The lake to report on (default ``ATIF_SQL_LAKE_ROOT``).
|
|
254
|
+
fmt
|
|
255
|
+
``auto`` = human lines on a TTY, JSON on a pipe.
|
|
256
|
+
"""
|
|
257
|
+
from atif_duck.infrastructure.lake import lake_status
|
|
258
|
+
|
|
259
|
+
layout, _ = lake_layout(lake_root)
|
|
260
|
+
state = lake_status(layout).as_dict()
|
|
261
|
+
if resolve_format(fmt) is OutputFormat.TABLE:
|
|
262
|
+
print(f"lake root: {state['root']}")
|
|
263
|
+
print(
|
|
264
|
+
f"present: {state['present']} (ducklake extension: {state['extension_installed']})"
|
|
265
|
+
)
|
|
266
|
+
if state["present"]:
|
|
267
|
+
print(
|
|
268
|
+
f"schema: {'current' if state['schema_current'] else 'STALE ' + ', '.join(state['stale'])}"
|
|
269
|
+
)
|
|
270
|
+
for corpus in state["corpora"]:
|
|
271
|
+
print(
|
|
272
|
+
f"corpus: {corpus['corpus']} ({corpus['agent']}): {corpus['sessions']} sessions"
|
|
273
|
+
)
|
|
274
|
+
print(f"snapshots: {state['snapshots']}")
|
|
275
|
+
print(
|
|
276
|
+
f"data files: {state['data_files']} ({state['data_bytes']:,} bytes), {state['delete_files']} delete files"
|
|
277
|
+
)
|
|
278
|
+
print(f"last write: {state['last_write']}")
|
|
279
|
+
else:
|
|
280
|
+
emit_json(state, fmt)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@lake_app.command
|
|
284
|
+
def compact(
|
|
285
|
+
*,
|
|
286
|
+
expire_older_than_days: int | None = None,
|
|
287
|
+
memory_limit: str | None = None,
|
|
288
|
+
lake_root: Path | None = None,
|
|
289
|
+
fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Merge small files, expire old snapshots, and remove the files nothing references.
|
|
292
|
+
|
|
293
|
+
Parameters
|
|
294
|
+
----------
|
|
295
|
+
expire_older_than_days
|
|
296
|
+
Expire snapshots older than this (default ``ATIF_SQL_LAKE_EXPIRE_DAYS``,
|
|
297
|
+
else 30). Files only expired snapshots referenced are then removed,
|
|
298
|
+
once an hour old, so a reader in flight keeps every file it names.
|
|
299
|
+
memory_limit
|
|
300
|
+
DuckDB's memory budget for the run, as a size (``2GiB``, ``1500MB``),
|
|
301
|
+
instead of the one derived from the host and cgroup. The writer's own
|
|
302
|
+
2 GiB ceiling still applies. The nightly refresh lane passes one that
|
|
303
|
+
fits its memory scope, so a sizing mistake can't shrink the budget
|
|
304
|
+
until the merge runs out of memory.
|
|
305
|
+
lake_root
|
|
306
|
+
The lake to compact (default ``ATIF_SQL_LAKE_ROOT``).
|
|
307
|
+
fmt
|
|
308
|
+
``auto`` = human lines on a TTY, JSON on a pipe.
|
|
309
|
+
"""
|
|
310
|
+
from atif_duck.infrastructure.lake import (
|
|
311
|
+
DEFAULT_WRITER_MEMORY_BYTES,
|
|
312
|
+
LakeError,
|
|
313
|
+
compact_lake,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
layout, settings = lake_layout(lake_root)
|
|
317
|
+
days = (
|
|
318
|
+
expire_older_than_days if expire_older_than_days is not None else settings.lake_expire_days
|
|
319
|
+
)
|
|
320
|
+
if days < 0:
|
|
321
|
+
_fail("invalid_input", "--expire-older-than-days must be >= 0", "pass 0 or more", fmt)
|
|
322
|
+
budget = writer_memory_limit()
|
|
323
|
+
if memory_limit is not None:
|
|
324
|
+
from atif_cli.app import parse_size
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
budget = parse_size(memory_limit)
|
|
328
|
+
except ValueError as exc:
|
|
329
|
+
_fail("invalid_input", str(exc), "pass a size such as 2GiB or 1500MB", fmt)
|
|
330
|
+
if budget <= 0:
|
|
331
|
+
_fail("invalid_input", "--memory-limit must be a positive size", "e.g. 2GiB", fmt)
|
|
332
|
+
import duckdb
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
report = compact_lake(
|
|
336
|
+
layout,
|
|
337
|
+
expire_older_than_days=days,
|
|
338
|
+
lock_timeout_seconds=settings.lake_lock_timeout_seconds,
|
|
339
|
+
memory_limit_bytes=budget,
|
|
340
|
+
)
|
|
341
|
+
except LakeError as exc:
|
|
342
|
+
_fail("lake_unavailable", str(exc), "run `atif-sql lake rebuild`", fmt)
|
|
343
|
+
return
|
|
344
|
+
except duckdb.Error as exc:
|
|
345
|
+
# An out-of-memory merge is the likely one. Nothing was published, so
|
|
346
|
+
# readers keep the previous catalog; the next run starts over.
|
|
347
|
+
_fail(
|
|
348
|
+
"runtime_error",
|
|
349
|
+
str(exc).splitlines()[0],
|
|
350
|
+
"raise --memory-limit (the writer's ceiling is 2GiB) or run it again",
|
|
351
|
+
fmt,
|
|
352
|
+
)
|
|
353
|
+
return
|
|
354
|
+
payload = {
|
|
355
|
+
"lake_root": str(layout.root),
|
|
356
|
+
"data_files_before": report.files_before,
|
|
357
|
+
"data_files_after": report.files_after,
|
|
358
|
+
"snapshots_before": report.snapshots_before,
|
|
359
|
+
"snapshots_after": report.snapshots_after,
|
|
360
|
+
"memory_limit_bytes": min(budget, DEFAULT_WRITER_MEMORY_BYTES),
|
|
361
|
+
"seconds": round(report.seconds, 3),
|
|
362
|
+
}
|
|
363
|
+
if resolve_format(fmt) is OutputFormat.TABLE:
|
|
364
|
+
print(
|
|
365
|
+
f"data files: {report.files_before} -> {report.files_after} "
|
|
366
|
+
f"snapshots: {report.snapshots_before} -> {report.snapshots_after} "
|
|
367
|
+
f"in {report.seconds:.1f}s"
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
emit_json(payload, fmt)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
__all__ = ["lake_app"]
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The lake behind atif-analytics' session port: what ``analyze`` reads when the lake holds the corpus.
|
|
4
|
+
|
|
5
|
+
atif-analytics may import no package of ours but atif-models, so it declares
|
|
6
|
+
the port (:class:`atif_analytics.domain.ports.SessionSource`) and atif-cli,
|
|
7
|
+
the composition root, adapts atif-duck's :class:`~atif_duck.infrastructure.lake_sessions.LakeSessionReader`
|
|
8
|
+
to it here. The rows become :class:`~atif_analytics.domain.transcript.StepEvent`
|
|
9
|
+
through :func:`~atif_analytics.domain.transcript.step_event`, the projection
|
|
10
|
+
the file source uses too, so both sources apply one set of rules, and the
|
|
11
|
+
author label comes from atif-analytics' rule table (pinned to atif-duck's
|
|
12
|
+
``step_author`` by ``test_authorship_twin_pin.py``).
|
|
13
|
+
|
|
14
|
+
Per session, the lake is used only when it holds the session's CURRENT
|
|
15
|
+
artifacts: its ``session_meta`` row must carry the ``materialized_at`` and
|
|
16
|
+
``source_mtime_ns`` the session's ``meta.json`` carries now. Anything else (a
|
|
17
|
+
session the lake has not loaded, one a failed or skipped lake write left
|
|
18
|
+
behind, one whose ``meta.json`` cannot be read) is read from its files, so a
|
|
19
|
+
lagging lake can never hand the pipelines an older transcript than the files
|
|
20
|
+
hold. Enumeration and the ``trajectory.json`` mtime bound come from the
|
|
21
|
+
session directories exactly as on the file path, which keeps the checkpoint's
|
|
22
|
+
bounds identical whichever source ran.
|
|
23
|
+
|
|
24
|
+
Order inside that per-session check matters: the trajectory is stat'ed before
|
|
25
|
+
``meta.json`` is read, so a materialize swapping the session in between leaves
|
|
26
|
+
an OLD mtime beside whatever was read, and the checkpoint re-admits the
|
|
27
|
+
session next run rather than skipping a transcript it never analyzed.
|
|
28
|
+
|
|
29
|
+
A lake read that fails mid-run (a ``lake rebuild`` swapped the lake out from
|
|
30
|
+
under the open attach) logs one warning, and that read and every later one
|
|
31
|
+
come from the files, so a rebuild never takes a stage down.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import dataclasses
|
|
37
|
+
import json
|
|
38
|
+
from collections import defaultdict
|
|
39
|
+
from typing import TYPE_CHECKING, Any
|
|
40
|
+
|
|
41
|
+
from loguru import logger
|
|
42
|
+
|
|
43
|
+
from atif_analytics.domain.transcript import StepEvent, step_event
|
|
44
|
+
from atif_analytics.infrastructure.corpus_reader import (
|
|
45
|
+
META_FILENAME,
|
|
46
|
+
TrajectoryFileSource,
|
|
47
|
+
complete_session_dirs,
|
|
48
|
+
last_edge_ts,
|
|
49
|
+
trajectory_mtime,
|
|
50
|
+
)
|
|
51
|
+
from atif_duck.infrastructure.lake_sessions import LakeReadError
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from collections.abc import Sequence
|
|
55
|
+
from datetime import datetime
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
from atif_analytics.domain.ports import SessionBounds
|
|
59
|
+
from atif_duck.infrastructure.lake import LakeLayout, LakeUnavailable
|
|
60
|
+
from atif_duck.infrastructure.lake_sessions import LakeSessionReader, SessionFreshness
|
|
61
|
+
|
|
62
|
+
#: Sessions per gate-level lake read. One read costs about the same for one
|
|
63
|
+
#: session as for this many (it scans the same row groups' metadata), and the
|
|
64
|
+
#: gates walk the newest-first order, so the reader reads ahead by this much.
|
|
65
|
+
TURNS_BATCH_SIZE: int = 64
|
|
66
|
+
|
|
67
|
+
#: Sessions per full read (the tool payloads). Smaller: a batch of results can
|
|
68
|
+
#: be large, and the renderer only reads what it admits.
|
|
69
|
+
STEPS_BATCH_SIZE: int = 4
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _json(text: str | None) -> Any:
|
|
73
|
+
return None if text is None else json.loads(text)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_current(session_dir: Path, recorded: SessionFreshness | None) -> bool:
|
|
77
|
+
"""True when the lake's row for this session is its current ``meta.json``'s."""
|
|
78
|
+
if recorded is None:
|
|
79
|
+
return False
|
|
80
|
+
try:
|
|
81
|
+
meta = json.loads((session_dir / META_FILENAME).read_text())
|
|
82
|
+
except (OSError, ValueError):
|
|
83
|
+
return False
|
|
84
|
+
if not isinstance(meta, dict):
|
|
85
|
+
return False
|
|
86
|
+
return bool(
|
|
87
|
+
meta.get("materialized_at") == recorded.materialized_at
|
|
88
|
+
and meta.get("source_mtime_ns") == recorded.source_mtime_ns
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LakeSessionSource:
|
|
93
|
+
"""atif-analytics' ``SessionSource`` over the lake, falling back to the files per session."""
|
|
94
|
+
|
|
95
|
+
name: str = "lake"
|
|
96
|
+
turns_batch_size: int = TURNS_BATCH_SIZE
|
|
97
|
+
steps_batch_size: int = STEPS_BATCH_SIZE
|
|
98
|
+
turns_are_complete: bool = False
|
|
99
|
+
|
|
100
|
+
def __init__(self, reader: LakeSessionReader, corpus_root: Path) -> None:
|
|
101
|
+
self._reader = reader
|
|
102
|
+
self._sessions_dir = corpus_root / "sessions"
|
|
103
|
+
self._files = TrajectoryFileSource(corpus_root)
|
|
104
|
+
self._current: set[str] | None = None
|
|
105
|
+
#: Sessions the last enumeration found on disk but read from files.
|
|
106
|
+
self.from_files: tuple[str, ...] = ()
|
|
107
|
+
#: True once a lake read failed; the rest of the run reads files.
|
|
108
|
+
self.failed: bool = False
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def open(
|
|
112
|
+
cls, layout: LakeLayout, corpus_root: Path, *, memory_limit_bytes: int | None = None
|
|
113
|
+
) -> LakeSessionSource | LakeUnavailable:
|
|
114
|
+
"""Attach the lake for ``corpus_root``, or return why it cannot serve it."""
|
|
115
|
+
from atif_duck.infrastructure.lake_sessions import LakeSessionReader
|
|
116
|
+
|
|
117
|
+
reader = LakeSessionReader.open(layout, corpus_root, memory_limit_bytes=memory_limit_bytes)
|
|
118
|
+
if not isinstance(reader, LakeSessionReader):
|
|
119
|
+
return reader
|
|
120
|
+
return cls(reader, corpus_root)
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
"""Release the lake connection."""
|
|
124
|
+
self._reader.close()
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Enumeration
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def session_bounds(self) -> SessionBounds:
|
|
131
|
+
"""Every complete session's bounds; the lake supplies the newest-record bound when current."""
|
|
132
|
+
freshness: dict[str, SessionFreshness] = {}
|
|
133
|
+
newest: dict[str, datetime | None] = {}
|
|
134
|
+
if not self.failed:
|
|
135
|
+
try:
|
|
136
|
+
freshness = self._reader.freshness()
|
|
137
|
+
newest = self._reader.last_edge_ts()
|
|
138
|
+
except LakeReadError as exc:
|
|
139
|
+
self._fail(exc)
|
|
140
|
+
freshness.clear()
|
|
141
|
+
newest.clear()
|
|
142
|
+
bounds: SessionBounds = {}
|
|
143
|
+
current: set[str] = set()
|
|
144
|
+
from_files: list[str] = []
|
|
145
|
+
for session_dir in complete_session_dirs(self._sessions_dir):
|
|
146
|
+
sid = session_dir.name
|
|
147
|
+
mtime = trajectory_mtime(session_dir)
|
|
148
|
+
if _is_current(session_dir, freshness.get(sid)):
|
|
149
|
+
current.add(sid)
|
|
150
|
+
bounds[sid] = (newest.get(sid), mtime)
|
|
151
|
+
else:
|
|
152
|
+
from_files.append(sid)
|
|
153
|
+
bounds[sid] = (last_edge_ts(session_dir), mtime)
|
|
154
|
+
self._current = current
|
|
155
|
+
self.from_files = tuple(from_files)
|
|
156
|
+
return bounds
|
|
157
|
+
|
|
158
|
+
def _fail(self, exc: LakeReadError) -> None:
|
|
159
|
+
"""Stop reading the lake for the rest of the run; every later read comes from files."""
|
|
160
|
+
if not self.failed:
|
|
161
|
+
logger.warning(
|
|
162
|
+
"analyze: a lake read failed ({}); reading the per-session artifacts for the rest of the run",
|
|
163
|
+
exc,
|
|
164
|
+
)
|
|
165
|
+
self.failed = True
|
|
166
|
+
self._current = set()
|
|
167
|
+
|
|
168
|
+
def _split(self, session_ids: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
169
|
+
"""``(read from the lake, read from files)``, enumerating first if nothing has."""
|
|
170
|
+
if self._current is None:
|
|
171
|
+
self.session_bounds()
|
|
172
|
+
current = self._current or set()
|
|
173
|
+
in_lake = [sid for sid in session_ids if sid in current]
|
|
174
|
+
on_disk = [sid for sid in session_ids if sid not in current]
|
|
175
|
+
return in_lake, on_disk
|
|
176
|
+
|
|
177
|
+
def batchable(self, session_id: str) -> bool:
|
|
178
|
+
"""True for a session read from the lake; a file fallback is parsed only on request."""
|
|
179
|
+
return self._current is not None and session_id in self._current
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Steps
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def load_turns(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
|
|
186
|
+
"""Each session's steps without tool payloads, error flags included."""
|
|
187
|
+
in_lake, on_disk = self._split(session_ids)
|
|
188
|
+
try:
|
|
189
|
+
from_lake = self._lake_turns(in_lake)
|
|
190
|
+
except LakeReadError as exc:
|
|
191
|
+
self._fail(exc)
|
|
192
|
+
from_lake, on_disk = {}, list(session_ids)
|
|
193
|
+
# A fallback parse yields the payloads; the turns memo holds many
|
|
194
|
+
# sessions, so keep it as small as the lake's rows.
|
|
195
|
+
out = {
|
|
196
|
+
sid: [dataclasses.replace(s, tool_calls=[], tool_results=[]) for s in steps]
|
|
197
|
+
for sid, steps in self._files.load_turns(on_disk).items()
|
|
198
|
+
}
|
|
199
|
+
out.update(from_lake)
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
def _lake_turns(self, in_lake: list[str]) -> dict[str, list[StepEvent]]:
|
|
203
|
+
errors = self._reader.error_steps(in_lake)
|
|
204
|
+
grouped: dict[str, list[StepEvent]] = defaultdict(list)
|
|
205
|
+
for sid, step_id, ts, source, message, sidechain, compact, uuids in self._reader.turns(
|
|
206
|
+
in_lake
|
|
207
|
+
):
|
|
208
|
+
grouped[str(sid)].append(
|
|
209
|
+
step_event(
|
|
210
|
+
ts=ts,
|
|
211
|
+
source=source,
|
|
212
|
+
text=message,
|
|
213
|
+
source_uuids=_json(uuids),
|
|
214
|
+
is_sidechain=bool(sidechain),
|
|
215
|
+
is_compact_summary=bool(compact),
|
|
216
|
+
has_error_result=(str(sid), int(step_id)) in errors,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return {sid: grouped.get(sid, []) for sid in in_lake}
|
|
220
|
+
|
|
221
|
+
def load_steps(self, session_ids: Sequence[str]) -> dict[str, list[StepEvent]]:
|
|
222
|
+
"""Each session's steps with every tool call and result, in source order."""
|
|
223
|
+
in_lake, on_disk = self._split(session_ids)
|
|
224
|
+
try:
|
|
225
|
+
from_lake = self._lake_steps(in_lake)
|
|
226
|
+
except LakeReadError as exc:
|
|
227
|
+
self._fail(exc)
|
|
228
|
+
from_lake, on_disk = {}, list(session_ids)
|
|
229
|
+
out: dict[str, list[StepEvent]] = dict(self._files.load_steps(on_disk))
|
|
230
|
+
out.update(from_lake)
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
def _lake_steps(self, in_lake: list[str]) -> dict[str, list[StepEvent]]:
|
|
234
|
+
calls: dict[tuple[str, int], list[tuple[str | None, object]]] = defaultdict(list)
|
|
235
|
+
for sid, step_id, name, tool_input in self._reader.tool_calls(in_lake):
|
|
236
|
+
calls[(str(sid), int(step_id))].append((name, _json(tool_input)))
|
|
237
|
+
results: dict[tuple[str, int], list[tuple[str | None, object, bool]]] = defaultdict(list)
|
|
238
|
+
for sid, step_id, call_id, content, is_error in self._reader.tool_results(in_lake):
|
|
239
|
+
results[(str(sid), int(step_id))].append((call_id, _json(content), is_error is True))
|
|
240
|
+
grouped: dict[str, list[StepEvent]] = defaultdict(list)
|
|
241
|
+
for sid, step_id, ts, source, message, sidechain, compact, uuids in self._reader.turns(
|
|
242
|
+
in_lake
|
|
243
|
+
):
|
|
244
|
+
key = (str(sid), int(step_id))
|
|
245
|
+
grouped[key[0]].append(
|
|
246
|
+
step_event(
|
|
247
|
+
ts=ts,
|
|
248
|
+
source=source,
|
|
249
|
+
text=message,
|
|
250
|
+
source_uuids=_json(uuids),
|
|
251
|
+
is_sidechain=bool(sidechain),
|
|
252
|
+
is_compact_summary=bool(compact),
|
|
253
|
+
tool_calls=calls.get(key, ()),
|
|
254
|
+
tool_results=results.get(key, ()),
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
return {sid: grouped.get(sid, []) for sid in in_lake}
|
|
258
|
+
|
|
259
|
+
def edges_uuids(self, session_id: str) -> set[str] | None:
|
|
260
|
+
"""The session's raw-record uuids, from the lake when it holds the session's current rows."""
|
|
261
|
+
in_lake, _ = self._split([session_id])
|
|
262
|
+
if in_lake:
|
|
263
|
+
try:
|
|
264
|
+
return self._reader.edge_uuids(session_id)
|
|
265
|
+
except LakeReadError as exc:
|
|
266
|
+
self._fail(exc)
|
|
267
|
+
return self._files.edges_uuids(session_id)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
__all__ = ["STEPS_BATCH_SIZE", "TURNS_BATCH_SIZE", "LakeSessionSource"]
|