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,194 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Read one corpus's sessions out of the published lake, a batch at a time.
|
|
4
|
+
|
|
5
|
+
The analytics read path. atif-cli wraps :class:`LakeSessionReader` in
|
|
6
|
+
atif-analytics' ``SessionSource`` port (the two packages may not import each
|
|
7
|
+
other, so the rows cross as plain tuples). The statements are the constants in
|
|
8
|
+
:mod:`atif_duck.domain.lake_sessions`.
|
|
9
|
+
|
|
10
|
+
The connection is private to the reader and runs only those statements, so
|
|
11
|
+
it is not sandboxed the way ``query``'s is. It attaches the published reader
|
|
12
|
+
catalog ``READ_ONLY`` through :func:`~atif_duck.infrastructure.lake.attach_lake_for_query`,
|
|
13
|
+
so it never waits on the writer, sees the last committed state, and refuses
|
|
14
|
+
a lake whose schema is stale or that holds another corpus under this name.
|
|
15
|
+
|
|
16
|
+
A read that fails after the attach raises :class:`LakeReadError`. The one
|
|
17
|
+
way that happens in practice: a ``lake rebuild`` swaps a new lake into place
|
|
18
|
+
while a long ``analyze`` still holds the old attach, and the old data files
|
|
19
|
+
it names are gone.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import shutil
|
|
25
|
+
import tempfile
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from atif_duck.domain.lake_sessions import (
|
|
32
|
+
EDGE_UUIDS_SQL,
|
|
33
|
+
ERROR_STEPS_SQL,
|
|
34
|
+
LAST_EDGE_TS_SQL,
|
|
35
|
+
SESSION_FRESHNESS_SQL,
|
|
36
|
+
TOOL_CALLS_SQL,
|
|
37
|
+
TOOL_RESULTS_SQL,
|
|
38
|
+
TURNS_SQL,
|
|
39
|
+
)
|
|
40
|
+
from atif_duck.domain.sql_literal import sql_literal
|
|
41
|
+
from atif_duck.infrastructure.lake import (
|
|
42
|
+
DEFAULT_WRITER_MEMORY_BYTES,
|
|
43
|
+
LakeReader,
|
|
44
|
+
LakeUnavailable,
|
|
45
|
+
attach_lake_for_query,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from collections.abc import Sequence
|
|
50
|
+
|
|
51
|
+
import duckdb
|
|
52
|
+
|
|
53
|
+
from atif_duck.infrastructure.lake import LakeLayout
|
|
54
|
+
|
|
55
|
+
#: The reader's DuckDB memory cap (lowered further by the caller's). A batch
|
|
56
|
+
#: of sessions' tool results is the largest thing it holds.
|
|
57
|
+
DEFAULT_READER_MEMORY_BYTES: int = DEFAULT_WRITER_MEMORY_BYTES
|
|
58
|
+
|
|
59
|
+
#: DuckDB threads for the reader's connection.
|
|
60
|
+
_READER_THREADS: int = 4
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LakeReadError(RuntimeError):
|
|
64
|
+
"""A read on an open :class:`LakeSessionReader` failed (the lake moved under it)."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class SessionFreshness:
|
|
69
|
+
"""What a session's ``meta.json`` said when the lake loaded it."""
|
|
70
|
+
|
|
71
|
+
materialized_at: str | None
|
|
72
|
+
source_mtime_ns: int | None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _utc(value: datetime | None) -> datetime | None:
|
|
76
|
+
"""The lake's naive UTC timestamp as an aware one (what the file path yields)."""
|
|
77
|
+
return None if value is None else value.replace(tzinfo=UTC)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LakeSessionReader:
|
|
81
|
+
"""One corpus's session rows from the published lake."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, con: duckdb.DuckDBPyConnection, corpus: str, spill: Path) -> None:
|
|
84
|
+
self._con = con
|
|
85
|
+
self._corpus = corpus
|
|
86
|
+
self._spill = spill
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def open(
|
|
90
|
+
cls,
|
|
91
|
+
layout: LakeLayout,
|
|
92
|
+
corpus_root: Path,
|
|
93
|
+
*,
|
|
94
|
+
memory_limit_bytes: int | None = None,
|
|
95
|
+
) -> LakeSessionReader | LakeUnavailable:
|
|
96
|
+
"""Attach the lake for ``corpus_root``'s corpus, or say why not (nothing is installed)."""
|
|
97
|
+
import duckdb
|
|
98
|
+
|
|
99
|
+
spill = Path(tempfile.mkdtemp(prefix="atif-sql-lake-read-"))
|
|
100
|
+
con = duckdb.connect()
|
|
101
|
+
try:
|
|
102
|
+
cap = min(
|
|
103
|
+
memory_limit_bytes or DEFAULT_READER_MEMORY_BYTES, DEFAULT_READER_MEMORY_BYTES
|
|
104
|
+
)
|
|
105
|
+
con.execute(f"SET memory_limit='{int(cap)}B'")
|
|
106
|
+
con.execute(f"SET threads={int(_READER_THREADS)}")
|
|
107
|
+
con.execute(f"SET temp_directory={sql_literal(str(spill))}")
|
|
108
|
+
con.execute("SET autoinstall_known_extensions=false")
|
|
109
|
+
con.execute("SET autoload_known_extensions=false")
|
|
110
|
+
attached = attach_lake_for_query(
|
|
111
|
+
con, layout, corpus_root=corpus_root, all_corpora=False
|
|
112
|
+
)
|
|
113
|
+
except BaseException:
|
|
114
|
+
con.close()
|
|
115
|
+
shutil.rmtree(spill, ignore_errors=True)
|
|
116
|
+
raise
|
|
117
|
+
if not isinstance(attached, LakeReader) or attached.corpus is None:
|
|
118
|
+
con.close()
|
|
119
|
+
shutil.rmtree(spill, ignore_errors=True)
|
|
120
|
+
if isinstance(attached, LakeUnavailable):
|
|
121
|
+
return attached
|
|
122
|
+
return LakeUnavailable("the lake attached without a corpus scope")
|
|
123
|
+
return cls(con, attached.corpus, spill)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def corpus(self) -> str:
|
|
127
|
+
"""The corpus name the reads are scoped to."""
|
|
128
|
+
return self._corpus
|
|
129
|
+
|
|
130
|
+
def close(self) -> None:
|
|
131
|
+
"""Close the connection and remove its spill directory."""
|
|
132
|
+
self._con.close()
|
|
133
|
+
shutil.rmtree(self._spill, ignore_errors=True)
|
|
134
|
+
|
|
135
|
+
def _fetch(self, statement: str, params: list[Any]) -> list[tuple[Any, ...]]:
|
|
136
|
+
import duckdb
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
return self._con.execute(statement, params).fetchall()
|
|
140
|
+
except duckdb.Error as exc:
|
|
141
|
+
raise LakeReadError(str(exc)) from exc
|
|
142
|
+
|
|
143
|
+
def _batch(self, statement: str, session_ids: Sequence[str]) -> list[tuple[Any, ...]]:
|
|
144
|
+
if not session_ids:
|
|
145
|
+
return []
|
|
146
|
+
return self._fetch(statement, [self._corpus, list(session_ids)])
|
|
147
|
+
|
|
148
|
+
def freshness(self) -> dict[str, SessionFreshness]:
|
|
149
|
+
"""``{session_id: SessionFreshness}`` for every session the lake holds for the corpus."""
|
|
150
|
+
rows = self._fetch(SESSION_FRESHNESS_SQL, [self._corpus])
|
|
151
|
+
return {
|
|
152
|
+
str(sid): SessionFreshness(
|
|
153
|
+
materialized_at=None if materialized is None else str(materialized),
|
|
154
|
+
source_mtime_ns=None if mtime is None else int(mtime),
|
|
155
|
+
)
|
|
156
|
+
for sid, materialized, mtime in rows
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def last_edge_ts(self) -> dict[str, datetime | None]:
|
|
160
|
+
"""``{session_id: newest record ts}`` (aware, UTC) over the corpus's edges."""
|
|
161
|
+
rows = self._fetch(LAST_EDGE_TS_SQL, [self._corpus])
|
|
162
|
+
return {str(sid): _utc(ts) for sid, ts in rows}
|
|
163
|
+
|
|
164
|
+
def turns(self, session_ids: Sequence[str]) -> list[tuple[Any, ...]]:
|
|
165
|
+
"""``(session_id, step_id, ts, source, message, is_sidechain, is_compact_summary, source_uuids)``.
|
|
166
|
+
|
|
167
|
+
In session, then step order; ``source_uuids`` is JSON text.
|
|
168
|
+
"""
|
|
169
|
+
return self._batch(TURNS_SQL, session_ids)
|
|
170
|
+
|
|
171
|
+
def error_steps(self, session_ids: Sequence[str]) -> set[tuple[str, int]]:
|
|
172
|
+
"""``(session_id, step_id)`` of every step with an error result."""
|
|
173
|
+
return {(str(sid), int(step)) for sid, step in self._batch(ERROR_STEPS_SQL, session_ids)}
|
|
174
|
+
|
|
175
|
+
def tool_calls(self, session_ids: Sequence[str]) -> list[tuple[Any, ...]]:
|
|
176
|
+
"""``(session_id, step_id, tool_name, tool_input)`` in source order (input as JSON text)."""
|
|
177
|
+
return self._batch(TOOL_CALLS_SQL, session_ids)
|
|
178
|
+
|
|
179
|
+
def tool_results(self, session_ids: Sequence[str]) -> list[tuple[Any, ...]]:
|
|
180
|
+
"""``(session_id, step_id, tool_use_id, content, is_error)`` in source order (content as JSON text)."""
|
|
181
|
+
return self._batch(TOOL_RESULTS_SQL, session_ids)
|
|
182
|
+
|
|
183
|
+
def edge_uuids(self, session_id: str) -> set[str]:
|
|
184
|
+
"""The session's non-empty raw-record uuids."""
|
|
185
|
+
rows = self._fetch(EDGE_UUIDS_SQL, [self._corpus, session_id])
|
|
186
|
+
return {str(uuid) for (uuid,) in rows}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
__all__ = [
|
|
190
|
+
"DEFAULT_READER_MEMORY_BYTES",
|
|
191
|
+
"LakeReadError",
|
|
192
|
+
"LakeSessionReader",
|
|
193
|
+
"SessionFreshness",
|
|
194
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Where the lake lives and how it is written, from ``ATIF_SQL_*`` env vars.
|
|
4
|
+
|
|
5
|
+
Reading process env is I/O, so this lives in ``infrastructure``. Every default
|
|
6
|
+
factory reads the environment at call time, so a test that re-points ``HOME``
|
|
7
|
+
sees the new value without a module reload.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
16
|
+
|
|
17
|
+
from atif_duck.infrastructure.lake import (
|
|
18
|
+
DEFAULT_LOAD_BATCH_SIZE,
|
|
19
|
+
DEFAULT_LOCK_TIMEOUT_SECONDS,
|
|
20
|
+
DEFAULT_STAGE_WORKERS,
|
|
21
|
+
DEFAULT_SYNC_BATCH_SIZE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_lake_root() -> Path:
|
|
26
|
+
"""``~/.atif-sql/lake``: one lake for every corpus."""
|
|
27
|
+
return Path.home() / ".atif-sql" / "lake"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _default_corpus_base() -> Path:
|
|
31
|
+
"""``~/.atif-sql/corpus``: where the default corpus roots live (``lake rebuild`` scans it)."""
|
|
32
|
+
return Path.home() / ".atif-sql" / "corpus"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LakeSettings(BaseSettings):
|
|
36
|
+
"""Env-driven settings for the DuckLake every corpus is queried through."""
|
|
37
|
+
|
|
38
|
+
model_config = SettingsConfigDict(env_prefix="ATIF_SQL_", env_file=".env", extra="ignore")
|
|
39
|
+
|
|
40
|
+
#: The lake root (``ATIF_SQL_LAKE_ROOT``): ``catalog.duckdb``, its
|
|
41
|
+
#: published reader copy, and ``data/``.
|
|
42
|
+
lake_root: Path = Field(default_factory=_default_lake_root)
|
|
43
|
+
#: The directory ``lake rebuild`` looks in for corpora when none is named
|
|
44
|
+
#: (``ATIF_SQL_CORPUS_BASE``); every child holding ``sessions/`` is one.
|
|
45
|
+
corpus_base: Path = Field(default_factory=_default_corpus_base)
|
|
46
|
+
#: Sessions per lake transaction when materialize syncs what it published
|
|
47
|
+
#: (``ATIF_SQL_LAKE_SYNC_BATCH_SIZE``).
|
|
48
|
+
lake_sync_batch_size: int = Field(default=DEFAULT_SYNC_BATCH_SIZE, ge=1)
|
|
49
|
+
#: Sessions per lake transaction when a whole corpus is loaded
|
|
50
|
+
#: (``ATIF_SQL_LAKE_LOAD_BATCH_SIZE``).
|
|
51
|
+
lake_load_batch_size: int = Field(default=DEFAULT_LOAD_BATCH_SIZE, ge=1)
|
|
52
|
+
#: Processes that stage a batch's parquet from its compressed trajectories
|
|
53
|
+
#: while the lake loads it (``ATIF_SQL_LAKE_STAGE_WORKERS``); 1 stages in
|
|
54
|
+
#: the loading process.
|
|
55
|
+
lake_stage_workers: int = Field(default=DEFAULT_STAGE_WORKERS, ge=1)
|
|
56
|
+
#: Seconds a writer waits for another writer (``ATIF_SQL_LAKE_LOCK_TIMEOUT_SECONDS``).
|
|
57
|
+
lake_lock_timeout_seconds: float = Field(default=DEFAULT_LOCK_TIMEOUT_SECONDS, gt=0)
|
|
58
|
+
#: ``lake compact`` expires snapshots older than this many days
|
|
59
|
+
#: (``ATIF_SQL_LAKE_EXPIRE_DAYS``).
|
|
60
|
+
lake_expire_days: int = Field(default=30, ge=0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["LakeSettings"]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""One corpus's step texts and keys, read from the lake for the embedding store.
|
|
4
|
+
|
|
5
|
+
atif-embed discovers what to embed from these reads (atif-cli adapts
|
|
6
|
+
:class:`LakeSteps` to atif-embed's ``LakeStepsPort``; the two packages may not
|
|
7
|
+
import each other). Everything here reads the published catalog copy
|
|
8
|
+
``READ_ONLY`` through :func:`atif_duck.infrastructure.lake.attach_lake_for_query`,
|
|
9
|
+
so it never waits on a writer and falls back for exactly the reasons ``query``
|
|
10
|
+
does (no lake, a stale one, a corpus it doesn't hold).
|
|
11
|
+
|
|
12
|
+
The rows are the ``steps`` table's, in the per-session reader's order
|
|
13
|
+
(session id, then step id), keyed like the store: a step's primary uuid is
|
|
14
|
+
the first entry of ``source_uuids``, and its text is the flattened
|
|
15
|
+
``message`` the lake already stores.
|
|
16
|
+
|
|
17
|
+
Change reads use DuckLake's ``ducklake_table_changes``: the sink replaces a
|
|
18
|
+
session by deleting and re-inserting its rows, so a session written since a
|
|
19
|
+
snapshot shows up as deletes of its old rows and inserts of its new ones.
|
|
20
|
+
File merges and rewrites (``lake compact``) change no row and show up as
|
|
21
|
+
nothing. Expired snapshots can't be read across, which
|
|
22
|
+
:meth:`LakeSteps.can_read_changes_since` reports.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import shutil
|
|
28
|
+
import tempfile
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TYPE_CHECKING, Self
|
|
32
|
+
|
|
33
|
+
from atif_duck.domain.lake import CORPORA_TABLE, LAKE_ALIAS, SESSION_KEY
|
|
34
|
+
from atif_duck.domain.raw_readers import CORPUS_COLUMN
|
|
35
|
+
from atif_duck.domain.sql_literal import SqlFragment, sql_literal
|
|
36
|
+
from atif_duck.infrastructure.lake import (
|
|
37
|
+
DEFAULT_WRITER_MEMORY_BYTES,
|
|
38
|
+
LakeReader,
|
|
39
|
+
LakeUnavailable,
|
|
40
|
+
attach_lake_for_query,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from collections.abc import Iterator
|
|
45
|
+
from types import TracebackType
|
|
46
|
+
|
|
47
|
+
import duckdb
|
|
48
|
+
|
|
49
|
+
from atif_duck.infrastructure.lake import LakeLayout
|
|
50
|
+
|
|
51
|
+
#: The lake table the embeddable rows live in.
|
|
52
|
+
STEPS_TABLE: str = "steps"
|
|
53
|
+
|
|
54
|
+
#: A step's primary uuid: the key the embedding store uses.
|
|
55
|
+
_PRIMARY_UUID: str = "json_extract_string(source_uuids, '$[0]')"
|
|
56
|
+
|
|
57
|
+
#: One corpus's steps with the key computed once (param: corpus).
|
|
58
|
+
_KEYED_STEPS: str = (
|
|
59
|
+
f"SELECT {SESSION_KEY} AS session_id, step_id, {_PRIMARY_UUID} AS uuid, message " # noqa: S608 # nosec B608 - module constants only
|
|
60
|
+
f"FROM {LAKE_ALIAS}.{STEPS_TABLE} WHERE {CORPUS_COLUMN} = ?"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
#: What makes a row embeddable (param: the minimum text length).
|
|
64
|
+
_QUALIFIES: str = "uuid IS NOT NULL AND message IS NOT NULL AND length(message) >= ?"
|
|
65
|
+
|
|
66
|
+
#: Every qualifying row of one corpus, in corpus order (params: corpus, min chars).
|
|
67
|
+
STEP_TEXTS_SQL: SqlFragment = SqlFragment(
|
|
68
|
+
f"SELECT uuid, message FROM ({_KEYED_STEPS}) WHERE {_QUALIFIES} " # noqa: S608 # nosec B608 - module constants only
|
|
69
|
+
"ORDER BY session_id, step_id"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
#: The qualifying rows of every uuid a step row inserted or deleted after a
|
|
73
|
+
#: snapshot touched, in corpus order (params: first snapshot, last snapshot,
|
|
74
|
+
#: corpus, corpus, min chars).
|
|
75
|
+
CHANGED_STEP_TEXTS_SQL: SqlFragment = SqlFragment(
|
|
76
|
+
f"WITH changed AS (SELECT DISTINCT {_PRIMARY_UUID} AS uuid " # noqa: S608 # nosec B608 - module constants only
|
|
77
|
+
f"FROM ducklake_table_changes({sql_literal(LAKE_ALIAS)}, 'main', {sql_literal(STEPS_TABLE)}, ?, ?) "
|
|
78
|
+
f"WHERE {CORPUS_COLUMN} = ?) "
|
|
79
|
+
f"SELECT uuid, message FROM ({_KEYED_STEPS}) WHERE {_QUALIFIES} "
|
|
80
|
+
"AND uuid IN (SELECT uuid FROM changed) ORDER BY session_id, step_id"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
#: Every step's primary uuid in one corpus, text or none (param: corpus).
|
|
84
|
+
STEP_KEYS_SQL: SqlFragment = SqlFragment(
|
|
85
|
+
f"SELECT DISTINCT uuid FROM ({_KEYED_STEPS}) WHERE uuid IS NOT NULL" # noqa: S608 # nosec B608 - module constants only
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
#: When the corpus was last loaded whole (param: corpus). A rebuild and a
|
|
89
|
+
#: first load both reset it, which is what makes it the lineage.
|
|
90
|
+
REGISTERED_AT_SQL: SqlFragment = SqlFragment(
|
|
91
|
+
f"SELECT registered_at FROM {LAKE_ALIAS}.{CORPORA_TABLE} WHERE {CORPUS_COLUMN} = ?" # noqa: S608 # nosec B608 - module constants only
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
#: The oldest and newest snapshot the attached catalog still holds.
|
|
95
|
+
SNAPSHOT_RANGE_SQL: SqlFragment = SqlFragment(
|
|
96
|
+
f"SELECT min(snapshot_id), max(snapshot_id) FROM ducklake_snapshots({sql_literal(LAKE_ALIAS)})" # noqa: S608 # nosec B608 - module constants only
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
#: Rows pulled off a result per ``fetchmany``.
|
|
100
|
+
_FETCH_PAGE_ROWS: int = 2048
|
|
101
|
+
|
|
102
|
+
#: Threads for a step read. The reads are one scan and one sort; more
|
|
103
|
+
#: threads only raise the peak.
|
|
104
|
+
_READ_THREADS: int = 4
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class LakeSteps:
|
|
109
|
+
"""One corpus's steps at the published catalog's current snapshot. Close it when done."""
|
|
110
|
+
|
|
111
|
+
con: duckdb.DuckDBPyConnection
|
|
112
|
+
corpus: str
|
|
113
|
+
#: The corpus's ``registered_at``: changes when its rows are loaded from scratch.
|
|
114
|
+
lineage: str
|
|
115
|
+
snapshot_id: int
|
|
116
|
+
oldest_snapshot_id: int
|
|
117
|
+
spill_dir: Path
|
|
118
|
+
|
|
119
|
+
def can_read_changes_since(self, snapshot_id: int) -> bool:
|
|
120
|
+
"""True when every snapshot after ``snapshot_id`` is still in the catalog."""
|
|
121
|
+
return self.oldest_snapshot_id <= snapshot_id + 1 and snapshot_id <= self.snapshot_id
|
|
122
|
+
|
|
123
|
+
def step_texts(
|
|
124
|
+
self, *, min_chars: int, since_snapshot: int | None
|
|
125
|
+
) -> Iterator[tuple[str, str]]:
|
|
126
|
+
"""``(uuid, text)`` rows in corpus order; with ``since_snapshot``, only the changed uuids'."""
|
|
127
|
+
if since_snapshot is None:
|
|
128
|
+
result = self.con.execute(STEP_TEXTS_SQL, [self.corpus, int(min_chars)])
|
|
129
|
+
elif since_snapshot >= self.snapshot_id:
|
|
130
|
+
return
|
|
131
|
+
else:
|
|
132
|
+
result = self.con.execute(
|
|
133
|
+
CHANGED_STEP_TEXTS_SQL,
|
|
134
|
+
[
|
|
135
|
+
int(since_snapshot) + 1,
|
|
136
|
+
self.snapshot_id,
|
|
137
|
+
self.corpus,
|
|
138
|
+
self.corpus,
|
|
139
|
+
int(min_chars),
|
|
140
|
+
],
|
|
141
|
+
)
|
|
142
|
+
while page := result.fetchmany(_FETCH_PAGE_ROWS):
|
|
143
|
+
for uuid, text in page:
|
|
144
|
+
yield str(uuid), str(text)
|
|
145
|
+
|
|
146
|
+
def step_keys(self) -> frozenset[str]:
|
|
147
|
+
"""Every step's primary uuid in the corpus."""
|
|
148
|
+
return frozenset(
|
|
149
|
+
str(row[0]) for row in self.con.execute(STEP_KEYS_SQL, [self.corpus]).fetchall()
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
"""Close the connection and remove its spill directory."""
|
|
154
|
+
self.con.close()
|
|
155
|
+
shutil.rmtree(self.spill_dir, ignore_errors=True)
|
|
156
|
+
|
|
157
|
+
def __enter__(self) -> Self:
|
|
158
|
+
"""Use as a context manager: the connection closes on exit."""
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
def __exit__(
|
|
162
|
+
self,
|
|
163
|
+
exc_type: type[BaseException] | None,
|
|
164
|
+
exc: BaseException | None,
|
|
165
|
+
tb: TracebackType | None,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Close on the way out, whatever happened."""
|
|
168
|
+
self.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def open_lake_steps(
|
|
172
|
+
layout: LakeLayout, corpus_root: Path, *, memory_limit_bytes: int | None = None
|
|
173
|
+
) -> LakeSteps | LakeUnavailable:
|
|
174
|
+
"""Attach the published lake for ``corpus_root``'s steps, or say why not.
|
|
175
|
+
|
|
176
|
+
DuckDB runs capped at ``memory_limit_bytes`` (at most the writer's cap),
|
|
177
|
+
spilling to a private temporary directory the returned object removes on
|
|
178
|
+
close.
|
|
179
|
+
"""
|
|
180
|
+
import duckdb
|
|
181
|
+
|
|
182
|
+
spill = Path(tempfile.mkdtemp(prefix="atif-sql-lake-steps-"))
|
|
183
|
+
con = duckdb.connect()
|
|
184
|
+
try:
|
|
185
|
+
cap = min(memory_limit_bytes or DEFAULT_WRITER_MEMORY_BYTES, DEFAULT_WRITER_MEMORY_BYTES)
|
|
186
|
+
con.execute(f"SET memory_limit='{int(cap)}B'")
|
|
187
|
+
con.execute(f"SET threads={int(_READ_THREADS)}")
|
|
188
|
+
con.execute(f"SET temp_directory={sql_literal(str(spill))}")
|
|
189
|
+
con.execute("SET autoinstall_known_extensions=false")
|
|
190
|
+
con.execute("SET autoload_known_extensions=false")
|
|
191
|
+
attached = attach_lake_for_query(con, layout, corpus_root=corpus_root, all_corpora=False)
|
|
192
|
+
if not isinstance(attached, LakeReader) or attached.corpus is None:
|
|
193
|
+
con.close()
|
|
194
|
+
shutil.rmtree(spill, ignore_errors=True)
|
|
195
|
+
return (
|
|
196
|
+
attached
|
|
197
|
+
if isinstance(attached, LakeUnavailable)
|
|
198
|
+
else LakeUnavailable("the lake did not scope to one corpus")
|
|
199
|
+
)
|
|
200
|
+
registered = con.execute(REGISTERED_AT_SQL, [attached.corpus]).fetchone()
|
|
201
|
+
snapshots = con.execute(SNAPSHOT_RANGE_SQL).fetchone()
|
|
202
|
+
except BaseException:
|
|
203
|
+
con.close()
|
|
204
|
+
shutil.rmtree(spill, ignore_errors=True)
|
|
205
|
+
raise
|
|
206
|
+
return LakeSteps(
|
|
207
|
+
con=con,
|
|
208
|
+
corpus=attached.corpus,
|
|
209
|
+
lineage=str(registered[0]) if registered else "",
|
|
210
|
+
snapshot_id=int(snapshots[1]) if snapshots and snapshots[1] is not None else 0,
|
|
211
|
+
oldest_snapshot_id=int(snapshots[0]) if snapshots and snapshots[0] is not None else 0,
|
|
212
|
+
spill_dir=spill,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = [
|
|
217
|
+
"CHANGED_STEP_TEXTS_SQL",
|
|
218
|
+
"REGISTERED_AT_SQL",
|
|
219
|
+
"SNAPSHOT_RANGE_SQL",
|
|
220
|
+
"STEPS_TABLE",
|
|
221
|
+
"STEP_KEYS_SQL",
|
|
222
|
+
"STEP_TEXTS_SQL",
|
|
223
|
+
"LakeSteps",
|
|
224
|
+
"open_lake_steps",
|
|
225
|
+
]
|