Coverage for src/lilbee/data/store/lance_helpers.py: 100%
93 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
1"""LanceDB plumbing helpers: table introspection, safe deletes, SQL escaping, error text."""
3from __future__ import annotations
5import logging
6import threading
7from typing import TYPE_CHECKING
9from lilbee.catalog.refs import hf_repo_from_ref
10from lilbee.runtime.lock import write_lock
12from .types import LOCAL_OWNER, ChunkType
14if TYPE_CHECKING:
15 from pathlib import Path
17 import lancedb
18 import lancedb.table
19 import pyarrow as pa
21log = logging.getLogger(__name__)
23# The chunks table's body-text column: FTS-indexed and searched by the lexical
24# arm, named here so the store's query and index code shares one spelling with
25# its sibling _TITLE_COLUMN.
26_CHUNK_COLUMN = "chunk"
29def install_lancedb_thread_error_suppressor() -> None:
30 """Install a ``threading.excepthook`` that swallows lancedb shutdown noise.
31 lancedb has no ``close()`` API and its internal event loop thread crashes
32 during Python interpreter teardown. The exception is harmless (the process
33 is exiting anyway) but pollutes CLI/TUI output. This is opt-in so importing
34 ``lilbee.data.store`` has no hidden side effects; call it once from the CLI/TUI
35 bootstrap.
36 """
37 original = threading.excepthook
39 def _hook(args: threading.ExceptHookArgs) -> None:
40 if args.thread and "LanceDB" in args.thread.name:
41 return
42 original(args)
44 threading.excepthook = _hook
47def table_names(db: lancedb.DBConnection) -> list[str]:
48 """Get list of table names, handling the ListTablesResponse object."""
49 result = db.list_tables()
50 try:
51 return result.tables # type: ignore[no-any-return, union-attr]
52 except AttributeError:
53 return list(result) # type: ignore[arg-type]
56def ensure_table(db: lancedb.DBConnection, name: str, schema: pa.Schema) -> lancedb.table.Table:
57 if name in table_names(db):
58 return db.open_table(name)
59 try:
60 return db.create_table(name, schema=schema)
61 except ValueError:
62 return db.open_table(name)
65def _safe_delete_unlocked(table: lancedb.table.Table, predicate: str) -> bool:
66 """Delete rows matching predicate. Caller must hold write lock.
68 Returns True when the delete succeeded, False when it raised (logged). The
69 return lets delete-then-add callers avoid corrupting state on a swallowed
70 failure (e.g. inserting a row whose stale predecessor was never removed).
71 """
72 try:
73 table.delete(predicate)
74 return True
75 except Exception:
76 log.warning("Failed to delete rows matching: %s", predicate, exc_info=True)
77 return False
80def safe_delete(
81 table: lancedb.table.Table, predicate: str, lancedb_dir: Path | None = None
82) -> bool:
83 """Delete rows matching predicate, logging on failure. Returns success.
85 Pass the store's ``lancedb_dir`` so the write lock coordinates on that
86 instance's data dir; ``None`` falls back to the global config dir.
87 """
88 with write_lock(lancedb_dir):
89 return _safe_delete_unlocked(table, predicate)
92def escape_sql_string(value: str) -> str:
93 """Escape a value for a single-quoted SQL string literal in a LanceDB predicate.
95 LanceDB's Datafusion engine follows standard SQL: the only escape inside a
96 ``'...'`` literal is doubling the single quote. Backslash is an ordinary
97 character, so escaping it (``\\`` -> ``\\\\``) corrupts the literal and makes a
98 value containing a backslash (e.g. a Windows path) never match.
99 """
100 return value.replace("'", "''")
103def local_owner_predicate() -> str:
104 """SQL predicate selecting the local human's own memories."""
105 return f"owner = '{LOCAL_OWNER}'"
108def human_recall_predicate() -> str:
109 """SQL predicate for the human: own memories plus any an agent has shared.
111 The mirror of :func:`agent_recall_predicate`: ``shared=True`` on an agent
112 memory means "expose to the human's TUI/CLI", so the human's view must
113 include those rather than only ``owner = 'local'``.
114 """
115 return f"owner = '{LOCAL_OWNER}' OR (shared = true AND owner != '{LOCAL_OWNER}')"
118def agent_recall_predicate(owner: str) -> str:
119 """SQL predicate for an agent: its own memories plus the human's shared ones."""
120 return f"owner = '{escape_sql_string(owner)}' OR (shared = true AND owner = '{LOCAL_OWNER}')"
123def _chunk_type_predicate(chunk_type: ChunkType | str) -> str:
124 """SQL predicate that matches ``chunk_type`` while tolerating NULL rows.
126 ``'raw'`` means document content, so it matches extracted table rows as
127 well as raw ones, and the NULL rows written before the column existed.
128 Scoping a search to the user's documents must not silently drop their
129 tables. A ``'wiki'`` filter matches only generated pages.
130 """
131 escaped = escape_sql_string(chunk_type)
132 if chunk_type == ChunkType.RAW:
133 return f"(chunk_type IN ('{ChunkType.RAW}', '{ChunkType.TABLE}') OR chunk_type IS NULL)"
134 return f"chunk_type = '{escaped}'"
137def _has_fts_index(table: lancedb.table.Table, column: str = _CHUNK_COLUMN) -> bool:
138 """Return True when an FTS index on *column* already exists."""
139 try:
140 for idx in table.list_indices():
141 if idx.index_type == "FTS" and column in idx.columns:
142 return True
143 except Exception:
144 return False
145 return False
148def _has_scalar_index(table: lancedb.table.Table, column: str) -> bool:
149 """Return True when a scalar index on *column* already exists.
151 lilbee builds only scalar indexes on the columns it prefilters by, never an
152 FTS or vector index, so any index touching *column* is the scalar one.
153 """
154 try:
155 return any(column in idx.columns for idx in table.list_indices())
156 except Exception:
157 return False
160def _has_vector_index(table: lancedb.table.Table) -> bool:
161 """Return True when an ANN index on the vector column already exists.
163 LanceDB reports IVF index types as ``IvfPq`` / ``IvfFlat`` etc., so the
164 family match is case-insensitive.
165 """
166 try:
167 for idx in table.list_indices():
168 if "IVF" in idx.index_type.upper() and "vector" in idx.columns:
169 return True
170 except Exception:
171 return False
172 return False
175def _escape_like_wildcards(value: str) -> str:
176 """Escape LIKE metacharacters so a search term matches literally.
178 ``%`` and ``_`` are wildcards inside a LIKE pattern; without escaping, a
179 search for ``a_b`` would also match ``axb``. Backslash is escaped first
180 because it is the ESCAPE character the predicate declares.
181 """
182 return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
185def _sources_search_filter(search: str | None, *, include_title: bool = False) -> str | None:
186 """Case-insensitive filename (and optionally title) WHERE clause, or ``None``.
188 *include_title* requires the caller to have checked the column exists
189 (pre-title stores lack it).
190 """
191 if not search:
192 return None
193 escaped = escape_sql_string(_escape_like_wildcards(search.lower()))
194 clause = f"LOWER(filename) LIKE '%{escaped}%' ESCAPE '\\'"
195 if include_title:
196 clause = f"({clause} OR LOWER(title) LIKE '%{escaped}%' ESCAPE '\\')"
197 return clause
200def refs_compatible(
201 persisted_ref: str,
202 current_ref: str,
203 persisted_dim: int,
204 current_dim: int,
205) -> bool:
206 """Return True when *persisted_ref* and *current_ref* describe the same embedder.
208 Compatible iff dims match and either the raw refs are equal or the persisted
209 ref is the legacy bare-repo form (``<org>/<repo>`` without a ``.gguf``
210 filename) whose repo matches the current canonical full ref. The legacy
211 asymmetry exists because pre-canonical lilbee versions persisted only the
212 repo; the current code persists the full ``<org>/<repo>/<filename>.gguf``.
213 Two different ``.gguf`` files in the same repo are not lumped together
214 (different quantizations can produce subtly different vectors), so both-
215 full-ref strict identity is preserved.
216 """
217 if persisted_dim != current_dim:
218 return False
219 if persisted_ref == current_ref:
220 return True
221 if persisted_ref.endswith(".gguf"):
222 return False
223 if not current_ref.endswith(".gguf"):
224 return False
225 return hf_repo_from_ref(current_ref) == persisted_ref