Coverage for src/lilbee/data/store/types.py: 100%

222 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Public dataclasses, TypedDicts, enums, and constants for the store package.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from datetime import timedelta 

7from enum import StrEnum 

8from typing import NamedTuple, NotRequired, TypedDict 

9 

10from pydantic import BaseModel, ConfigDict, Field, field_validator 

11 

12# How often readers re-check the manifest for new versions from other processes. 

13# Zero means strong consistency (every read checks); higher values reduce disk I/O 

14# on slow media (HDD) at the cost of serving slightly stale data. 

15READ_CONSISTENCY_INTERVAL = timedelta(seconds=5) 

16 

17 

18@dataclass 

19class ConceptRecords: 

20 """Rows for the three concept tables, built from one or more files' chunks.""" 

21 

22 nodes: list[dict] 

23 edges: list[dict] 

24 chunk_concepts: list[dict] 

25 

26 @classmethod 

27 def merged(cls, batches: list[ConceptRecords]) -> ConceptRecords: 

28 """Concatenate several record sets into one batched write unit.""" 

29 return cls( 

30 nodes=[row for batch in batches for row in batch.nodes], 

31 edges=[row for batch in batches for row in batch.edges], 

32 chunk_concepts=[row for batch in batches for row in batch.chunk_concepts], 

33 ) 

34 

35 

36class SourceType(StrEnum): 

37 """Values for the ``_sources.source_type`` column. 

38 

39 ``DOCUMENT`` mirrors a file under ``documents/`` and is managed by the 

40 file-driven sync. ``IMPORTED`` is detached: it came from ``lilbee import`` 

41 and has no backing file, so sync must not treat it as a missing document. 

42 ``WIKI`` is an indexed wiki page; no listing shows it. 

43 """ 

44 

45 DOCUMENT = "document" 

46 IMPORTED = "imported" 

47 WIKI = "wiki" 

48 

49 

50class HeldOut(StrEnum): 

51 """Why a ``_sources`` row holds its path out of later syncs. 

52 

53 ``REMOVED``: the user removed it; the row also keeps the path a citation names. 

54 ``FAILED``: ingestion produced nothing at that content; a row that holds a 

55 document keeps it listed. 

56 """ 

57 

58 FAILED = "failed" 

59 REMOVED = "removed" 

60 

61 

62class HeldRecord(NamedTuple): 

63 """Why a sync holds a path out, and the content hash it holds it out at.""" 

64 

65 kind: HeldOut 

66 file_hash: str 

67 reason: str 

68 

69 

70# The reason a removal records, shown with the held-out file. 

71REMOVED_REASON = "removed via remove (add the source again to restore)" 

72 

73 

74class SourceMeta(NamedTuple): 

75 """Document-level metadata captured at extraction time. 

76 

77 ``title`` is always derivable (extraction metadata or the cleaned filename 

78 stem); ``authors`` and ``created_at`` are only present when the extractor 

79 reports them. Empty strings persist as NULL so old and new rows read alike. 

80 """ 

81 

82 title: str = "" 

83 authors: str = "" 

84 created_at: str = "" 

85 

86 

87class ChunkWrite(NamedTuple): 

88 """One document's chunks plus its source-table update, for a batched write. 

89 

90 ``Store.write_chunks_batch`` folds many of these into a single locked 

91 transaction so bulk ingest doesn't pay a write-lock acquisition per document. 

92 ``page_texts`` rows land in the same transaction, after the cleanup delete 

93 and before the source row. ``source_type`` lets the detached import path 

94 reuse the same atomic write while still tagging its rows ``IMPORTED``. 

95 """ 

96 

97 source: str 

98 file_hash: str 

99 records: list[dict] 

100 needs_cleanup: bool 

101 stat: SourceStat | None = None 

102 page_texts: list[dict] | None = None 

103 source_type: SourceType = SourceType.DOCUMENT 

104 meta: SourceMeta | None = None 

105 

106 

107class ChunkType(StrEnum): 

108 """Values for the ``chunk_type`` column. 

109 

110 Documents ingest as ``RAW``, extracted tables as ``TABLE`` (when table 

111 extraction is on), and wiki pages written by the wiki producer as 

112 ``WIKI``. Callers filter with ``Store.search(chunk_type=...)``; a ``RAW`` 

113 filter also covers table chunks, since both are document content. 

114 """ 

115 

116 RAW = "raw" 

117 TABLE = "table" 

118 WIKI = "wiki" 

119 

120 

121# ``schema_version`` is an integer for forward-compat. Bump only if we ever need to 

122# add or rename a meta column without forcing every store to drop_all. 

123# 2: nomic-embed document prefixes are stamped at ingest (see 

124# lilbee.retrieval.embedding_profiles doc_prefix_since). 

125# 3: every table keys a source by ``source_id``; ``_sources`` alone holds its path. 

126META_SCHEMA_VERSION = 3 

127 

128# Always-true predicate used to clear the single-row ``_meta`` table before re-insert. 

129# Lance's ``Table.delete`` requires a SQL where clause; this matches every row without 

130# coupling the deletion to any specific column's value domain. 

131META_DELETE_ALL_PREDICATE = "schema_version IS NOT NULL" 

132 

133# Same, for the single-row ``_entity_schema`` table. 

134ENTITY_SCHEMA_DELETE_ALL_PREDICATE = "updated_at IS NOT NULL" 

135 

136 

137class EntitySchemaState(TypedDict): 

138 """Single-row state of the induced entity schema. 

139 

140 ``applied`` records whether a full extraction pass completed under this 

141 schema; an interrupted pass leaves it False so the next sync redoes the 

142 (idempotent) pass. ``source_count`` is how many documents the index held 

143 when the schema was induced, which is what the next sync compares against 

144 to decide the corpus has drifted far enough to re-induce. 

145 """ 

146 

147 schema_json: str 

148 applied: bool 

149 source_count: int 

150 updated_at: str 

151 

152 

153class SearchScope(StrEnum): 

154 """What the user wants to search over. 

155 

156 Values are used as-is on CLI flags, MCP params, and HTTP query strings. 

157 ``BOTH`` resolves to a ``None`` ``chunk_type`` (no filter); the two 

158 others map 1:1 to the chunks-table values. 

159 """ 

160 

161 RAW = ChunkType.RAW 

162 WIKI = ChunkType.WIKI 

163 BOTH = "both" 

164 

165 

166def scope_to_chunk_type(scope: SearchScope | str | None) -> ChunkType | None: 

167 """Translate a user-facing scope into a ``Store.search`` ``chunk_type`` arg. 

168 

169 ``None``/``"both"`` → no filter. ``"raw"`` / ``"wiki"`` → the matching 

170 ``ChunkType``. Raises ``ValueError`` on any other string. 

171 """ 

172 if scope is None: 

173 return None 

174 normalized = SearchScope(scope) 

175 if normalized is SearchScope.BOTH: 

176 return None 

177 return ChunkType(normalized.value) 

178 

179 

180class SearchChunk(BaseModel): 

181 """A search result from LanceDB. 

182 Every store search path sets ``score``: canonical [0, 1] relevance, 

183 higher = better. Ranking, filtering, and selection compare only this 

184 field; the arm-specific fields below it are provenance. 

185 Vector-arm rows carry ``distance``; FTS-arm rows carry ``bm25_score``; 

186 reranked rows additionally carry ``rerank_score`` (higher = better). 

187 Memory pseudo-rows carry ``memory_id`` and a ``memory:<id>`` source. 

188 """ 

189 

190 model_config = ConfigDict(populate_by_name=True) 

191 

192 source: str 

193 content_type: str 

194 chunk_type: ChunkType = ChunkType.RAW 

195 

196 @field_validator("chunk_type", mode="before") 

197 @classmethod 

198 def _coerce_none_chunk_type(cls, v: str | None) -> str: 

199 """LanceDB rows from before the chunk_type column was added return None.""" 

200 return v if v is not None else ChunkType.RAW 

201 

202 # Document title at ingest time; None on rows written before the column 

203 # existed (or by writers that carry no title, e.g. wiki pages). 

204 title: str | None = None 

205 

206 page_start: int 

207 page_end: int 

208 line_start: int 

209 line_end: int 

210 chunk: str 

211 chunk_index: int 

212 vector: list[float] = Field(repr=False) 

213 distance: float | None = Field(None, alias="_distance") 

214 # Legacy ``_relevance_score`` passthrough. No store path populates it and 

215 # no ranking code reads it; it survives only as a display-compatible field 

216 # for rows produced by external LanceDB rerankers. 

217 relevance_score: float | None = Field(None, validation_alias="_relevance_score") 

218 # FTS/BM25-only rows carry a raw, unbounded ``_score``. It lives in its own 

219 # field so it never contaminates the canonical ``score``; the 

220 # confidence-based expansion-skip reads it (squashed to [0, 1]), and the 

221 # relevance filter treats its presence as lexical support. 

222 bm25_score: float | None = Field(None, validation_alias="_score") 

223 rerank_score: float | None = None 

224 # Canonical relevance in [0, 1], set by the store on every search path: 

225 # normalized reciprocal-rank fusion on the hybrid path, clamped cosine 

226 # similarity on vector-only, list-normalized BM25 on FTS-only probes. 

227 score: float | None = None 

228 # Set only on memory pseudo-rows built from a recalled MemoryRow (never by 

229 # the store): the memory id, so consumers can tell a memory source apart 

230 # from a retrieved passage and link it back to /api/memories. 

231 memory_id: str | None = None 

232 

233 

234class SourceRecord(TypedDict): 

235 """A tracked source document record. 

236 

237 The stat columns are absent on rows read from stores created before they 

238 existed; ``source_stat`` is the accessor that folds absence and the 

239 ``SOURCE_STAT_UNKNOWN`` sentinel into ``None``. 

240 """ 

241 

242 filename: str 

243 file_hash: str 

244 ingested_at: str 

245 chunk_count: int 

246 source_type: str 

247 size_bytes: NotRequired[int] 

248 mtime_ns: NotRequired[int] 

249 stat_captured_ns: NotRequired[int] 

250 # Extraction-time document metadata; absent or None on rows written 

251 # before the columns existed, and None when the extractor reported none. 

252 title: NotRequired[str | None] 

253 authors: NotRequired[str | None] 

254 created_at: NotRequired[str | None] 

255 

256 

257class SourceHead(TypedDict): 

258 """The columns of a tracked source that a listing shows.""" 

259 

260 filename: str 

261 file_hash: str 

262 chunk_count: int 

263 ingested_at: str 

264 

265 

266@dataclass(frozen=True) 

267class SourceSummary: 

268 """Totals over the tracked sources and the most recently ingested few.""" 

269 

270 document_count: int 

271 total_chunks: int 

272 kinds: dict[str, int] 

273 newest: list[SourceHead] 

274 

275 

276DOCUMENTS_PAGE_DEFAULT = 50 

277"""Rows in one page of the document listing when the caller names no limit.""" 

278 

279DOCUMENTS_PAGE_MAX = 1000 

280"""The most rows one page of the document listing may hold.""" 

281 

282 

283# Sentinel for the stat columns on rows written before they existed (or for 

284# detached imports with no backing file). Planning treats it as "unknown: re-hash". 

285SOURCE_STAT_UNKNOWN = -1 

286 

287 

288class SourceStat(NamedTuple): 

289 """File size and mtime captured when a source was hashed, plus the capture time. 

290 

291 ``captured_ns`` is the wall-clock time the stat was taken; the sync planner 

292 hashes a file whose mtime is not strictly older than it (racily clean). 

293 """ 

294 

295 size_bytes: int 

296 mtime_ns: int 

297 captured_ns: int = SOURCE_STAT_UNKNOWN 

298 

299 

300def source_stat(record: SourceRecord) -> SourceStat | None: 

301 """Stored stat for a source row, or None when unknown. 

302 

303 The stat columns are nullable ``int64``, so a row can carry an explicit 

304 ``None`` (an import, or a write before the columns existed) as well as a 

305 missing key or the ``SOURCE_STAT_UNKNOWN`` sentinel. All three mean "no 

306 usable stat": return None so the caller re-hashes instead of crashing on 

307 ``int(None)``. 

308 """ 

309 size = record.get("size_bytes") 

310 mtime = record.get("mtime_ns") 

311 captured = record.get("stat_captured_ns") 

312 if size is None or mtime is None or size == SOURCE_STAT_UNKNOWN or mtime == SOURCE_STAT_UNKNOWN: 

313 return None 

314 captured_ns = SOURCE_STAT_UNKNOWN if captured is None else int(captured) 

315 return SourceStat(int(size), int(mtime), captured_ns) 

316 

317 

318class SourceStatBackfill(NamedTuple): 

319 """An already-tracked source row paired with its freshly verified stat.""" 

320 

321 record: SourceRecord 

322 stat: SourceStat 

323 

324 

325class SourceMove(NamedTuple): 

326 """A file under a new name, and the absent sources of its content it can take the rows of.""" 

327 

328 candidates: tuple[str, ...] 

329 new: str 

330 stat: SourceStat | None 

331 

332 

333class SourceRelocation(NamedTuple): 

334 """One hold of a re-key: the old name each new name took, and how many moves it settled.""" 

335 

336 taken: dict[str, str] 

337 settled: int 

338 

339 

340class SourcesCheckpoint(NamedTuple): 

341 """A point in the life of the source table. 

342 

343 Every source row stamped before ``time`` is committed, and every later 

344 commit moves the table off ``version``, which is None with no table. 

345 """ 

346 

347 time: str 

348 version: int | None 

349 

350 

351class PageTextRecord(TypedDict): 

352 """One row of the per-page text dataset, matching ``_page_texts``. 

353 

354 The export dataset additionally carries the source's extraction metadata 

355 (denormalized onto every page row) so an export/import cycle preserves it; 

356 these are absent on rows read from the ``_page_texts`` table and on datasets 

357 exported before the columns existed. 

358 """ 

359 

360 source: str 

361 page: int 

362 text: str 

363 content_type: str 

364 title: NotRequired[str | None] 

365 authors: NotRequired[str | None] 

366 created_at: NotRequired[str | None] 

367 

368 

369class CitationRecord(TypedDict): 

370 """A citation linking a wiki chunk to a specific source location.""" 

371 

372 wiki_source: str 

373 wiki_chunk_index: int 

374 citation_key: str 

375 claim_type: str 

376 source_filename: str 

377 source_hash: str 

378 page_start: int 

379 page_end: int 

380 line_start: int 

381 line_end: int 

382 excerpt: str 

383 created_at: str 

384 

385 

386class MemoryKind(StrEnum): 

387 """Whether a memory is an always-injected preference or a similarity-recalled fact.""" 

388 

389 PREFERENCE = "preference" 

390 FACT = "fact" 

391 

392 

393class MemorySource(StrEnum): 

394 """Provenance of a memory: user-typed, LLM-extracted, or agent-written.""" 

395 

396 MANUAL = "manual" 

397 EXTRACTED = "extracted" 

398 AGENT = "agent" 

399 

400 

401# Memory owner namespaces. ``"local"`` is the single human (TUI/CLI/REST); agents own 

402# ``"agent:<id>"`` namespaces. The prefix lives only here so it is never hand-spliced. 

403LOCAL_OWNER = "local" 

404AGENT_OWNER_PREFIX = "agent:" 

405 

406# Memory pseudo-source values on SearchChunk: ``"memory:<id>"`` with a 

407# ``"memory"`` content type. Built by retrieval, never stored in the chunks 

408# table, so recalled facts show in sources without a backing document. 

409MEMORY_SOURCE_PREFIX = "memory:" 

410MEMORY_CONTENT_TYPE = "memory" 

411 

412 

413def agent_owner(agent_id: str) -> str: 

414 """Owner string for an agent identity (``"opencode"`` -> ``"agent:opencode"``).""" 

415 return f"{AGENT_OWNER_PREFIX}{agent_id}" 

416 

417 

418def is_agent_owner(owner: str) -> bool: 

419 """True when *owner* is an agent namespace rather than the local human.""" 

420 return owner.startswith(AGENT_OWNER_PREFIX) 

421 

422 

423def memory_source(memory_id: str) -> str: 

424 """Source value for a recalled memory (``"abc"`` -> ``"memory:abc"``).""" 

425 return f"{MEMORY_SOURCE_PREFIX}{memory_id}" 

426 

427 

428def is_memory_source(source: str) -> bool: 

429 """True when *source* names a recalled memory rather than a document.""" 

430 return source.startswith(MEMORY_SOURCE_PREFIX) 

431 

432 

433class MemoryRow(BaseModel): 

434 """A long-term memory entry in the per-library ``_memories`` table. 

435 

436 Built from a LanceDB row via ``MemoryRow(**row)`` (which coerces the ``kind`` 

437 and ``source`` strings to enums) and written back via ``model_dump(mode="json")``. 

438 Extra keys like a search ``_distance`` are ignored on construction. 

439 """ 

440 

441 model_config = ConfigDict(extra="ignore") 

442 

443 id: str 

444 owner: str 

445 shared: bool 

446 kind: MemoryKind 

447 source: MemorySource 

448 text: str 

449 vector: list[float] = Field(repr=False) 

450 created_at: str 

451 updated_at: str 

452 

453 

454class StoreMeta(TypedDict): 

455 """Single-row store metadata recording the embedding model used to build the store. 

456 

457 Compatibility is checked before every read and write. When ``cfg.embedding_model`` 

458 or ``cfg.embedding_dim`` drifts from the persisted row, the store refuses to serve 

459 until ``lilbee rebuild`` (CLI) or ``POST /api/sync {"force_rebuild": true}`` (HTTP) 

460 rewrites the chunks under the new model. 

461 

462 ``updated_at`` is an ISO 8601 UTC timestamp produced by ``datetime.isoformat()``; 

463 kept as ``str`` to match the LanceDB ``utf8`` schema column. 

464 """ 

465 

466 embedding_model: str 

467 embedding_dim: int 

468 schema_version: int 

469 updated_at: str 

470 

471 

472class IndexMismatch(BaseModel): 

473 """The embedder drift between a persisted index and the configured model. 

474 

475 ``adoptable`` is true when the dimensions agree, so switching the embedding 

476 model back to ``persisted_model`` makes the index searchable without a rebuild. 

477 """ 

478 

479 persisted_model: str 

480 persisted_dim: int 

481 current_model: str 

482 current_dim: int 

483 adoptable: bool 

484 message: str 

485 

486 

487class EmbeddingModelMismatchError(RuntimeError): 

488 """Raised when stored vectors were built with a different embedder than ``cfg``. 

489 

490 Carries the persisted and configured refs and dims so each surface renders its 

491 own recovery affordance (TUI prompt, CLI command, REST body) from the facts. 

492 """ 

493 

494 def __init__( 

495 self, 

496 *, 

497 persisted_model: str, 

498 persisted_dim: int, 

499 current_model: str, 

500 current_dim: int, 

501 ) -> None: 

502 self.persisted_model = persisted_model 

503 self.persisted_dim = persisted_dim 

504 self.current_model = current_model 

505 self.current_dim = current_dim 

506 super().__init__(self._build_message()) 

507 

508 @property 

509 def dims_match(self) -> bool: 

510 """True when the index is adoptable by switching embedder alone (same dim).""" 

511 return self.persisted_dim == self.current_dim 

512 

513 def describe(self) -> IndexMismatch: 

514 """The drift as a serializable record for sync results and status payloads.""" 

515 return IndexMismatch( 

516 persisted_model=self.persisted_model, 

517 persisted_dim=self.persisted_dim, 

518 current_model=self.current_model, 

519 current_dim=self.current_dim, 

520 adoptable=self.dims_match, 

521 message=str(self), 

522 ) 

523 

524 def _build_message(self) -> str: 

525 if self.dims_match: 

526 return ( 

527 f"This index was built with embedding model '{self.persisted_model}', " 

528 f"but lilbee is configured to use '{self.current_model}'. Configure lilbee " 

529 f"to use '{self.persisted_model}' to search this index, or rebuild it under " 

530 f"'{self.current_model}'." 

531 ) 

532 return ( 

533 f"This index was built with embedding model '{self.persisted_model}' " 

534 f"(dim {self.persisted_dim}), which differs from the current " 

535 f"'{self.current_model}' (dim {self.current_dim}). The dimensions differ, " 

536 f"so rebuild the index under '{self.current_model}' to use it." 

537 ) 

538 

539 

540@dataclass 

541class RemoveResult: 

542 """Result of a remove: *removed* can also name held-out files and root labels.""" 

543 

544 removed: list[str] 

545 not_found: list[str]