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

147 statements  

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

1"""Disk-write side effects for wiki page generation. 

2 

3Owns the orchestrator that lands a generated page on disk plus the 

4draft-routing helpers (drift redirects, PENDING markers for parse 

5failures, collision markers for duplicate concept slugs). Higher-level 

6code in :mod:`lilbee.wiki.page` calls into here for the publish step; 

7the actual ``write_page`` lives there to keep file-handling close to 

8content assembly. 

9""" 

10 

11from __future__ import annotations 

12 

13import json 

14import logging 

15from collections.abc import Mapping 

16from pathlib import Path 

17 

18from lilbee.core.config import Config 

19from lilbee.data.store import CitationRecord, SourceType, Store 

20from lilbee.wiki.index import append_wiki_log, update_wiki_index 

21from lilbee.wiki.shared import ( 

22 PENDING_COLLISION_MARKER_PREFIX, 

23 PageTarget, 

24 WikiLogAction, 

25 WikiSubdir, 

26 atomic_write_text, 

27 frontmatter_span, 

28 is_pending_marker_text, 

29 parse_frontmatter, 

30) 

31from lilbee.wiki.stats import BuildStats 

32 

33log = logging.getLogger(__name__) 

34 

35# Leading comment on a drift-diverted draft. The drafts surface matches the 

36# same wording with a regex. 

37_DRIFT_MARKER_PREFIX = "<!-- DRIFT:" 

38 

39 

40def origin_marker(subdir: str) -> str: 

41 """Leading comment recording the page type a draft would have published as. 

42 

43 Drift and collision drafts carry ``origin:`` inside their own markers. A 

44 draft held only by the faithfulness gate has no marker of its own, so 

45 without this one, accepting it with no published counterpart files it 

46 under ``summaries/`` instead of its own page type. 

47 """ 

48 return f"<!-- origin: {subdir} -->" 

49 

50 

51# Once ``<wiki_dir>/`` is stripped, a well-formed source leaves at least 

52# ``<subdir>/<slug>.md``. Anything shorter has no subdir. 

53_WIKI_SOURCE_MIN_PARTS = 2 

54 

55# Drift-marker field carrying the hash of the diverting page's sources, so a 

56# later divert can tell its own draft from another source's. 

57_DRIFT_SOURCE_FIELD = "source: " 

58 

59# Frontmatter field listing the sources a generated page was written from. 

60_DRAFT_SOURCES_FIELD = "sources" 

61 

62# Frontmatter field of a draft naming the id of each source path it was written with. 

63SOURCE_IDS_FIELD = "source_ids" 

64 

65 

66def with_source_ids(content: str, source_ids: Mapping[str, str]) -> str: 

67 """*content* with a frontmatter line naming the id of each source path it shows.""" 

68 lines = content.split("\n") 

69 span = frontmatter_span(lines) 

70 if span is None: 

71 return content 

72 _start, end = span 

73 lines.insert(end, f"{SOURCE_IDS_FIELD}: {json.dumps(dict(sorted(source_ids.items())))}") 

74 return "\n".join(lines) 

75 

76 

77def without_source_ids(text: str) -> str: 

78 """*text* without the frontmatter line naming the ids of its sources.""" 

79 lines = text.split("\n") 

80 span = frontmatter_span(lines) 

81 if span is None: 

82 return text 

83 start, end = span 

84 field = f"{SOURCE_IDS_FIELD}:" 

85 kept = [line for line in lines[start + 1 : end] if not line.startswith(field)] 

86 return "\n".join([*lines[: start + 1], *kept, *lines[end:]]) 

87 

88 

89def recorded_source_ids(text: str) -> dict[str, str] | None: 

90 """The id of each source path a draft records, or None when it records none.""" 

91 recorded = parse_frontmatter(text).get(SOURCE_IDS_FIELD) 

92 # Frontmatter is untyped YAML: a hand-edited draft can hold anything here. 

93 if not isinstance(recorded, dict): 

94 return None 

95 return {str(path): str(source_id) for path, source_id in recorded.items()} 

96 

97 

98def _read_draft(draft_path: Path) -> str | None: 

99 """Return the draft's text, or None when it is absent or unreadable.""" 

100 if not draft_path.is_file(): 

101 return None 

102 try: 

103 return draft_path.read_text(encoding="utf-8") 

104 except OSError: 

105 return None 

106 

107 

108def draft_source_names(draft_path: Path) -> list[str] | None: 

109 """Sorted source names in an existing draft's frontmatter, or None when it has none. 

110 

111 A PENDING marker carries no ``sources`` field and so reads as unowned: it is 

112 a placeholder, not review content. Reading past the marker run is the 

113 parser's job. 

114 """ 

115 text = _read_draft(draft_path) 

116 if text is None: 

117 return None 

118 sources = parse_frontmatter(text).get(_DRAFT_SOURCES_FIELD) 

119 # Frontmatter is untyped YAML: a hand-edited draft can hold anything. 

120 return sorted(sources) if isinstance(sources, list) else None 

121 

122 

123def _draft_belongs_to_other_source( 

124 draft_path: Path, source_key: str, source_names: list[str] 

125) -> bool: 

126 """Return whether an existing draft holds reviewable content from another source. 

127 

128 A PENDING marker or an empty file is a placeholder, not review content, so 

129 neither blocks the write. A drift draft carries its source key in the 

130 marker; a quality-gate draft carries its sources in frontmatter, so a 

131 same-source draft of either kind is superseded in place rather than 

132 treated as a collision. 

133 """ 

134 text = _read_draft(draft_path) 

135 if text is None or not text.strip() or is_pending_marker_text(text): 

136 return False 

137 first_line = text.splitlines()[0] 

138 if f"{_DRIFT_SOURCE_FIELD}{source_key}" in first_line: 

139 return False 

140 return draft_source_names(draft_path) != sorted(source_names) 

141 

142 

143def divert_to_drafts( 

144 new_content: str, 

145 drafts_dir: Path, 

146 slug: str, 

147 change_ratio: float, 

148 diff_text: str, 

149 origin_subdir: str, 

150 source_names: list[str], 

151 *, 

152 source_ids: Mapping[str, str], 

153) -> Path: 

154 """Write new content to wiki/drafts/ with a drift note instead of overwriting. 

155 

156 ``origin_subdir`` is the published subdir the page would have landed in 

157 (``concepts``, ``entities``, ...); it rides the drift marker so that 

158 accepting an unpaired draft restores it to its own page type instead of 

159 defaulting to ``summaries/``. The marker also carries a hash of 

160 ``source_names``: when ``drafts/<slug>.md`` already holds another source's 

161 diverted content, this one lands at a ``-collision-<hash>`` draft rather 

162 than overwriting a page awaiting review. 

163 """ 

164 # circular: persistence -> batch via short_source_hash (batch imports 

165 # persist_and_finalize / divert_concept_collision from persistence). 

166 from lilbee.wiki.batch import short_source_hash 

167 

168 sources_label = ", ".join(sorted(source_names)) 

169 source_key = short_source_hash(sources_label) 

170 note = ( 

171 f"{_DRIFT_MARKER_PREFIX} {change_ratio:.0%} content changed; origin: {origin_subdir}; " 

172 f"{_DRIFT_SOURCE_FIELD}{source_key} - flagged for human review -->\n\n" 

173 ) 

174 log.warning( 

175 "Drift detected for %s (%.0f%% changed), diverted to drafts. Diff:\n%s", 

176 slug, 

177 change_ratio * 100, 

178 diff_text, 

179 ) 

180 draft_path = drafts_dir / f"{slug}.md" 

181 if _draft_belongs_to_other_source(draft_path, source_key, source_names): 

182 return divert_concept_collision( 

183 slug=slug, 

184 source=sources_label, 

185 first_source=f"{WikiSubdir.DRAFTS}/{slug}.md", 

186 content=note + new_content, 

187 drafts_dir=drafts_dir, 

188 origin_subdir=origin_subdir, 

189 source_ids=source_ids, 

190 ) 

191 atomic_write_text(draft_path, note + with_source_ids(new_content, source_ids)) 

192 return draft_path 

193 

194 

195def subdir_from_wiki_source(wiki_source: str, wiki_dir: str) -> str | None: 

196 """Return the subdir component (``summaries``, ``concepts``, ...) of *wiki_source*. 

197 

198 ``wiki_source`` is the ``<wiki_dir>/<subdir>/<slug>.md`` path stored in 

199 citations and chunks. ``wiki_dir`` is stripped as a prefix rather than 

200 split positionally, so a nested wiki_dir (``notes/wiki``) still resolves 

201 to its subdir. Returns None when the source is not under *wiki_dir* or 

202 carries no subdir. 

203 """ 

204 relative = wiki_source.removeprefix(wiki_dir + "/") 

205 if relative == wiki_source: 

206 return None 

207 parts = relative.split("/") 

208 return parts[0] if len(parts) >= _WIKI_SOURCE_MIN_PARTS else None 

209 

210 

211def persist_and_finalize( 

212 content: str, 

213 target: PageTarget, 

214 verified: list[CitationRecord], 

215 source_names: list[str], 

216 store: Store, 

217 config: Config, 

218 *, 

219 stats: BuildStats | None = None, 

220) -> Path: 

221 """Write page to disk, persist citations, index body chunks, update index and log. 

222 

223 Only a published page carries store state: a page routed to ``drafts/`` 

224 (drift diversion or a failed quality gate) is written and logged, then 

225 returns. Its citations, chunks, and the raw sources it would supersede stay 

226 untouched until the draft is accepted. Every outcome is recorded on *stats*. 

227 """ 

228 # circular: page -> persistence via persist_and_finalize 

229 from lilbee.wiki.page import index_wiki_page, write_page 

230 

231 stats = BuildStats.ensure(stats) 

232 page_path = write_page( 

233 target.wiki_root, 

234 target.subdir, 

235 target.slug, 

236 content, 

237 config.wiki_drift_threshold, 

238 source_names, 

239 target.page_type, 

240 source_ids=store.source_ids(source_names, SourceType.DOCUMENT), 

241 ) 

242 published_path = target.wiki_root / target.subdir / f"{target.slug}.md" 

243 if page_path != published_path: 

244 # A drafts-target collision writes a PENDING marker, not review content; 

245 # count it the same way the batch collision branch does. 

246 if is_pending_marker_text(_read_draft(page_path) or ""): 

247 stats.record_pending_marker() 

248 else: 

249 stats.record_drafted() 

250 append_wiki_log( 

251 WikiLogAction.GENERATED, 

252 f"{target.page_type} page for {target.label} diverted to draft " 

253 f"{page_path.name} (published page unchanged)", 

254 config, 

255 ) 

256 return page_path 

257 

258 if target.subdir == WikiSubdir.DRAFTS: 

259 stats.record_drafted() 

260 append_wiki_log( 

261 WikiLogAction.GENERATED, 

262 f"{target.page_type} page for {target.label} held in " 

263 f"{WikiSubdir.DRAFTS}/{page_path.name} pending review", 

264 config, 

265 ) 

266 return page_path 

267 

268 stats.record_published(target.wiki_source, len(verified)) 

269 for rec in verified: 

270 rec["wiki_source"] = target.wiki_source 

271 store.replace_citations_for_wiki(target.wiki_source, verified) 

272 

273 index_wiki_page(content, target.wiki_source, store, config) 

274 

275 if config.wiki_prune_raw and target.supersedes_sources: 

276 for name in source_names: 

277 try: 

278 store.delete_by_source(name, SourceType.DOCUMENT) 

279 except Exception: 

280 # Best-effort pruning of raw sources the new page supersedes; one 

281 # failed delete must not abort the loop or fail the generated page. 

282 log.warning("Failed to prune raw source %s", name, exc_info=True) 

283 

284 update_wiki_index(config) 

285 append_wiki_log( 

286 WikiLogAction.GENERATED, 

287 f"{target.page_type} page for {target.label} -> {target.subdir}/{target.slug}.md", 

288 config, 

289 ) 

290 return page_path 

291 

292 

293def write_pending_marker( 

294 drafts_dir: Path, 

295 slug: str, 

296 marker_line: str, 

297 frontmatter: str = "", 

298) -> Path | None: 

299 """Write a PENDING marker page under ``drafts/<slug>.md``. None when withheld. 

300 

301 ``marker_line`` is the leading HTML comment that both identifies 

302 the marker kind and carries the context (source, label). The 

303 optional ``frontmatter`` preserves minimal metadata for the 

304 drafts surface to round-trip (e.g. ``bad_title``-style fields). 

305 

306 An existing draft that is not itself a marker is generated content 

307 awaiting review and is kept: the marker is not written over it, and 

308 the caller gets None so it does not count a marker that never landed. 

309 """ 

310 draft_path = drafts_dir / f"{slug}.md" 

311 existing = _read_draft(draft_path) 

312 if existing is not None and not is_pending_marker_text(existing): 

313 log.warning( 

314 "Keeping the draft at %s: it holds content pending review, not a marker", 

315 draft_path, 

316 ) 

317 return None 

318 body = marker_line + "\n" 

319 if frontmatter: 

320 body += "\n" + frontmatter 

321 atomic_write_text(draft_path, body) 

322 return draft_path 

323 

324 

325def delete_pending_marker_if_present(drafts_dir: Path, slug: str) -> bool: 

326 """Delete an existing PENDING marker for *slug*; return whether one was removed. 

327 

328 Match is slug-equality (not fuzzy): an LLM that rephrases a 

329 label on retry (``brake system`` → ``braking system``) leaves 

330 the old marker behind for the user to drain via ``wiki drafts 

331 reject``. Documented limitation; follow-up if the pattern 

332 matters. 

333 """ 

334 draft_path = drafts_dir / f"{slug}.md" 

335 body = _read_draft(draft_path) 

336 if body is None or not is_pending_marker_text(body): 

337 return False 

338 draft_path.unlink() 

339 return True 

340 

341 

342def delete_drift_draft_if_present(drafts_dir: Path, slug: str, source_names: list[str]) -> bool: 

343 """Delete *slug*'s superseded drift draft; return whether one was removed. 

344 

345 A regen that lands under the drift threshold supersedes the proposal an 

346 earlier regen of the same sources parked in ``drafts/``: accepting the 

347 older draft afterwards would overwrite the newer published body. The 

348 drafts namespace is flat, so a draft carrying another source's key (a 

349 concept and an entity can share a slug) is kept. 

350 """ 

351 # circular: persistence -> batch via short_source_hash (batch imports 

352 # persist_and_finalize / divert_concept_collision from persistence). 

353 from lilbee.wiki.batch import short_source_hash 

354 

355 draft_path = drafts_dir / f"{slug}.md" 

356 text = _read_draft(draft_path) 

357 if text is None or not text.lstrip().startswith(_DRIFT_MARKER_PREFIX): 

358 return False 

359 source_key = short_source_hash(", ".join(sorted(source_names))) 

360 if _draft_belongs_to_other_source(draft_path, source_key, source_names): 

361 log.info("Keeping drift draft %s: it holds another source's proposal", draft_path) 

362 return False 

363 draft_path.unlink() 

364 log.info("Removed superseded drift draft %s", draft_path) 

365 return True 

366 

367 

368def divert_concept_collision( 

369 *, 

370 slug: str, 

371 source: str, 

372 first_source: str, 

373 content: str, 

374 drafts_dir: Path, 

375 origin_subdir: str, 

376 source_ids: Mapping[str, str], 

377) -> Path: 

378 """Write the losing concept to ``drafts/<slug>-collision-<hash>.md``. 

379 

380 The winning source's page is unchanged on disk. Hash is the 

381 first 8 hex of sha256(source_filename); stable per source so a 

382 retry on the same two sources lands at the same draft path, 

383 letting the user iterate without marker sprawl. ``origin_subdir`` 

384 is the published subdir the losing page would have landed in; it 

385 rides the marker like the drift note's ``origin:`` field so that 

386 accepting a collision with no published counterpart restores it to 

387 its own page type instead of ``summaries/``. 

388 """ 

389 # circular: persistence -> batch via short_source_hash (batch imports 

390 # persist_and_finalize / divert_concept_collision from persistence). 

391 from lilbee.wiki.batch import short_source_hash 

392 

393 short = short_source_hash(source) 

394 collision_slug = f"{slug}-collision-{short}" 

395 marker = ( 

396 f"{PENDING_COLLISION_MARKER_PREFIX} with source {first_source}, " 

397 f"content from {source} held for review; origin: {origin_subdir} -->\n\n" 

398 ) 

399 path = drafts_dir / f"{collision_slug}.md" 

400 atomic_write_text(path, marker + with_source_ids(content, source_ids)) 

401 log.warning( 

402 "Concept slug collision: %s already written by %s; diverted %s's version to %s", 

403 slug, 

404 first_source, 

405 source, 

406 path, 

407 ) 

408 return path