Coverage for src/lilbee/wiki/stubs.py: 100%

176 statements  

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

1"""The wiki's page index: every page that could exist, generated or not. 

2 

3NER extraction names every entity in the corpus and its chunk refs without 

4spending an LLM call, so the browse tree can list every page as soon as a sync 

5finishes and a body can be generated only when someone asks for that page. A 

6build costs one call per source document, which scales with library size rather 

7than with what the reader opens; this is the half of that cost that is free. 

8 

9Only entities are enumerable this way. LLM-curated concepts come from the 

10per-source batched call, so they keep arriving published from an explicit 

11``lilbee wiki build``. 

12""" 

13 

14from __future__ import annotations 

15 

16import json 

17import logging 

18from dataclasses import dataclass 

19from typing import TYPE_CHECKING, Any 

20 

21from lilbee.app.services import get_services 

22from lilbee.core.config import cfg 

23 

24from .entity_extractor import EntityKind, get_entity_extractor 

25from .generation import _corpus_chunks 

26from .shared import ( 

27 WIKI_BUILD_LOCK, 

28 WikiSubdir, 

29 atomic_write_text, 

30 is_pending_marker_text, 

31) 

32 

33if TYPE_CHECKING: 

34 from pathlib import Path 

35 

36 from lilbee.core.config import Config 

37 from lilbee.data.store import SearchChunk, Store 

38 

39 from .entity_extractor import EntityExtractor, ExtractedEntity 

40 

41log = logging.getLogger(__name__) 

42 

43# The index file is a derived cache: the authoritative mention evidence lives in 

44# the store's _wiki_mentions table, and this file is the corpus-wide >=floor view 

45# rebuilt from it on every refresh. It lives beside the pages because it is read 

46# on every browse and a wipe should remove it with everything else. 

47STUB_INDEX_FILENAME = "stubs.json" 

48 

49# Schema marker, so a future shape change can be detected rather than parsed 

50# as garbage. A mismatch is treated as no index at all and the next sync 

51# rebuilds it. 

52# Bumped to 3 when the index became a derived view of the store mention table: 

53# an older file reads as absent so the first refresh rebuilds in full and seeds 

54# the table. 

55_INDEX_VERSION = 3 

56 

57 

58@dataclass(frozen=True) 

59class WikiStub: 

60 """One entity the corpus names, with the evidence a page would be built from.""" 

61 

62 slug: str 

63 label: str 

64 kind: EntityKind 

65 type_hint: str 

66 # Per-source mention counts, sorted by source, summed by ``mentions`` for 

67 # the floor. Kept per source rather than as a single total because the 

68 # store aggregate produces them per source, and because chunk refs are 

69 # capped, so counting those would undercount a subject named more often 

70 # than the cap. 

71 source_mentions: tuple[tuple[str, int], ...] 

72 chunk_refs: tuple[tuple[str, int], ...] 

73 

74 @property 

75 def sources(self) -> tuple[str, ...]: 

76 """Every document naming this subject.""" 

77 return tuple(source for source, _ in self.source_mentions) 

78 

79 @property 

80 def mentions(self) -> int: 

81 """How often the corpus names this subject, across all its sources.""" 

82 return sum(count for _, count in self.source_mentions) 

83 

84 @property 

85 def subdir(self) -> WikiSubdir: 

86 """Where this stub's page lands once generated.""" 

87 return WikiSubdir.CONCEPTS if self.kind is EntityKind.CONCEPT else WikiSubdir.ENTITIES 

88 

89 @property 

90 def wiki_slug(self) -> str: 

91 """The browse slug, ``<subdir>/<slug>``.""" 

92 return f"{self.subdir}/{self.slug}" 

93 

94 def to_dict(self) -> dict[str, Any]: 

95 """Serialize for the on-disk index.""" 

96 return { 

97 "slug": self.slug, 

98 "label": self.label, 

99 "kind": self.kind.value, 

100 "type_hint": self.type_hint, 

101 "source_mentions": [[source, count] for source, count in self.source_mentions], 

102 "chunk_refs": [[source, index] for source, index in self.chunk_refs], 

103 } 

104 

105 

106def _stub_from_dict(raw: dict[str, Any]) -> WikiStub | None: 

107 """Rebuild a stub from the index, or None when the row is unusable. 

108 

109 The index is a plain file a user can edit or a partial write can truncate, 

110 so a bad row is dropped rather than failing the whole browse. 

111 """ 

112 try: 

113 return WikiStub( 

114 slug=str(raw["slug"]), 

115 label=str(raw["label"]), 

116 kind=EntityKind(raw["kind"]), 

117 type_hint=str(raw.get("type_hint", "")), 

118 source_mentions=tuple((str(s), int(c)) for s, c in raw["source_mentions"]), 

119 chunk_refs=tuple((str(s), int(i)) for s, i in raw.get("chunk_refs", [])), 

120 ) 

121 except (KeyError, TypeError, ValueError): 

122 log.warning("Dropping unreadable wiki stub row: %r", raw) 

123 return None 

124 

125 

126def stub_index_path(config: Config | None = None) -> Path: 

127 """Where the index file lives.""" 

128 if config is None: 

129 config = cfg 

130 return config.data_root / config.wiki_dir / STUB_INDEX_FILENAME 

131 

132 

133def _read_stub_index(config: Config | None = None) -> dict[str, WikiStub] | None: 

134 """The index as stored, or None when it cannot be read. 

135 

136 None and an empty dict mean different things to an incremental refresh: 

137 one has nothing to build on and must rebuild, the other is a corpus that 

138 genuinely indexes to nothing and must not re-scan on every sync. 

139 """ 

140 path = stub_index_path(config) 

141 if not path.is_file(): 

142 return None 

143 try: 

144 payload = json.loads(path.read_text(encoding="utf-8")) 

145 except (OSError, json.JSONDecodeError, UnicodeDecodeError): 

146 log.warning("Wiki stub index unreadable", exc_info=True) 

147 return None 

148 if not isinstance(payload, dict) or payload.get("version") != _INDEX_VERSION: 

149 log.info("Wiki stub index version mismatch") 

150 return None 

151 rows = payload.get("stubs") 

152 if not isinstance(rows, list): 

153 # Every index this code writes carries a stubs list, so one without it 

154 # is damaged rather than describing a corpus that names nothing. 

155 log.warning("Wiki stub index has no stubs list") 

156 return None 

157 parsed = [_stub_from_dict(row) for row in rows if isinstance(row, dict)] 

158 if rows and not any(stub is not None for stub in parsed): 

159 # It claims entries and none survived parsing. Reporting that as empty 

160 # would let an incremental refresh build on it and drop everything the 

161 # current sync did not touch. 

162 log.warning("Wiki stub index holds no usable rows") 

163 return None 

164 return {stub.slug: stub for stub in parsed if stub is not None} 

165 

166 

167def load_stub_index(config: Config | None = None) -> dict[str, WikiStub]: 

168 """Read the index, keyed by slug. Empty when absent or unreadable.""" 

169 return _read_stub_index(config) or {} 

170 

171 

172def save_stub_index(stubs: dict[str, WikiStub], config: Config | None = None) -> None: 

173 """Write the index atomically, in slug order so the file is diffable.""" 

174 if config is None: 

175 config = cfg 

176 payload = { 

177 "version": _INDEX_VERSION, 

178 "stubs": [stubs[slug].to_dict() for slug in sorted(stubs)], 

179 } 

180 atomic_write_text(stub_index_path(config), json.dumps(payload, indent=2, sort_keys=False)) 

181 

182 

183def _recut_refs(stub: WikiStub, cap: int) -> WikiStub: 

184 """Re-apply the cap across the stub's refs, most-mentioning source first.""" 

185 if len(stub.chunk_refs) <= cap: 

186 return stub 

187 by_source: dict[str, list[int]] = {} 

188 for source, index in stub.chunk_refs: 

189 by_source.setdefault(source, []).append(index) 

190 counts = dict(stub.source_mentions) 

191 ordered = sorted(by_source.items(), key=lambda kv: (-counts.get(kv[0], 0), kv[0])) 

192 refs: list[tuple[str, int]] = [] 

193 for source, indexes in ordered: 

194 for index in sorted(set(indexes)): 

195 if len(refs) >= cap: 

196 break 

197 refs.append((source, index)) 

198 return WikiStub( 

199 slug=stub.slug, 

200 label=stub.label, 

201 kind=stub.kind, 

202 type_hint=stub.type_hint, 

203 source_mentions=stub.source_mentions, 

204 chunk_refs=tuple(refs), 

205 ) 

206 

207 

208def _mention_rows_by_source( 

209 entities: list[ExtractedEntity], cap: int 

210) -> dict[str, list[dict[str, Any]]]: 

211 """Split extracted entities into per-(subject, source) store rows. 

212 

213 One extraction pass yields entities whose refs span every source; the store 

214 keeps them per source so a later sync can replace one source's evidence 

215 without re-reading the rest. ``mention_count`` is the true per-source count; 

216 the indices are capped, since the aggregate re-caps across sources anyway. 

217 """ 

218 by_source: dict[str, list[dict[str, Any]]] = {} 

219 for entity in entities: 

220 per_source: dict[str, list[int]] = {} 

221 for ref in entity.chunk_refs: 

222 per_source.setdefault(ref.source, []).append(ref.chunk_index) 

223 for source, indices in per_source.items(): 

224 unique = sorted(set(indices)) 

225 by_source.setdefault(source, []).append( 

226 { 

227 "slug": entity.slug, 

228 "label": entity.label, 

229 "kind": entity.kind.value, 

230 "type_hint": entity.type_hint, 

231 "source": source, 

232 "mention_count": len(unique), 

233 "chunk_indices": unique[:cap], 

234 } 

235 ) 

236 return by_source 

237 

238 

239def _stubs_from_mention_rows( 

240 rows: list[dict[str, Any]], config: Config, cap: int 

241) -> dict[str, WikiStub]: 

242 """Aggregate mention rows into the stubs whose corpus-wide count clears the 

243 floor. 

244 

245 The floor is judged over the count summed across every source, so a subject 

246 below it in each separately-synced document still qualifies once all of its 

247 rows are present. This is the one place the floor is applied. 

248 """ 

249 floor = config.wiki_entity_min_mentions 

250 by_slug: dict[str, list[dict[str, Any]]] = {} 

251 for row in rows: 

252 by_slug.setdefault(row["slug"], []).append(row) 

253 stubs: dict[str, WikiStub] = {} 

254 for slug, slug_rows in by_slug.items(): 

255 if sum(r["mention_count"] for r in slug_rows) < floor: 

256 continue 

257 # The source that names the subject most often labels its page. 

258 canonical = max(slug_rows, key=lambda r: (r["mention_count"], r["source"])) 

259 source_mentions = tuple(sorted((r["source"], r["mention_count"]) for r in slug_rows)) 

260 refs = tuple( 

261 (r["source"], index) 

262 for r in sorted(slug_rows, key=lambda r: r["source"]) 

263 for index in r["chunk_indices"] 

264 ) 

265 stub = WikiStub( 

266 slug=slug, 

267 label=canonical["label"], 

268 kind=EntityKind(canonical["kind"]), 

269 type_hint=canonical["type_hint"], 

270 source_mentions=source_mentions, 

271 chunk_refs=refs, 

272 ) 

273 stubs[slug] = _recut_refs(stub, cap) 

274 return stubs 

275 

276 

277def _write_source_mentions( 

278 store: Store, 

279 extractor: EntityExtractor, 

280 chunks: list[SearchChunk], 

281 sources: set[str], 

282 cap: int, 

283) -> set[str]: 

284 """Extract *chunks* and replace the mention rows for every source in 

285 *sources*, returning the slugs those sources now name. 

286 

287 A source that named something and now names nothing is still replaced, with 

288 an empty set, so its stale rows go. 

289 """ 

290 rows_by_source = _mention_rows_by_source(extractor.extract(chunks), cap) 

291 affected: set[str] = set() 

292 for source in sources: 

293 rows = rows_by_source.get(source, []) 

294 store.replace_wiki_mentions_for_source(source, rows) 

295 affected.update(row["slug"] for row in rows) 

296 return affected 

297 

298 

299def refresh_stub_index( 

300 store: Store, 

301 config: Config | None = None, 

302 *, 

303 sources: set[str] | None = None, 

304) -> dict[str, WikiStub]: 

305 """Rebuild the index from the corpus and persist it. 

306 

307 The store's ``_wiki_mentions`` table is the source of truth. ``sources=None`` 

308 re-extracts the whole corpus; otherwise only those sources are re-extracted 

309 and their mention rows replaced. The stub index is then the corpus-wide 

310 aggregate of that table filtered to the mention floor, so a subject below 

311 the floor in each separately-synced document still appears once all its 

312 evidence is present. 

313 

314 Spends no LLM call. Holds the wiki mutex because it writes into the wiki 

315 directory alongside builds and prunes. 

316 """ 

317 if config is None: 

318 config = cfg 

319 cap = config.wiki_stub_max_chunk_refs 

320 with WIKI_BUILD_LOCK: 

321 # Floor forced to one for extraction: the store keeps every mention so 

322 # the corpus-wide floor can be judged over the aggregate rather than one 

323 # sync's slice. _stubs_from_mention_rows applies the real floor. 

324 pass_config = config.model_copy(update={"wiki_entity_min_mentions": 1}) 

325 extractor = get_entity_extractor( 

326 config.wiki_entity_mode, get_services().provider, pass_config 

327 ) 

328 if not extractor.available(): 

329 # An unavailable backend extracts nothing, which is indistinguishable 

330 # from a corpus that names nothing. Rebuilding on that would drop the 

331 # whole index; leave both the store and the file untouched. 

332 log.warning("Wiki entity extractor unavailable; leaving the stub index unchanged") 

333 return load_stub_index(config) 

334 

335 # A cold or file-only-migrated store has no rows to aggregate, so an 

336 # incremental pass would derive an empty index. Rebuild in full to seed 

337 # the table; incremental passes maintain it from there. 

338 if sources is None or not store.has_wiki_mentions(): 

339 store.clear_wiki_mentions() 

340 all_sources = {record["filename"] for record in store.get_sources()} 

341 _write_source_mentions(store, extractor, _corpus_chunks(store), all_sources, cap) 

342 stubs = _stubs_from_mention_rows(store.wiki_mention_rows(), config, cap) 

343 else: 

344 previous = load_stub_index(config) 

345 chunks = [c for name in sorted(sources) for c in store.get_chunks_by_source(name)] 

346 affected = _write_source_mentions(store, extractor, chunks, sources, cap) 

347 affected |= {slug for slug, stub in previous.items() if set(stub.sources) & sources} 

348 recomputed = _stubs_from_mention_rows( 

349 store.wiki_mention_rows(slugs=affected), config, cap 

350 ) 

351 stubs = {slug: stub for slug, stub in previous.items() if slug not in affected} 

352 stubs.update(recomputed) 

353 save_stub_index(stubs, config) 

354 log.info("Wiki stub index: %d entities across the corpus", len(stubs)) 

355 return stubs 

356 

357 

358def _page_exists(stub: WikiStub, wiki_root: Path) -> bool: 

359 """Whether this subject already has a page, published or awaiting review. 

360 

361 A page the faithfulness gate routed to drafts/ has been written and is 

362 waiting on a human. Offering it as unwritten invites generating it again 

363 and again, each time for another LLM call, while the draft sits there. 

364 

365 An archived page does not count. Prune retires a page when the sources it 

366 cited are deleted, while the subject can still be named by documents that 

367 survive, and nothing lists or restores archive/. Suppressing the stub would 

368 make the subject unreachable from either pane; listing it costs nothing, 

369 since generation only ever runs when a user asks for it. 

370 """ 

371 if (wiki_root / f"{stub.wiki_slug}.md").is_file(): 

372 return True 

373 draft = wiki_root / WikiSubdir.DRAFTS / f"{stub.slug}.md" 

374 return draft.is_file() and not _is_placeholder(draft) 

375 

376 

377def drop_sources_from_index(names: set[str], config: Config | None = None) -> None: 

378 """Forget documents the library no longer has. 

379 

380 Re-aggregates the affected slugs from the store, whose rows for the removed 

381 sources went with their chunks, so it costs no extraction pass. Without it a 

382 removed document keeps its subjects in the browse tree forever: its skip 

383 marker keeps it out of later syncs, so no refresh ever revisits them. 

384 """ 

385 if not names: 

386 return 

387 if config is None: 

388 config = cfg 

389 cap = config.wiki_stub_max_chunk_refs 

390 with WIKI_BUILD_LOCK: 

391 previous = load_stub_index(config) 

392 affected = {slug for slug, stub in previous.items() if set(stub.sources) & names} 

393 if not affected: 

394 return 

395 # The removed sources' mention rows went with their chunks, so 

396 # re-aggregating the affected slugs reflects the smaller corpus: a 

397 # subject only those sources named drops out, one still named elsewhere 

398 # keeps its surviving evidence. 

399 store = get_services().store 

400 recomputed = _stubs_from_mention_rows(store.wiki_mention_rows(slugs=affected), config, cap) 

401 stubs = {slug: stub for slug, stub in previous.items() if slug not in affected} 

402 stubs.update(recomputed) 

403 if stubs != previous: 

404 save_stub_index(stubs, config) 

405 

406 

407def _is_placeholder(draft: Path) -> bool: 

408 """Whether a draft is a PENDING marker rather than written content. 

409 

410 A marker records that generation failed to produce the section, so the 

411 subject still has no page and must stay listed as unwritten. 

412 """ 

413 try: 

414 text = draft.read_text(encoding="utf-8") 

415 except OSError: 

416 return False 

417 return is_pending_marker_text(text) 

418 

419 

420def ungenerated_stubs(stubs: dict[str, WikiStub], wiki_root: Path) -> list[WikiStub]: 

421 """The stubs nothing has written a page for yet, in slug order.""" 

422 return [stub for _, stub in sorted(stubs.items()) if not _page_exists(stub, wiki_root)]