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,203 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Embed discovery from the DuckLake, reading only what changed since the last complete pass.
|
|
4
|
+
|
|
5
|
+
The per-session reader (:mod:`atif_embed.infrastructure.corpus_text_rows`)
|
|
6
|
+
parses every session's ``trajectory.json`` on every run, which on a large
|
|
7
|
+
corpus is minutes and gigabytes to find a handful of new rows. The lake
|
|
8
|
+
already holds each step's flattened text and primary uuid in its ``steps``
|
|
9
|
+
table, so this reader asks the lake instead (through
|
|
10
|
+
:class:`~atif_embed.domain.ports.LakeStepsPort`, which atif-cli implements
|
|
11
|
+
over atif-duck).
|
|
12
|
+
|
|
13
|
+
The watermark
|
|
14
|
+
-------------
|
|
15
|
+
``lake_watermark.json`` sits beside the Lance table, inside the store's own
|
|
16
|
+
directory, so deleting the store deletes it too. It records the lake
|
|
17
|
+
position (lineage and snapshot id) of the last pass that ran to its end and
|
|
18
|
+
whose every yielded row was embedded, the text floor that pass used, and how
|
|
19
|
+
many rows the store held afterwards. The next pass reads only the rows whose
|
|
20
|
+
uuid was inserted or deleted in a lake snapshot after that position.
|
|
21
|
+
|
|
22
|
+
Why that's the same row set a full read gives: a uuid that no step row
|
|
23
|
+
touched since the watermark has the same rows, in the same order, as it had
|
|
24
|
+
then, so a full read would pick the same text for it, and that text was
|
|
25
|
+
embedded when the watermark was written. A uuid a changed row touched is
|
|
26
|
+
re-read in full, every occurrence in corpus order, so the first-wins rule
|
|
27
|
+
lands on the same text a full read would.
|
|
28
|
+
|
|
29
|
+
A full read runs instead whenever the watermark can't be trusted: there is
|
|
30
|
+
none, it names another lineage (the lake was rebuilt), the snapshots after
|
|
31
|
+
it were expired, the text floor changed, or the store's row count isn't the
|
|
32
|
+
one recorded (something outside a complete pass wrote or deleted rows). With
|
|
33
|
+
no usable lake at all, discovery falls back to the per-session reader.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
from typing import TYPE_CHECKING, Any
|
|
41
|
+
|
|
42
|
+
from loguru import logger
|
|
43
|
+
|
|
44
|
+
from atif_embed.domain.discovery import PendingSelection
|
|
45
|
+
from atif_embed.domain.ports import LakePosition
|
|
46
|
+
from atif_embed.infrastructure.corpus_text_rows import MIN_TEXT_CHARS
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from collections.abc import Iterator
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
from atif_embed.domain.ports import LakeStepSnapshot, LakeStepsPort, TextRowsPort
|
|
53
|
+
from atif_embed.domain.text_stamp import PendingText
|
|
54
|
+
|
|
55
|
+
#: The watermark's filename inside the Lance store directory.
|
|
56
|
+
LAKE_WATERMARK_FILE = "lake_watermark.json"
|
|
57
|
+
|
|
58
|
+
#: Bump when what a watermark means changes (the row key, the order).
|
|
59
|
+
WATERMARK_VERSION = 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_watermark(path: Path) -> dict[str, Any] | None:
|
|
63
|
+
"""The recorded watermark, or ``None`` when absent or unreadable."""
|
|
64
|
+
try:
|
|
65
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
except (OSError, ValueError):
|
|
67
|
+
return None
|
|
68
|
+
return payload if isinstance(payload, dict) else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def write_watermark(path: Path, payload: dict[str, Any]) -> None:
|
|
72
|
+
"""Write the watermark atomically (a temporary file renamed over it)."""
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
|
75
|
+
tmp.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
|
|
76
|
+
tmp.replace(path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def watermark_position(payload: dict[str, Any] | None) -> LakePosition | None:
|
|
80
|
+
"""The position a watermark records, when it's one this code wrote."""
|
|
81
|
+
if payload is None or payload.get("version") != WATERMARK_VERSION:
|
|
82
|
+
return None
|
|
83
|
+
lineage, snapshot = payload.get("lineage"), payload.get("snapshot_id")
|
|
84
|
+
if not isinstance(lineage, str) or not isinstance(snapshot, int) or isinstance(snapshot, bool):
|
|
85
|
+
return None
|
|
86
|
+
return LakePosition(lineage=lineage, snapshot_id=snapshot)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class LakeTextRows:
|
|
90
|
+
""":class:`~atif_embed.domain.ports.TextRowsPort` over the lake, with a watermark.
|
|
91
|
+
|
|
92
|
+
``fallback`` serves every call the lake can't (no lake, a stale one, a
|
|
93
|
+
corpus it doesn't hold). :attr:`discovery` says which path the last call
|
|
94
|
+
took: ``corpus`` (the fallback), ``lake-full``, ``lake-incremental`` or
|
|
95
|
+
``lake-unchanged`` (the watermark is already at the lake's snapshot).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self, *, lake: LakeStepsPort, watermark_path: Path, fallback: TextRowsPort
|
|
100
|
+
) -> None:
|
|
101
|
+
self._lake = lake
|
|
102
|
+
self._watermark_path = watermark_path
|
|
103
|
+
self._fallback = fallback
|
|
104
|
+
self._completed: LakePosition | None = None
|
|
105
|
+
self._last_source: TextRowsPort | None = None
|
|
106
|
+
self.discovery = "none"
|
|
107
|
+
|
|
108
|
+
def iter_unembedded(
|
|
109
|
+
self,
|
|
110
|
+
corpus_root: Path,
|
|
111
|
+
*,
|
|
112
|
+
embedded: dict[str, str] | None = None,
|
|
113
|
+
limit: int | None = None,
|
|
114
|
+
) -> Iterator[PendingText]:
|
|
115
|
+
"""Yield the texts needing embedding, from the lake when there is one to read."""
|
|
116
|
+
self._completed = None
|
|
117
|
+
snapshot = self._lake.open_steps(corpus_root)
|
|
118
|
+
if snapshot is None:
|
|
119
|
+
self._last_source = self._fallback
|
|
120
|
+
self.discovery = "corpus"
|
|
121
|
+
yield from self._fallback.iter_unembedded(corpus_root, embedded=embedded, limit=limit)
|
|
122
|
+
return
|
|
123
|
+
self._last_source = None
|
|
124
|
+
with snapshot:
|
|
125
|
+
position = snapshot.position
|
|
126
|
+
since = self._trusted_since(snapshot, stored_rows=len(embedded or {}))
|
|
127
|
+
selection = PendingSelection(embedded=embedded, limit=limit)
|
|
128
|
+
if since is not None and since == position:
|
|
129
|
+
self.discovery = "lake-unchanged"
|
|
130
|
+
rows: Iterator[tuple[str, str]] = iter(())
|
|
131
|
+
else:
|
|
132
|
+
self.discovery = "lake-full" if since is None else "lake-incremental"
|
|
133
|
+
rows = snapshot.step_texts(min_chars=MIN_TEXT_CHARS, changed_since=since)
|
|
134
|
+
logger.info(
|
|
135
|
+
"embed discovery: {} at lake snapshot {}{}",
|
|
136
|
+
self.discovery,
|
|
137
|
+
position.snapshot_id,
|
|
138
|
+
f" (changes after snapshot {since.snapshot_id})" if since is not None else "",
|
|
139
|
+
)
|
|
140
|
+
yield from selection.select(rows)
|
|
141
|
+
if selection.exhausted:
|
|
142
|
+
self._completed = position
|
|
143
|
+
|
|
144
|
+
def _trusted_since(
|
|
145
|
+
self, snapshot: LakeStepSnapshot, *, stored_rows: int
|
|
146
|
+
) -> LakePosition | None:
|
|
147
|
+
"""The watermark to read changes after, or ``None`` for a full read (with the reason logged)."""
|
|
148
|
+
payload = read_watermark(self._watermark_path)
|
|
149
|
+
since = watermark_position(payload)
|
|
150
|
+
reason: str | None = None
|
|
151
|
+
if payload is None:
|
|
152
|
+
reason = "no watermark yet"
|
|
153
|
+
elif since is None:
|
|
154
|
+
reason = "the watermark was written by another version"
|
|
155
|
+
elif payload.get("min_text_chars") != MIN_TEXT_CHARS:
|
|
156
|
+
reason = "the text floor changed"
|
|
157
|
+
elif payload.get("stored_rows") != stored_rows:
|
|
158
|
+
reason = (
|
|
159
|
+
f"the store holds {stored_rows} rows, not the {payload.get('stored_rows')} "
|
|
160
|
+
"the watermark recorded"
|
|
161
|
+
)
|
|
162
|
+
elif since.lineage != snapshot.position.lineage:
|
|
163
|
+
reason = "the lake was rebuilt since the watermark"
|
|
164
|
+
elif since.snapshot_id > snapshot.position.snapshot_id:
|
|
165
|
+
reason = "the watermark is ahead of the lake"
|
|
166
|
+
elif since != snapshot.position and not snapshot.can_read_changes_since(since):
|
|
167
|
+
reason = "the lake no longer holds the snapshots after the watermark"
|
|
168
|
+
if reason is not None:
|
|
169
|
+
logger.info("embed discovery: full lake read ({})", reason)
|
|
170
|
+
return None
|
|
171
|
+
return since
|
|
172
|
+
|
|
173
|
+
def commit(self, *, stored_rows: int) -> None:
|
|
174
|
+
"""Advance the watermark to the position the last complete pass read."""
|
|
175
|
+
if self._last_source is not None:
|
|
176
|
+
self._last_source.commit(stored_rows=stored_rows)
|
|
177
|
+
return
|
|
178
|
+
if self._completed is None:
|
|
179
|
+
return
|
|
180
|
+
write_watermark(
|
|
181
|
+
self._watermark_path,
|
|
182
|
+
{
|
|
183
|
+
"version": WATERMARK_VERSION,
|
|
184
|
+
"lineage": self._completed.lineage,
|
|
185
|
+
"snapshot_id": self._completed.snapshot_id,
|
|
186
|
+
"min_text_chars": MIN_TEXT_CHARS,
|
|
187
|
+
"stored_rows": stored_rows,
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
logger.info(
|
|
191
|
+
"embed discovery: watermark now at lake snapshot {}", self._completed.snapshot_id
|
|
192
|
+
)
|
|
193
|
+
self._completed = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
__all__ = [
|
|
197
|
+
"LAKE_WATERMARK_FILE",
|
|
198
|
+
"WATERMARK_VERSION",
|
|
199
|
+
"LakeTextRows",
|
|
200
|
+
"read_watermark",
|
|
201
|
+
"watermark_position",
|
|
202
|
+
"write_watermark",
|
|
203
|
+
]
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Local LanceDB embeddings store.
|
|
4
|
+
|
|
5
|
+
Lance is exactly the right shape for "AI artifact store with versioning +
|
|
6
|
+
native vector search":
|
|
7
|
+
|
|
8
|
+
* writes are append-only fragments via ``tbl.add()``
|
|
9
|
+
* stale rows are removed by predicate via ``tbl.delete()``
|
|
10
|
+
* compaction is explicit via ``tbl.optimize()``
|
|
11
|
+
* the vector index lives next to the data
|
|
12
|
+
* DuckDB reads it back via the lance core extension
|
|
13
|
+
(``INSTALL lance; LOAD lance; ATTACH (TYPE LANCE)`` — that read path lives
|
|
14
|
+
in atif-duck's ``register_vss``; this module never touches DuckDB)
|
|
15
|
+
|
|
16
|
+
Schema evolution policy (v2, 2026-08-27)
|
|
17
|
+
----------------------------------------
|
|
18
|
+
ADDITIVE columns migrate ONLINE, never via rebuild: opening a store that
|
|
19
|
+
lacks a newer column evolves the table in place (``Table.add_columns``, a
|
|
20
|
+
metadata-only operation — no re-embedding, no data loss) and backfills the
|
|
21
|
+
new column with a sentinel/default. Rows carrying the
|
|
22
|
+
:data:`_PRE_STAMP_SENTINEL` stamp then mismatch their corpus hash in the
|
|
23
|
+
discovery anti-join and re-embed incrementally through the ordinary
|
|
24
|
+
staleness path (delete-before-append), so the store heals itself across
|
|
25
|
+
successive runs while search stays online. A BREAKING change — a provider
|
|
26
|
+
or dimension switch — stays fail-loud via ``embedding_guard``: those
|
|
27
|
+
vectors live in an incompatible space and must be rebuilt deliberately.
|
|
28
|
+
|
|
29
|
+
The store records its schema version in a ``schema_version.json`` sidecar
|
|
30
|
+
next to the table (written on create, checked on open). Bump
|
|
31
|
+
:data:`SCHEMA_VERSION` with the next evolution and key the migration on the
|
|
32
|
+
stored number rather than probing by column name.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
from datetime import timedelta
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
41
|
+
|
|
42
|
+
import lancedb
|
|
43
|
+
import pyarrow as pa
|
|
44
|
+
from lancedb.index import IvfHnswSq
|
|
45
|
+
from loguru import logger
|
|
46
|
+
|
|
47
|
+
from atif_embed.domain.sql_literal import sql_literal
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Iterable
|
|
51
|
+
|
|
52
|
+
import polars as pl
|
|
53
|
+
|
|
54
|
+
#: Lance table name inside the namespace.
|
|
55
|
+
TABLE_NAME = "embeddings"
|
|
56
|
+
|
|
57
|
+
#: Current store schema version. v1 was the pre-stamp 5-column shape; v2
|
|
58
|
+
#: added ``text_hash`` + ``truncated``. Bump with the next ADDITIVE evolution
|
|
59
|
+
#: and key its migration on the stored number (``read_schema_version``), not
|
|
60
|
+
#: on probing for a column by name.
|
|
61
|
+
SCHEMA_VERSION = 2
|
|
62
|
+
|
|
63
|
+
#: Sidecar filename recording :data:`SCHEMA_VERSION`, written next to the
|
|
64
|
+
#: Lance table directory contents (inside ``lance_uri``). A sidecar rather
|
|
65
|
+
#: than a table column: the version describes the SCHEMA, and reading it must
|
|
66
|
+
#: not require the schema to be readable.
|
|
67
|
+
SCHEMA_VERSION_FILE = "schema_version.json"
|
|
68
|
+
|
|
69
|
+
#: ``text_hash`` stamped on rows that predate the stamp. Angle brackets make
|
|
70
|
+
#: it impossible to equal a real blake2b hex digest, so every pre-stamp row
|
|
71
|
+
#: mismatches its current corpus hash in the discovery anti-join and is
|
|
72
|
+
#: re-embedded incrementally through the ordinary staleness path.
|
|
73
|
+
_PRE_STAMP_SENTINEL = "<pre-stamp>"
|
|
74
|
+
|
|
75
|
+
#: Distance metrics accepted by the LanceDB vector index (``IvfHnswSq``'s
|
|
76
|
+
#: ``distance_type``). Mirrors ``EmbedSettings.hnsw_metric``'s domain.
|
|
77
|
+
DistanceMetric = Literal["cosine", "l2", "dot"]
|
|
78
|
+
|
|
79
|
+
# Process-local connection cache. lancedb.connect() is cheap (no I/O until a
|
|
80
|
+
# table is opened) but caching avoids re-running the consistency-interval setup.
|
|
81
|
+
_DB_CACHE: dict[str, lancedb.DBConnection] = {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _has_table(db: lancedb.DBConnection, name: str) -> bool:
|
|
85
|
+
"""True iff ``name`` exists in the LanceDB root namespace.
|
|
86
|
+
|
|
87
|
+
``db.list_tables()`` returns a ``ListTablesResponse`` (pydantic model)
|
|
88
|
+
with a ``tables: list[str]`` field plus an opaque ``page_token``. The
|
|
89
|
+
local namespace is small enough that pagination never kicks in, so we
|
|
90
|
+
read ``.tables`` directly. ``db.table_names()`` is deprecated as of
|
|
91
|
+
lancedb 0.30 (emits ``DeprecationWarning``) — don't reach for it.
|
|
92
|
+
"""
|
|
93
|
+
return name in db.list_tables().tables
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# pyarrow ships no py.typed and no bundled stubs, so `pa.Schema` is itself an
|
|
97
|
+
# unknown type and any signature naming it reports as partially unknown. The
|
|
98
|
+
# annotation is still the most precise one available and is what a reader needs.
|
|
99
|
+
def lance_schema(dim: int) -> pa.Schema: # pyright: ignore[reportUnknownParameterType]
|
|
100
|
+
"""Pyarrow schema for the embeddings table.
|
|
101
|
+
|
|
102
|
+
The ``embedding`` column is a FIXED-SIZE list of float32. Lance treats
|
|
103
|
+
this as a vector column for indexing — a regular ``pa.list_(pa.float32())``
|
|
104
|
+
won't work for ``create_index``.
|
|
105
|
+
|
|
106
|
+
``text_hash`` is the content stamp the backfill's staleness check reads:
|
|
107
|
+
a re-converted session can change a step's flattened text under the same
|
|
108
|
+
uuid, and without the stamp the uuid-only anti-join would treat that row
|
|
109
|
+
as already embedded forever. ``truncated`` marks rows whose source text
|
|
110
|
+
was clipped before embedding, so a search miss past the clip point is
|
|
111
|
+
attributable instead of invisible.
|
|
112
|
+
"""
|
|
113
|
+
return pa.schema(
|
|
114
|
+
[
|
|
115
|
+
pa.field("uuid", pa.string(), nullable=False),
|
|
116
|
+
pa.field("model", pa.string(), nullable=False),
|
|
117
|
+
pa.field("dim", pa.int32(), nullable=False),
|
|
118
|
+
pa.field("embedding", pa.list_(pa.float32(), dim), nullable=False),
|
|
119
|
+
pa.field("embedded_at", pa.timestamp("us", tz="UTC"), nullable=False),
|
|
120
|
+
pa.field("text_hash", pa.string(), nullable=False),
|
|
121
|
+
pa.field("truncated", pa.bool_(), nullable=False),
|
|
122
|
+
]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def connect_db(uri: Path | str) -> lancedb.DBConnection:
|
|
127
|
+
"""Open (or reuse) a LanceDB connection rooted at ``uri``.
|
|
128
|
+
|
|
129
|
+
``read_consistency_interval=timedelta(0)`` makes every read see writes
|
|
130
|
+
from other processes immediately. Without this, readers get a stale
|
|
131
|
+
manifest snapshot and miss recently appended fragments.
|
|
132
|
+
"""
|
|
133
|
+
key = str(Path(uri).resolve())
|
|
134
|
+
cached = _DB_CACHE.get(key)
|
|
135
|
+
if cached is not None:
|
|
136
|
+
return cached
|
|
137
|
+
Path(uri).mkdir(parents=True, exist_ok=True)
|
|
138
|
+
db = lancedb.connect(key, read_consistency_interval=timedelta(0))
|
|
139
|
+
_DB_CACHE[key] = db
|
|
140
|
+
return db
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def read_schema_version(lance_uri: Path) -> int | None:
|
|
144
|
+
"""Return the store's recorded schema version, or ``None`` when unrecorded.
|
|
145
|
+
|
|
146
|
+
``None`` covers both a store that predates the sidecar (v1/v2 written
|
|
147
|
+
before 2026-08-27) and a directory with no store at all; callers that
|
|
148
|
+
need to distinguish those already know whether the table exists.
|
|
149
|
+
"""
|
|
150
|
+
sidecar = Path(lance_uri) / SCHEMA_VERSION_FILE
|
|
151
|
+
try:
|
|
152
|
+
payload = json.loads(sidecar.read_text(encoding="utf-8"))
|
|
153
|
+
except (OSError, ValueError):
|
|
154
|
+
return None
|
|
155
|
+
version = payload.get("schema_version")
|
|
156
|
+
return int(version) if isinstance(version, int) else None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def write_schema_version(lance_uri: Path, version: int = SCHEMA_VERSION) -> None:
|
|
160
|
+
"""Record ``version`` in the store's sidecar (create or overwrite)."""
|
|
161
|
+
sidecar = Path(lance_uri) / SCHEMA_VERSION_FILE
|
|
162
|
+
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
sidecar.write_text(json.dumps({"schema_version": version}) + "\n", encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def migrate_pre_stamp_table(tbl: Any, lance_uri: Path) -> None:
|
|
167
|
+
"""Evolve a pre-stamp table to the v2 schema, in place and online.
|
|
168
|
+
|
|
169
|
+
Adds ``text_hash`` (backfilled with :data:`_PRE_STAMP_SENTINEL`) and
|
|
170
|
+
``truncated`` (backfilled false) via Lance schema evolution
|
|
171
|
+
(``Table.add_columns`` with SQL default expressions) — a metadata-only
|
|
172
|
+
operation: no rows are dropped, no vectors re-embedded, and readers keep
|
|
173
|
+
working throughout. The sentinel then drives incremental re-embedding
|
|
174
|
+
through the ordinary staleness path (see the module docstring).
|
|
175
|
+
"""
|
|
176
|
+
missing = [name for name in ("text_hash", "truncated") if name not in tbl.schema.names]
|
|
177
|
+
additions: dict[str, str] = {}
|
|
178
|
+
if "text_hash" in missing:
|
|
179
|
+
additions["text_hash"] = f"'{_PRE_STAMP_SENTINEL}'"
|
|
180
|
+
if "truncated" in missing:
|
|
181
|
+
additions["truncated"] = "false"
|
|
182
|
+
if not additions:
|
|
183
|
+
return
|
|
184
|
+
tbl.add_columns(additions)
|
|
185
|
+
write_schema_version(lance_uri)
|
|
186
|
+
logger.info(
|
|
187
|
+
"Migrated pre-stamp Lance store at {} to schema v{}: added {} online "
|
|
188
|
+
"(metadata-only; rows re-embed incrementally via the sentinel staleness path)",
|
|
189
|
+
lance_uri,
|
|
190
|
+
SCHEMA_VERSION,
|
|
191
|
+
sorted(additions),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def open_or_create_table(db: lancedb.DBConnection, *, dim: int) -> Any:
|
|
196
|
+
"""Open the embeddings table, creating it with the right schema if missing.
|
|
197
|
+
|
|
198
|
+
An existing table that predates a current ADDITIVE column is evolved in
|
|
199
|
+
place (see :func:`migrate_pre_stamp_table`) before it is handed to the
|
|
200
|
+
write path, so ``add_chunk`` never appends a 7-column frame into a
|
|
201
|
+
5-column table (Lance rejects the schema mismatch).
|
|
202
|
+
"""
|
|
203
|
+
if _has_table(db, TABLE_NAME):
|
|
204
|
+
tbl = db.open_table(TABLE_NAME)
|
|
205
|
+
migrate_pre_stamp_table(tbl, Path(db.uri))
|
|
206
|
+
if read_schema_version(Path(db.uri)) is None:
|
|
207
|
+
# A store from before the sidecar existed whose schema is already
|
|
208
|
+
# current: stamp it so the NEXT evolution keys on the number.
|
|
209
|
+
write_schema_version(Path(db.uri))
|
|
210
|
+
return tbl
|
|
211
|
+
tbl = db.create_table(TABLE_NAME, schema=lance_schema(dim), mode="create")
|
|
212
|
+
write_schema_version(Path(db.uri))
|
|
213
|
+
return tbl
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _is_missing_column(exc: Exception, column: str) -> bool:
|
|
217
|
+
"""True when ``exc`` is LanceDB's untyped "no such column" ValueError.
|
|
218
|
+
|
|
219
|
+
LanceDB raises a plain ``ValueError`` whose text carries the schema
|
|
220
|
+
complaint, so matching the message is the only available discriminator.
|
|
221
|
+
"""
|
|
222
|
+
message = str(exc)
|
|
223
|
+
return "Schema error" in message and f"No field named {column}" in message
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def delete_uuids(tbl: Any, uuids: Iterable[str]) -> int:
|
|
227
|
+
"""Delete every row whose uuid is in ``uuids``; return how many were named.
|
|
228
|
+
|
|
229
|
+
Re-embedding a changed text must REPLACE the stale vector, not sit
|
|
230
|
+
beside it: two rows under one uuid would make the kNN join fan out and
|
|
231
|
+
rank against pre-change content. Lance has no upsert on this table, so
|
|
232
|
+
delete-then-append is the replace.
|
|
233
|
+
"""
|
|
234
|
+
names = sorted(set(uuids))
|
|
235
|
+
if not names:
|
|
236
|
+
return 0
|
|
237
|
+
predicate = f"uuid IN ({', '.join(sql_literal(u) for u in names)})"
|
|
238
|
+
tbl.delete(predicate)
|
|
239
|
+
return len(names)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def add_chunk(tbl: Any, df: pl.DataFrame) -> None:
|
|
243
|
+
"""Append one chunk of embeddings.
|
|
244
|
+
|
|
245
|
+
The polars DataFrame must have ``embedding: pl.Array(pl.Float32, dim)``
|
|
246
|
+
— the fixed-size list shape Lance expects. ``to_arrow()`` preserves the
|
|
247
|
+
distinction; a regular ``pl.List`` becomes a variable-size list and Lance
|
|
248
|
+
will reject it as a vector column for indexing.
|
|
249
|
+
"""
|
|
250
|
+
arrow_table = df.to_arrow()
|
|
251
|
+
tbl.add(arrow_table)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def ensure_index(tbl: Any, *, metric: DistanceMetric = "cosine") -> None:
|
|
255
|
+
"""Create the IVF_HNSW_SQ vector index on the ``embedding`` column.
|
|
256
|
+
|
|
257
|
+
No-op if an index already exists. Index name is exactly ``IVF_HNSW_SQ``
|
|
258
|
+
(scalar-quantized HNSW) — there is no plain ``IVF_HNSW`` literal in
|
|
259
|
+
LanceDB. SQ gives a good size/recall balance at small-corpus scale.
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
existing = tbl.list_indices()
|
|
263
|
+
except (AttributeError, RuntimeError):
|
|
264
|
+
existing = []
|
|
265
|
+
for idx in existing:
|
|
266
|
+
if getattr(idx, "column", None) == "embedding":
|
|
267
|
+
return
|
|
268
|
+
if "embedding" in getattr(idx, "columns", []):
|
|
269
|
+
return
|
|
270
|
+
try:
|
|
271
|
+
# lancedb >=0.30 unified index API: pass the vector column as the first
|
|
272
|
+
# positional arg and an index-config object via ``config=``. The legacy
|
|
273
|
+
# ``metric=/vector_column_name=/index_type=`` kwargs are deprecated
|
|
274
|
+
# (they emit a DeprecationWarning as of 0.34). ``IvfHnswSq`` is the
|
|
275
|
+
# config class for the scalar-quantized HNSW index this store uses;
|
|
276
|
+
# ``distance_type`` carries the former ``metric``.
|
|
277
|
+
tbl.create_index("embedding", config=IvfHnswSq(distance_type=metric))
|
|
278
|
+
except (RuntimeError, ValueError) as exc:
|
|
279
|
+
# Index creation can fail on tiny tables (no vectors yet, or k-means
|
|
280
|
+
# seeding hits a degenerate split). Log and continue — cosine search
|
|
281
|
+
# still works without an index, just slower (brute-force scan).
|
|
282
|
+
logger.warning("LanceDB create_index failed ({}); falling back to brute-force scan", exc)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def optimize_if_needed(tbl: Any) -> None:
|
|
286
|
+
"""Compact accumulated fragments + clean up old versions.
|
|
287
|
+
|
|
288
|
+
Lance accumulates one fragment per ``tbl.add()`` call. Past ~100
|
|
289
|
+
fragments read latency starts to degrade; the backfill triggers
|
|
290
|
+
compaction much earlier (every 8 chunks) because the embeddings table is
|
|
291
|
+
small. ``tbl.optimize()`` returns ``None`` and mutates in place.
|
|
292
|
+
"""
|
|
293
|
+
try:
|
|
294
|
+
tbl.optimize()
|
|
295
|
+
except (RuntimeError, AttributeError) as exc:
|
|
296
|
+
logger.warning("Lance optimize failed: {}", exc)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def count_rows(lance_uri: Path) -> int:
|
|
300
|
+
"""Return the row count of the embeddings Lance table, or 0 when missing."""
|
|
301
|
+
if not lance_uri.exists():
|
|
302
|
+
return 0
|
|
303
|
+
db = connect_db(lance_uri)
|
|
304
|
+
if not _has_table(db, TABLE_NAME):
|
|
305
|
+
return 0
|
|
306
|
+
return int(db.open_table(TABLE_NAME).count_rows())
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def table_identity(lance_uri: Path) -> tuple[str, int] | None:
|
|
310
|
+
"""Return the store's stamped ``(model, dim)`` from its first row, or ``None``.
|
|
311
|
+
|
|
312
|
+
``None`` means the store is empty or absent (fresh install), so any
|
|
313
|
+
provider may claim it. Every row stamps the same ``model`` / ``dim`` (see
|
|
314
|
+
``run_backfill``), so reading the first row is sufficient. Used by the
|
|
315
|
+
fail-loud provider/dimension guard
|
|
316
|
+
(:func:`atif_embed.domain.embedding_guard.ensure_store_matches`).
|
|
317
|
+
"""
|
|
318
|
+
n = count_rows(lance_uri)
|
|
319
|
+
if n == 0:
|
|
320
|
+
return None
|
|
321
|
+
db = connect_db(lance_uri)
|
|
322
|
+
tbl = db.open_table(TABLE_NAME)
|
|
323
|
+
arrow = tbl.search().select(["model", "dim"]).limit(1).to_arrow()
|
|
324
|
+
if arrow.num_rows == 0:
|
|
325
|
+
return None
|
|
326
|
+
model = str(arrow.column("model").to_pylist()[0])
|
|
327
|
+
dim = int(arrow.column("dim").to_pylist()[0])
|
|
328
|
+
return (model, dim)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def get_embedded_hashes(lance_uri: Path) -> dict[str, str]:
|
|
332
|
+
"""Return ``{uuid: text_hash}`` for every embedded row.
|
|
333
|
+
|
|
334
|
+
Used by the backfill's anti-join: a uuid whose stored hash differs from
|
|
335
|
+
the corpus's current text is STALE, not embedded, so it must be re-picked
|
|
336
|
+
and its old row deleted. Reads only two string columns via a
|
|
337
|
+
column-projected scan so the ``FLOAT[dim]`` vector column is never
|
|
338
|
+
decoded — ``to_arrow()`` has no projection pushdown and materializes the
|
|
339
|
+
full N×dim float matrix before a ``.select()`` could prune it (~221 MB
|
|
340
|
+
peak at 20k×1024 unprojected vs ~0 MB projected). The explicit
|
|
341
|
+
``limit(count_rows())`` overrides LanceDB's default 10-row query cap so
|
|
342
|
+
the scan returns every row.
|
|
343
|
+
|
|
344
|
+
A store written before ``text_hash`` existed has no such column, and
|
|
345
|
+
LanceDB reports that as a bare ``ValueError``. That is NOT terminal:
|
|
346
|
+
every row gets :data:`_PRE_STAMP_SENTINEL` instead (a value no real
|
|
347
|
+
blake2b digest can equal), so the discovery anti-join marks every row
|
|
348
|
+
stale and the store migrates itself incrementally through the ordinary
|
|
349
|
+
delete-before-append path — no rebuild, no empty-store window. The
|
|
350
|
+
2026-08-24 incident (both production corpora rebuilt, ~3.4M vectors of Cohere
|
|
351
|
+
spend) came from raising a destroy-and-rebuild error here instead.
|
|
352
|
+
"""
|
|
353
|
+
n = count_rows(lance_uri)
|
|
354
|
+
if n == 0:
|
|
355
|
+
return {}
|
|
356
|
+
db = connect_db(lance_uri)
|
|
357
|
+
tbl = db.open_table(TABLE_NAME)
|
|
358
|
+
try:
|
|
359
|
+
arrow = tbl.search().select(["uuid", "text_hash"]).limit(n).to_arrow()
|
|
360
|
+
except ValueError as exc:
|
|
361
|
+
if not _is_missing_column(exc, "text_hash"):
|
|
362
|
+
raise
|
|
363
|
+
logger.info(
|
|
364
|
+
"Embedding store at {} predates the text_hash stamp; treating all "
|
|
365
|
+
"{} rows as stale for incremental online re-embedding",
|
|
366
|
+
lance_uri,
|
|
367
|
+
n,
|
|
368
|
+
)
|
|
369
|
+
arrow = tbl.search().select(["uuid"]).limit(n).to_arrow()
|
|
370
|
+
return {str(u): _PRE_STAMP_SENTINEL for u in arrow.column("uuid").to_pylist()}
|
|
371
|
+
uuids = arrow.column("uuid").to_pylist()
|
|
372
|
+
hashes = arrow.column("text_hash").to_pylist()
|
|
373
|
+
return {str(u): str(h) for u, h in zip(uuids, hashes, strict=True)}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class LanceVectorStore:
|
|
377
|
+
""":class:`~atif_embed.domain.ports.VectorStorePort` over this module.
|
|
378
|
+
|
|
379
|
+
Thin object adapter binding the module functions to one ``lance_uri`` +
|
|
380
|
+
``dim`` pair so the use case takes a single injectable seam.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(self, lance_uri: Path, *, dim: int) -> None:
|
|
384
|
+
self._lance_uri = lance_uri
|
|
385
|
+
self._dim = dim
|
|
386
|
+
self._tbl: Any | None = None
|
|
387
|
+
|
|
388
|
+
def _table(self) -> Any:
|
|
389
|
+
if self._tbl is None:
|
|
390
|
+
db = connect_db(self._lance_uri)
|
|
391
|
+
self._tbl = open_or_create_table(db, dim=self._dim)
|
|
392
|
+
return self._tbl
|
|
393
|
+
|
|
394
|
+
def table_identity(self) -> tuple[str, int] | None:
|
|
395
|
+
"""The store's stamped ``(model, dim)``, or None when it has none."""
|
|
396
|
+
return table_identity(self._lance_uri)
|
|
397
|
+
|
|
398
|
+
def get_embedded_hashes(self) -> dict[str, str]:
|
|
399
|
+
"""``{uuid: text_hash}`` for every row already in the store."""
|
|
400
|
+
return get_embedded_hashes(self._lance_uri)
|
|
401
|
+
|
|
402
|
+
def delete_uuids(self, uuids: Iterable[str]) -> int:
|
|
403
|
+
"""Delete the rows carrying ``uuids``; return how many went."""
|
|
404
|
+
return delete_uuids(self._table(), uuids)
|
|
405
|
+
|
|
406
|
+
def add_chunk(self, df: pl.DataFrame) -> None:
|
|
407
|
+
"""Append one embedded chunk to the table."""
|
|
408
|
+
add_chunk(self._table(), df)
|
|
409
|
+
|
|
410
|
+
def optimize(self) -> None:
|
|
411
|
+
"""Compact the table's fragments when it has accumulated enough."""
|
|
412
|
+
optimize_if_needed(self._table())
|
|
413
|
+
|
|
414
|
+
def ensure_index(self, *, metric: str = "cosine") -> None:
|
|
415
|
+
"""Create the vector index under ``metric`` when the table lacks one."""
|
|
416
|
+
if metric not in {"cosine", "l2", "dot"}:
|
|
417
|
+
msg = f"Unsupported Lance metric: {metric!r}"
|
|
418
|
+
raise ValueError(msg)
|
|
419
|
+
ensure_index(self._table(), metric=metric) # type: ignore[arg-type]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
__all__ = [
|
|
423
|
+
"SCHEMA_VERSION",
|
|
424
|
+
"SCHEMA_VERSION_FILE",
|
|
425
|
+
"TABLE_NAME",
|
|
426
|
+
"LanceVectorStore",
|
|
427
|
+
"add_chunk",
|
|
428
|
+
"connect_db",
|
|
429
|
+
"count_rows",
|
|
430
|
+
"delete_uuids",
|
|
431
|
+
"ensure_index",
|
|
432
|
+
"get_embedded_hashes",
|
|
433
|
+
"lance_schema",
|
|
434
|
+
"migrate_pre_stamp_table",
|
|
435
|
+
"open_or_create_table",
|
|
436
|
+
"optimize_if_needed",
|
|
437
|
+
"read_schema_version",
|
|
438
|
+
"table_identity",
|
|
439
|
+
"write_schema_version",
|
|
440
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Build the embedder ``ATIF_SQL_EMBED_PROVIDER`` selects.
|
|
4
|
+
|
|
5
|
+
The one place that names a concrete :class:`~atif_embed.domain.ports.EmbeddingProvider`.
|
|
6
|
+
Each adapter is imported only when selected, so the Cohere path never loads
|
|
7
|
+
torch and the Gemma path never loads boto3.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from atif_embed.domain.ports import EmbeddingProvider
|
|
16
|
+
from atif_embed.infrastructure.settings import EmbedSettings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_embedder(settings: EmbedSettings) -> EmbeddingProvider:
|
|
20
|
+
"""The selected provider's embedder, constructed without loading a model or a client."""
|
|
21
|
+
if settings.embed_provider == "gemma":
|
|
22
|
+
from atif_embed.infrastructure.gemma_local import EmbeddingGemmaLocalEmbedder
|
|
23
|
+
|
|
24
|
+
return EmbeddingGemmaLocalEmbedder(settings)
|
|
25
|
+
from atif_embed.infrastructure.cohere_bedrock import CohereBedrockEmbedder
|
|
26
|
+
|
|
27
|
+
return CohereBedrockEmbedder(settings)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["build_embedder"]
|