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

134 statements  

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

1"""Shared ingest types and constants.""" 

2 

3from __future__ import annotations 

4 

5import hashlib 

6from dataclasses import dataclass 

7from enum import StrEnum 

8from pathlib import Path 

9from typing import NamedTuple, NotRequired, TypedDict 

10 

11from pydantic import BaseModel 

12from rich.highlighter import ReprHighlighter 

13from rich.text import Text 

14 

15from lilbee.core.vectors import Vector 

16from lilbee.data.store import ( 

17 ChunkType, 

18 ConceptRecords, 

19 IndexMismatch, 

20 PageTextRecord, 

21 SourceMeta, 

22 SourceStat, 

23 SourceStatBackfill, 

24) 

25from lilbee.runtime.progress import OcrBackendUsed 

26 

27# PDF and image content types route to paginated extraction; every other format 

28# routes to markdown extraction. content_type is derived per-file in 

29# discovery.classify_file (PDFs and images grouped; others keyed by extension). 

30PDF_CONTENT_TYPE = "pdf" 

31IMAGE_CONTENT_TYPE = "image" 

32CODE_CONTENT_TYPE = "code" 

33MARKDOWN_OUTPUT = "markdown" 

34MARKDOWN_MIME = "text/markdown" 

35# Sync summary note for a skipped document whose extraction ran with OCR off. 

36SKIPPED_OCR_OFF_NOTE = ": ocr is off" 

37 

38 

39def is_under(key: str, root: str) -> bool: 

40 """Whether source *key* is *root* or lies below it, on whole path segments.""" 

41 return key == root or key.startswith(f"{root}/") 

42 

43 

44@dataclass(frozen=True) 

45class ShardId: 

46 """Which slice of the corpus one ingest worker owns. 

47 

48 *corpus_root* is the data root holding the corpus's ``.lilbeeignore``, 

49 which every worker uses in place of its own private data root. 

50 """ 

51 

52 index: int 

53 count: int 

54 corpus_root: Path 

55 

56 def owns(self, key: str) -> bool: 

57 """Whether source *key* belongs to this slice. 

58 

59 Hashed with blake2b, not ``hash()``, which is salted per process: two 

60 runs would deal the same corpus differently and every resume would 

61 re-embed what a sibling already holds. 

62 """ 

63 digest = hashlib.blake2b(key.encode("utf-8"), digest_size=8).digest() 

64 return int.from_bytes(digest, "big") % self.count == self.index 

65 

66 

67class FileToProcess(NamedTuple): 

68 """A file queued for ingestion with its metadata.""" 

69 

70 name: str 

71 path: Path 

72 content_type: str 

73 file_hash: str 

74 needs_cleanup: bool 

75 stat: SourceStat | None = None 

76 

77 

78class MemberRecords(NamedTuple): 

79 """One archive member's records, written as its own source.""" 

80 

81 name: str 

82 content_type: str 

83 records: list[ChunkRecord] 

84 page_texts: list[PageTextRecord] 

85 meta: SourceMeta 

86 

87 

88class DocumentRecords(NamedTuple): 

89 """One file's records, its source metadata, and the OCR its extraction ran with.""" 

90 

91 records: list[ChunkRecord] 

92 meta: SourceMeta 

93 ocr: OcrReport | None = None 

94 

95 

96class SkippedSource(BaseModel): 

97 """One file a held-out record holds out of the index, and why.""" 

98 

99 filename: str 

100 reason: str 

101 

102 

103class FileChangePlan(NamedTuple): 

104 """Outcome of diffing disk files against the tracked sources.""" 

105 

106 files_to_process: list[FileToProcess] 

107 added: dict[str, None] 

108 updated: dict[str, None] 

109 unchanged: int 

110 stat_backfills: list[SourceStatBackfill] 

111 # Files a held-out record holds out; not in the index, so never counted as unchanged. 

112 held_out: list[str] 

113 

114 

115class OcrBackendName(StrEnum): 

116 """OCR backends lilbee selects in OcrConfig: xberg's tesseract or lilbee's vision plugin.""" 

117 

118 TESSERACT = "tesseract" 

119 LILBEE_VISION = "lilbee-vision" 

120 

121 

122class OcrReport(BaseModel, frozen=True): 

123 """Which OCR backend one extraction ran and how many pages it OCR'd.""" 

124 

125 backend: OcrBackendUsed 

126 pages: int = 0 

127 

128 

129class EmbeddingBackendName(StrEnum): 

130 """Embedding backends registered with xberg. lilbee registers its own embedder 

131 as a plugin so the semantic chunker detects boundaries with the same model that 

132 vectorizes chunks.""" 

133 

134 LILBEE = "lilbee" 

135 

136 

137class TokenizerBackendName(StrEnum): 

138 """Tokenizer backends registered with xberg. lilbee registers its embedder's 

139 tokenizer so ChunkSizing counts chunk budgets in the same tokens the embedder 

140 consumes, instead of a chars-per-token heuristic. Separate registry from the 

141 embedding backend, so sharing the ``lilbee`` name is fine.""" 

142 

143 LILBEE = "lilbee" 

144 

145 

146class ExtractMode(StrEnum): 

147 """Extraction topology: paginated (PDFs/images) vs markdown output (text formats).""" 

148 

149 MARKDOWN = "markdown" 

150 PAGINATED = "paginated" 

151 

152 

153class ChunkRecord(TypedDict): 

154 """A single store-ready chunk record matching store.CHUNKS_SCHEMA.""" 

155 

156 source: str 

157 content_type: str 

158 chunk_type: ChunkType 

159 page_start: int 

160 page_end: int 

161 line_start: int 

162 line_end: int 

163 chunk: str 

164 chunk_index: int 

165 vector: Vector 

166 # Stamped once per document by the pipeline (see produce_records); None 

167 # when the title is empty, so chunk rows persist NULL like the _sources table. 

168 title: NotRequired[str | None] 

169 

170 

171class SyncResult(BaseModel): 

172 """Summary of a sync operation.""" 

173 

174 added: list[str] = [] 

175 updated: list[str] = [] 

176 removed: list[str] = [] 

177 unchanged: int = 0 

178 # Sources recognized as moved (same content hash, new location): re-keyed to 

179 # the new name in place, so their chunks and embeddings were reused, not rebuilt. 

180 relocated: list[str] = [] 

181 failed: list[str] = [] 

182 skipped: list[str] = [] 

183 # The OCR each skipped document ran with; files that never reach OCR are absent. 

184 skipped_ocr: dict[str, OcrReport] = {} 

185 # Files an earlier sync held out, so this run did not attempt them. 

186 held_out: list[SkippedSource] = [] 

187 # Chunks whose text exceeded the embedder's char budget and were truncated 

188 # before embedding. Non-zero means some tail content did not reach the index. 

189 truncated: int = 0 

190 # Folders the walk could not open, so the files in them were not looked at. 

191 unreadable_folders: list[str] = [] 

192 # Set when the index was built with another embedder than the one configured: 

193 # the sync left it as it is, and search refuses it until a rebuild or a switch back. 

194 index_mismatch: IndexMismatch | None = None 

195 

196 def _lines(self) -> list[list[tuple[str, str]]]: 

197 """The summary as lines of ``(text, style)`` segments; ``""`` means unstyled.""" 

198 lines: list[list[tuple[str, str]]] = [ 

199 [(f"Added: {len(self.added)}", "")], 

200 [(f"Updated: {len(self.updated)}", "")], 

201 [(f"Removed: {len(self.removed)}", "")], 

202 [(f"Unchanged: {self.unchanged}", "")], 

203 ] 

204 if self.index_mismatch is not None: 

205 lines.append([("Index mismatch:", "red"), (f" {self.index_mismatch.message}", "")]) 

206 if self.relocated: 

207 lines.append([(f"Relocated: {len(self.relocated)}", "")]) 

208 lines += [ 

209 [(f"Held out: {len(self.held_out)}", "")], 

210 [(f"Skipped: {len(self.skipped)}", "")], 

211 [(f"Failed: {len(self.failed)}", "")], 

212 [(f"Truncated: {self.truncated}", "")], 

213 ] 

214 lines += [ 

215 [(" ", ""), (h.filename, "yellow"), (f": {h.reason}", "")] for h in self.held_out 

216 ] 

217 lines += [[(" ", ""), (name, "yellow"), self._skip_note(name)] for name in self.skipped] 

218 lines += [[(" ", ""), (name, "red")] for name in self.failed] 

219 lines += [ 

220 [("Could not open folder: ", "yellow"), (folder, "")] 

221 for folder in self.unreadable_folders 

222 ] 

223 return lines 

224 

225 def _skip_note(self, name: str) -> tuple[str, str]: 

226 """The OCR-off note for a skipped file, or an empty segment.""" 

227 report = self.skipped_ocr.get(name) 

228 if report is not None and report.backend is OcrBackendUsed.NONE: 

229 return (SKIPPED_OCR_OFF_NOTE, "") 

230 return ("", "") 

231 

232 def __str__(self) -> str: 

233 return "\n".join( 

234 "".join(f"[{style}]{text}[/{style}]" if style else text for text, style in line) 

235 for line in self._lines() 

236 ) 

237 

238 def __repr__(self) -> str: 

239 return ( 

240 f"SyncResult(added={len(self.added)}, updated={len(self.updated)}, " 

241 f"removed={len(self.removed)}, unchanged={self.unchanged}, " 

242 f"held_out={len(self.held_out)}, skipped={len(self.skipped)}, " 

243 f"failed={len(self.failed)}, truncated={self.truncated})" 

244 ) 

245 

246 def __rich__(self) -> Text: 

247 """Render the summary with every filename and reason as literal text.""" 

248 rendered = Text("\n").join(Text.assemble(*line) for line in self._lines()) 

249 return ReprHighlighter()(rendered) 

250 

251 

252@dataclass 

253class _IngestResult: 

254 """Outcome of a single file ingestion attempt. 

255 

256 ``records`` carries the produced (extracted + embedded) chunks until the 

257 batched flush writes them; ``None`` on a failed file. ``needs_cleanup`` 

258 travels with the records so the flush can delete the source's old chunks in 

259 the same transaction. ``page_texts`` carries the per-page text dataset rows 

260 and ``concept_records`` the file's concept-table rows, and ``entity_rows`` 

261 the file's typed-entity rows, all written by the same flush. ``meta`` 

262 carries the document's extraction-time metadata for the source row. 

263 ``skip_reason`` is set when the file was refused rather than attempted, and 

264 it decides the outcome ahead of the chunk count. ``ocr`` is the OCR the 

265 document extraction ran with; ``None`` for files that never reach OCR. 

266 """ 

267 

268 name: str 

269 path: Path 

270 chunk_count: int 

271 error: Exception | None 

272 file_hash: str = "" 

273 skip_reason: str | None = None 

274 records: list[ChunkRecord] | None = None 

275 needs_cleanup: bool = True 

276 page_texts: list[PageTextRecord] | None = None 

277 stat: SourceStat | None = None 

278 concept_records: ConceptRecords | None = None 

279 entity_rows: list[dict] | None = None 

280 meta: SourceMeta | None = None 

281 members: list[MemberRecords] | None = None 

282 ocr: OcrReport | None = None