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.
Files changed (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. 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
+ ]