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

172 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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 """ 

43 

44 DOCUMENT = "document" 

45 IMPORTED = "imported" 

46 

47 

48class SourceMeta(NamedTuple): 

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

50 

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

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

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

54 """ 

55 

56 title: str = "" 

57 authors: str = "" 

58 created_at: str = "" 

59 

60 

61class ChunkWrite(NamedTuple): 

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

63 

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

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

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

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

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

69 """ 

70 

71 source: str 

72 file_hash: str 

73 records: list[dict] 

74 needs_cleanup: bool 

75 stat: SourceStat | None = None 

76 page_texts: list[dict] | None = None 

77 source_type: SourceType = SourceType.DOCUMENT 

78 meta: SourceMeta | None = None 

79 

80 

81class ChunkType(StrEnum): 

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

83 

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

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

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

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

88 """ 

89 

90 RAW = "raw" 

91 TABLE = "table" 

92 WIKI = "wiki" 

93 

94 

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

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

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

98# lilbee.retrieval.embedding_profiles doc_prefix_since). 

99META_SCHEMA_VERSION = 2 

100 

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

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

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

104META_DELETE_ALL_PREDICATE = "schema_version IS NOT NULL" 

105 

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

107ENTITY_SCHEMA_DELETE_ALL_PREDICATE = "updated_at IS NOT NULL" 

108 

109 

110class EntitySchemaState(TypedDict): 

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

112 

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

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

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

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

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

118 """ 

119 

120 schema_json: str 

121 applied: bool 

122 source_count: int 

123 updated_at: str 

124 

125 

126class SearchScope(StrEnum): 

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

128 

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

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

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

132 """ 

133 

134 RAW = ChunkType.RAW 

135 WIKI = ChunkType.WIKI 

136 BOTH = "both" 

137 

138 

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

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

141 

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

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

144 """ 

145 if scope is None: 

146 return None 

147 normalized = SearchScope(scope) 

148 if normalized is SearchScope.BOTH: 

149 return None 

150 return ChunkType(normalized.value) 

151 

152 

153class SearchChunk(BaseModel): 

154 """A search result from LanceDB. 

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

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

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

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

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

160 """ 

161 

162 model_config = ConfigDict(populate_by_name=True) 

163 

164 source: str 

165 content_type: str 

166 chunk_type: ChunkType = ChunkType.RAW 

167 

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

169 @classmethod 

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

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

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

173 

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

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

176 title: str | None = None 

177 

178 page_start: int 

179 page_end: int 

180 line_start: int 

181 line_end: int 

182 chunk: str 

183 chunk_index: int 

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

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

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

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

188 # for rows produced by external LanceDB rerankers. 

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

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

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

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

193 # relevance filter treats its presence as lexical support. 

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

195 rerank_score: float | None = None 

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

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

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

199 score: float | None = None 

200 

201 

202class SourceRecord(TypedDict): 

203 """A tracked source document record. 

204 

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

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

207 ``SOURCE_STAT_UNKNOWN`` sentinel into ``None``. 

208 """ 

209 

210 filename: str 

211 file_hash: str 

212 ingested_at: str 

213 chunk_count: int 

214 source_type: str 

215 size_bytes: NotRequired[int] 

216 mtime_ns: NotRequired[int] 

217 stat_captured_ns: NotRequired[int] 

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

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

220 title: NotRequired[str | None] 

221 authors: NotRequired[str | None] 

222 created_at: NotRequired[str | None] 

223 

224 

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

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

227SOURCE_STAT_UNKNOWN = -1 

228 

229 

230class SourceStat(NamedTuple): 

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

232 

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

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

235 """ 

236 

237 size_bytes: int 

238 mtime_ns: int 

239 captured_ns: int = SOURCE_STAT_UNKNOWN 

240 

241 

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

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

244 

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

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

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

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

249 ``int(None)``. 

250 """ 

251 size = record.get("size_bytes") 

252 mtime = record.get("mtime_ns") 

253 captured = record.get("stat_captured_ns") 

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

255 return None 

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

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

258 

259 

260class SourceStatBackfill(NamedTuple): 

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

262 

263 record: SourceRecord 

264 stat: SourceStat 

265 

266 

267class PageTextRecord(TypedDict): 

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

269 

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

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

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

273 exported before the columns existed. 

274 """ 

275 

276 source: str 

277 page: int 

278 text: str 

279 content_type: str 

280 title: NotRequired[str | None] 

281 authors: NotRequired[str | None] 

282 created_at: NotRequired[str | None] 

283 

284 

285class CitationRecord(TypedDict): 

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

287 

288 wiki_source: str 

289 wiki_chunk_index: int 

290 citation_key: str 

291 claim_type: str 

292 source_filename: str 

293 source_hash: str 

294 page_start: int 

295 page_end: int 

296 line_start: int 

297 line_end: int 

298 excerpt: str 

299 created_at: str 

300 

301 

302class MemoryKind(StrEnum): 

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

304 

305 PREFERENCE = "preference" 

306 FACT = "fact" 

307 

308 

309class MemorySource(StrEnum): 

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

311 

312 MANUAL = "manual" 

313 EXTRACTED = "extracted" 

314 AGENT = "agent" 

315 

316 

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

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

319LOCAL_OWNER = "local" 

320AGENT_OWNER_PREFIX = "agent:" 

321 

322 

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

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

325 return f"{AGENT_OWNER_PREFIX}{agent_id}" 

326 

327 

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

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

330 return owner.startswith(AGENT_OWNER_PREFIX) 

331 

332 

333class MemoryRow(BaseModel): 

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

335 

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

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

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

339 """ 

340 

341 model_config = ConfigDict(extra="ignore") 

342 

343 id: str 

344 owner: str 

345 shared: bool 

346 kind: MemoryKind 

347 source: MemorySource 

348 text: str 

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

350 created_at: str 

351 updated_at: str 

352 

353 

354class StoreMeta(TypedDict): 

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

356 

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

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

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

360 rewrites the chunks under the new model. 

361 

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

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

364 """ 

365 

366 embedding_model: str 

367 embedding_dim: int 

368 schema_version: int 

369 updated_at: str 

370 

371 

372class EmbeddingModelMismatchError(RuntimeError): 

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

374 

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

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

377 """ 

378 

379 def __init__( 

380 self, 

381 *, 

382 persisted_model: str, 

383 persisted_dim: int, 

384 current_model: str, 

385 current_dim: int, 

386 ) -> None: 

387 self.persisted_model = persisted_model 

388 self.persisted_dim = persisted_dim 

389 self.current_model = current_model 

390 self.current_dim = current_dim 

391 super().__init__(self._build_message()) 

392 

393 @property 

394 def dims_match(self) -> bool: 

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

396 return self.persisted_dim == self.current_dim 

397 

398 def _build_message(self) -> str: 

399 if self.dims_match: 

400 return ( 

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

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

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

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

405 ) 

406 return ( 

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

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

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

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

411 ) 

412 

413 

414@dataclass 

415class RemoveResult: 

416 """Result of a remove_documents operation.""" 

417 

418 removed: list[str] 

419 not_found: list[str]