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

237 statements  

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

1"""Draft review surface. List, diff, accept, reject wiki drafts. 

2 

3Wiki generation routes pages to ``wiki/drafts/`` when the content 

4drift against an existing page exceeds the configured threshold or 

5when the faithfulness score falls below it. Without a review 

6surface drafts accumulate with no exit ramp, so this module exposes 

7the four operations a reviewer needs: see what is pending, diff 

8against the published version, accept (publish the page, register its 

9citations, re-index its chunks), or reject (delete the draft file). 

10""" 

11 

12from __future__ import annotations 

13 

14import difflib 

15import logging 

16import re 

17from dataclasses import dataclass 

18from pathlib import Path 

19from typing import Any 

20 

21from lilbee.core.config import Config, cfg 

22from lilbee.core.security import validate_path_within 

23from lilbee.data.store import CitationRecord, SearchChunk, Store 

24from lilbee.wiki.batch import hash_existing_sources 

25from lilbee.wiki.citations import ( 

26 CitationStatus, 

27 parse_wiki_citations, 

28 render_citation_block, 

29 resolve_multi_source_citations, 

30 scrub_unverified_markers, 

31 strip_citation_block, 

32 verify_citation, 

33) 

34from lilbee.wiki.generation import rewrite_links_across_wiki 

35from lilbee.wiki.index import update_wiki_index 

36from lilbee.wiki.page import index_wiki_page, indexable_chunks 

37from lilbee.wiki.persistence import recorded_source_ids, with_source_ids, without_source_ids 

38from lilbee.wiki.rerender import renamed_page 

39from lilbee.wiki.shared import ( 

40 PENDING_COLLISION_MARKER_RE, 

41 PENDING_PARSE_MARKER_RE, 

42 WIKI_BUILD_LOCK, 

43 WIKI_CONTENT_SUBDIRS, 

44 PendingKind, 

45 WikiSubdir, 

46 atomic_write_text, 

47 parse_frontmatter, 

48 rewrite_keeping_time, 

49) 

50 

51__all__ = [ 

52 "AcceptResult", 

53 "BodylessDraftError", 

54 "DraftAcceptError", 

55 "DraftInfo", 

56 "PendingKind", 

57 "StaleDraftError", 

58 "UnverifiedDraftError", 

59 "accept_draft", 

60 "diff_draft", 

61 "list_drafts", 

62 "reject_draft", 

63] 

64 

65log = logging.getLogger(__name__) 

66 

67_DRIFT_MARKER_RE = re.compile( 

68 # Keeps the stricter tail: this marker interpolates only a percentage, a 

69 # subdir name and a hex hash, never a raw source name that could hold ">". 

70 r"<!--\s*DRIFT:\s*(?P<pct>\d+)%\s*content changed[^>]*-->", 

71 re.IGNORECASE, 

72) 

73 

74# Pending-marker patterns come from wiki.shared, which owns the wording. 

75 

76# Published wiki subdirs searched in priority order when pairing a 

77# draft slug with its counterpart. Summaries and synthesis come first 

78# because they are the subdirs most drafts originate from (drift 

79# detection runs on regen of an existing source or cluster page). 

80_PUBLISHED_SUBDIRS: tuple[str, ...] = ( 

81 WikiSubdir.SUMMARIES, 

82 WikiSubdir.SYNTHESIS, 

83 WikiSubdir.CONCEPTS, 

84 WikiSubdir.ENTITIES, 

85) 

86 

87 

88class DraftAcceptError(ValueError): 

89 """Base for the refusals that stop a draft from being published.""" 

90 

91 

92class StaleDraftError(DraftAcceptError): 

93 """Raised when a draft's published counterpart is newer than the draft itself.""" 

94 

95 

96class UnverifiedDraftError(DraftAcceptError): 

97 """Raised when none of a draft's citations survive verification.""" 

98 

99 

100class UnindexedDraftError(DraftAcceptError): 

101 """Raised when publishing a draft produced no chunk rows.""" 

102 

103 

104class BodylessDraftError(DraftAcceptError): 

105 """Raised when a draft's body would index nothing. 

106 

107 Covers an empty body and a body whose markup produces no indexable text, 

108 such as a bare "#", a horizontal rule, or an image. A heading with words in 

109 it indexes normally. 

110 """ 

111 

112 

113@dataclass 

114class DraftInfo: 

115 """Metadata about a single draft, surfaced in ``wiki drafts list``. 

116 

117 ``pending_kind`` distinguishes drift drafts (None) from 

118 batched-generation markers (``"parse"``, ``"collision"``). Callers 

119 can render the kind in the list view and branch on it when 

120 deciding how to surface the draft (e.g. a collision needs the 

121 winning-source context, a parse marker just needs a rerun). 

122 """ 

123 

124 slug: str 

125 path: Path 

126 drift_ratio: float | None 

127 faithfulness_score: float | None 

128 bad_title: bool 

129 published_path: Path | None 

130 mtime: float 

131 pending_kind: str | None = None 

132 

133 @property 

134 def published_exists(self) -> bool: 

135 """True when a matching published page exists for this draft.""" 

136 return self.published_path is not None 

137 

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

139 """Serialize to a JSON-friendly dict.""" 

140 return { 

141 "slug": self.slug, 

142 "path": str(self.path), 

143 "drift_ratio": self.drift_ratio, 

144 "faithfulness_score": self.faithfulness_score, 

145 "bad_title": self.bad_title, 

146 "published_path": str(self.published_path) if self.published_path else None, 

147 "published_exists": self.published_exists, 

148 "mtime": self.mtime, 

149 "pending_kind": self.pending_kind, 

150 } 

151 

152 

153@dataclass 

154class AcceptResult: 

155 """Outcome of accepting a draft. Returned so callers can confirm. 

156 

157 ``requested_slug`` is always the slug the caller asked to accept 

158 (for PENDING-COLLISION drafts this looks like 

159 ``brakes-collision-abc12345``). ``slug`` is where the content 

160 landed (the de-collisioned base slug, so ``brakes``). For 

161 non-collision drafts the two match. HTTP clients that round-trip 

162 accept→list-refresh can compare both fields to track the rename. 

163 """ 

164 

165 slug: str 

166 requested_slug: str 

167 moved_to: Path 

168 reindexed_chunks: int 

169 

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

171 """Serialize to a JSON-friendly dict for HTTP/MCP/CLI responses.""" 

172 return { 

173 "slug": self.slug, 

174 "requested_slug": self.requested_slug, 

175 "moved_to": self.moved_to.as_posix(), 

176 "reindexed_chunks": self.reindexed_chunks, 

177 } 

178 

179 

180def _draft_path(wiki_root: Path, slug: str) -> Path: 

181 """Resolve a draft slug to a path, rejecting traversal outside the drafts dir. 

182 

183 The slug reaches here straight from a ``{slug:path}`` HTTP route and the 

184 MCP tool, so an unvalidated ``..`` would let accept/reject/diff read, 

185 overwrite, or delete arbitrary ``.md`` files. Mirrors browse.find_page. 

186 """ 

187 drafts_root = wiki_root / WikiSubdir.DRAFTS 

188 candidate = drafts_root / f"{slug}.md" 

189 validate_path_within(candidate, drafts_root) 

190 return candidate 

191 

192 

193def _find_published(wiki_root: Path, slug: str) -> Path | None: 

194 """Return the first published page matching *slug*, or None. 

195 

196 Checks summaries, synthesis, concepts, and entities subdirs in 

197 priority order so a draft regenerated from an existing summary 

198 page pairs with its original rather than the same slug under a 

199 different page type. Rejects a traversal slug rather than reading 

200 a matching file outside the wiki tree. 

201 """ 

202 for subdir in _PUBLISHED_SUBDIRS: 

203 candidate = wiki_root / subdir / f"{slug}.md" 

204 validate_path_within(candidate, wiki_root) 

205 if candidate.is_file(): 

206 return candidate 

207 return None 

208 

209 

210_ORIGIN_MARKER_RE = re.compile( 

211 # The head stays greedy so the LAST "origin:" wins: a collision marker 

212 # interpolates raw source names ahead of the real field, and a filename can 

213 # contain the literal "origin:". The tail is non-greedy so a ">" inside the 

214 # comment does not stop the match. 

215 r"<!--.*origin:\s*(?P<subdir>\w+).*?-->", 

216 re.IGNORECASE, 

217) 

218 

219_CONTENT_SUBDIR_BY_VALUE = {s.value: s for s in WIKI_CONTENT_SUBDIRS} 

220 

221 

222def _parse_drift_ratio(text: str) -> float | None: 

223 """Extract the drift percentage from a draft's leading marker.""" 

224 match = _DRIFT_MARKER_RE.search(text) 

225 if match is None: 

226 return None 

227 return int(match.group("pct")) / 100.0 

228 

229 

230def _parse_origin_subdir(text: str) -> WikiSubdir | None: 

231 """Extract the origin page-type subdir from a marker run, if it names a valid one. 

232 

233 Drift and collision markers both carry ``origin: <subdir>`` so an unpaired 

234 draft accepts back into its own page type. Returns None for drafts without 

235 the field (markers written before this was recorded) or values outside the 

236 content subdirs, so the caller keeps the summaries fallback. 

237 """ 

238 match = _ORIGIN_MARKER_RE.search(text) 

239 if match is None: 

240 return None 

241 return _CONTENT_SUBDIR_BY_VALUE.get(match.group("subdir").lower()) 

242 

243 

244def _parse_pending_kind(text: str) -> str | None: 

245 """Classify *text* as a PENDING-PARSE, PENDING-COLLISION, or neither. 

246 

247 Returns ``None`` when the leading line is not a PENDING marker. Markers 

248 are always written as the first line, so a draft body that quotes a 

249 marker comment further down does not get mis-classified. 

250 """ 

251 first_line = text.splitlines()[0] if text else "" 

252 if PENDING_PARSE_MARKER_RE.match(first_line): 

253 return PendingKind.PARSE 

254 if PENDING_COLLISION_MARKER_RE.match(first_line): 

255 return PendingKind.COLLISION 

256 return None 

257 

258 

259def _is_marker_line(line: str) -> bool: 

260 return any( 

261 pattern.match(line) 

262 for pattern in ( 

263 PENDING_PARSE_MARKER_RE, 

264 PENDING_COLLISION_MARKER_RE, 

265 _DRIFT_MARKER_RE, 

266 _ORIGIN_MARKER_RE, 

267 ) 

268 ) 

269 

270 

271def _split_marker_line(text: str) -> tuple[str, str]: 

272 """Split *text* into its leading run of marker lines and the untouched remainder. 

273 

274 A drift that also collides stacks a PENDING marker above the DRIFT note, so 

275 the whole leading run is consumed: kind comes from the first marker line, 

276 drift ratio and origin from any of them. The run stops at the first 

277 non-marker content, so a marker comment quoted in the body is never parsed 

278 or stripped. 

279 """ 

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

281 markers: list[str] = [] 

282 index = 0 

283 while index < len(lines): 

284 line = lines[index] 

285 if _is_marker_line(line): 

286 markers.append(line) 

287 elif line.strip() or not markers: 

288 break 

289 index += 1 

290 if not markers: 

291 return "", text 

292 return "\n".join(markers), "\n".join(lines[index:]) 

293 

294 

295def _classify_and_strip_markers(text: str) -> tuple[str | None, float | None, str]: 

296 """Single-pass read: parse kind, drift ratio, and return marker-stripped body. 

297 

298 Classification, drift ratio, and origin all come from the leading run of 

299 marker lines, and stripping removes only that run, so a marker comment 

300 quoted in the body survives the accept untouched. 

301 """ 

302 marker_line, remainder = _split_marker_line(text) 

303 pending_kind = _parse_pending_kind(marker_line) 

304 drift = _parse_drift_ratio(marker_line) 

305 return pending_kind, drift, remainder.lstrip() if marker_line else remainder 

306 

307 

308def list_drafts(wiki_root: Path) -> list[DraftInfo]: 

309 """Return one ``DraftInfo`` per draft markdown file under ``drafts/``. 

310 

311 Recurses so per-source draft nesting (``drafts/<source>/page.md``) 

312 is covered. Reads each draft's full text once, classifies any 

313 pending marker and drift ratio, strips the markers, then parses 

314 frontmatter on the stripped body (so frontmatter parsing works 

315 uniformly whether or not a marker shifted it down). 

316 """ 

317 drafts_dir = wiki_root / WikiSubdir.DRAFTS 

318 if not drafts_dir.is_dir(): 

319 return [] 

320 infos: list[DraftInfo] = [] 

321 for path in sorted(drafts_dir.rglob("*.md")): 

322 text = path.read_text(encoding="utf-8") 

323 pending_kind, drift, stripped = _classify_and_strip_markers(text) 

324 fm = parse_frontmatter(stripped) 

325 slug = str(path.relative_to(drafts_dir).with_suffix("")).replace("\\", "/") 

326 infos.append( 

327 DraftInfo( 

328 slug=slug, 

329 path=path, 

330 drift_ratio=drift, 

331 faithfulness_score=_coerce_float(fm.get("faithfulness_score")), 

332 bad_title=bool(fm.get("bad_title", False)), 

333 published_path=_find_published(wiki_root, slug), 

334 mtime=path.stat().st_mtime, 

335 pending_kind=pending_kind, 

336 ) 

337 ) 

338 return infos 

339 

340 

341def _keyed_source_ids(draft: Path, text: str, store: Store) -> dict[str, str]: 

342 """Record in *draft*, written before drafts named ids, the id of each source it cites. 

343 

344 A cited path no source row was keyed from stays as written, and is logged. 

345 """ 

346 cited = _frontmatter_sources(text) 

347 source_ids = store.keyed_source_ids(cited) 

348 unresolved = sorted(set(cited) - set(source_ids)) 

349 if unresolved: 

350 log.warning( 

351 "Draft %s cites sources the library has no row for; they stay as written: %s", 

352 draft.name, 

353 ", ".join(unresolved), 

354 ) 

355 recorded = with_source_ids(text, source_ids) 

356 if recorded != text: 

357 # The draft keeps its time: the stale check and the listing read it. 

358 with WIKI_BUILD_LOCK: 

359 # A writer that replaced the draft since the read keeps its text. 

360 if draft.read_text(encoding="utf-8") == text: 

361 rewrite_keeping_time(draft, recorded) 

362 return source_ids 

363 

364 

365def _shown_draft(draft: Path, store: Store) -> str: 

366 """The text of *draft* showing the path each source it names by id has now.""" 

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

368 source_ids = recorded_source_ids(text) 

369 if source_ids is None: 

370 source_ids = _keyed_source_ids(draft, text, store) 

371 paths = store.source_paths(source_ids.values()) 

372 renamed = { 

373 written: paths[source_id] 

374 for written, source_id in source_ids.items() 

375 if paths.get(source_id, written) != written 

376 } 

377 return renamed_page(without_source_ids(text), renamed) 

378 

379 

380def diff_draft(slug: str, wiki_root: Path, store: Store) -> str: 

381 """Return a unified diff of the draft against its published counterpart. 

382 

383 Raises :class:`FileNotFoundError` when the draft does not exist. 

384 When no published counterpart exists the diff shows the draft as 

385 all-new (baseline empty), which is useful for reviewing drafts 

386 that originated from a fresh low-faithfulness generation. 

387 """ 

388 draft = _draft_path(wiki_root, slug) 

389 if not draft.is_file(): 

390 raise FileNotFoundError(f"draft not found: {slug}") 

391 draft_text = _shown_draft(draft, store) 

392 published = _find_published(wiki_root, slug) 

393 baseline = published.read_text(encoding="utf-8") if published else "" 

394 diff = difflib.unified_diff( 

395 baseline.splitlines(), 

396 draft_text.splitlines(), 

397 fromfile=str(published) if published else "(new draft)", 

398 tofile=str(draft), 

399 lineterm="", 

400 ) 

401 return "\n".join(diff) 

402 

403 

404_COLLISION_SUFFIX_RE = re.compile(r"-collision-[0-9a-f]{8}$") 

405 

406 

407def _base_slug_for_collision(slug: str) -> str: 

408 """Strip the ``-collision-<hash>`` suffix so accept lands on the winning slug.""" 

409 return _COLLISION_SUFFIX_RE.sub("", slug) 

410 

411 

412def accept_draft( 

413 slug: str, wiki_root: Path, store: Store, config: Config | None = None 

414) -> AcceptResult: 

415 """Publish the draft, register its citations, and re-index its chunks. 

416 

417 Behavior branches on the draft's pending kind: 

418 

419 - **Drift draft** (default): write the accepted body to its 

420 published counterpart (or ``summaries/`` when unpaired), 

421 re-index, delete the draft. 

422 - **PENDING-PARSE** (batched-generation parser could not recover 

423 a section): accepting is a no-op on the published side: the 

424 marker has no body to accept. The marker is deleted and the 

425 user is told to run ``wiki build`` to regenerate. Returns an 

426 ``AcceptResult`` with ``reindexed_chunks=0`` and 

427 ``moved_to`` pointing at the deleted marker. 

428 - **PENDING-COLLISION** (two sources proposed the same concept 

429 slug): strips the ``-collision-<hash>`` suffix to find the 

430 winning slug, overwrites the winning page with this draft's 

431 body, re-indexes, deletes the collision marker. 

432 

433 The draft shows the path each source it records by id has now. 

434 Drafts carry no store state, so accept is where a page's citation 

435 rows are created: the citation block embedded in the draft body is 

436 re-parsed, verified against the chunks of the sources its 

437 frontmatter names, and written under the published ``wiki_source``. 

438 The published body is rendered from that same set, so its footnotes 

439 and its rows cannot disagree. 

440 

441 Sequence for drift/collision: write the published file first, 

442 register citations and re-index next, delete the draft last. If a 

443 later step raises (chunker, embedder, LanceDB contention), the 

444 draft file stays on disk so the user can retry ``accept``: both 

445 the citation replace and ``index_wiki_page`` are idempotent on the 

446 same ``wiki_source``. A body that indexed nothing is a failed step 

447 too: an accepted page always chunks to at least one row. 

448 

449 Raises :class:`FileNotFoundError` when the draft does not exist, 

450 :class:`StaleDraftError` when the published counterpart is newer, 

451 :class:`UnverifiedDraftError` when no cited excerpt is still in its source, 

452 :class:`BodylessDraftError` when the draft's body would index nothing, and 

453 :class:`UnindexedDraftError` when the store write landed no rows anyway. 

454 

455 Holds the wiki build mutex while publishing, so accepting a draft cannot 

456 interleave with a build, synthesis, or prune from another surface. 

457 """ 

458 if config is None: 

459 config = cfg 

460 with WIKI_BUILD_LOCK: 

461 draft = _draft_path(wiki_root, slug) 

462 if not draft.is_file(): 

463 raise FileNotFoundError(f"draft not found: {slug}") 

464 raw = _shown_draft(draft, store) 

465 # Single-pass classify + strip (kind plus the three-marker removal), instead 

466 # of re-deriving the kind and re-stripping the markers separately. 

467 pending_kind, _drift, clean = _classify_and_strip_markers(raw) 

468 

469 if pending_kind == PendingKind.PARSE: 

470 draft.unlink() 

471 log.info( 

472 "Accepted PENDING-PARSE marker %s; run `lilbee wiki build` " 

473 "to regenerate the missing section.", 

474 slug, 

475 ) 

476 return AcceptResult(slug=slug, requested_slug=slug, moved_to=draft, reindexed_chunks=0) 

477 

478 target_slug = ( 

479 _base_slug_for_collision(slug) if pending_kind == PendingKind.COLLISION else slug 

480 ) 

481 target = _accept_target(wiki_root, target_slug, slug, raw) 

482 wiki_source = _wiki_source_for(target, wiki_root, config) 

483 records = _accepted_citations(clean, wiki_source, slug, store) 

484 content = _render_accepted_page(clean, records) 

485 _refuse_stale_draft(target, draft, slug, content) 

486 chunks = _refuse_bodyless_draft(content, slug) 

487 

488 atomic_write_text(target, content) 

489 store.replace_citations_for_wiki(wiki_source, records) 

490 reindexed = index_wiki_page(content, wiki_source, store, config, chunks) 

491 if not reindexed: 

492 # Backstop on the store write. The accept-time guard already 

493 # refused every body that chunks to nothing, so no production input 

494 # reaches this branch; its only test mocks the indexer. 

495 raise UnindexedDraftError( 

496 f"draft {slug} published no searchable chunks: the page was " 

497 "written but the index write did not land. The draft is kept; " 

498 "re-run accept once the index is writable" 

499 ) 

500 update_wiki_index(config) 

501 # Same link pass a build runs. An accepted draft is a published page, 

502 # and without this it arrives with no [[links]] and sits alone in the 

503 # graph, the way a page written on request did before #710. 

504 rewrite_links_across_wiki([], config, wiki_root) 

505 draft.unlink() 

506 log.info("Accepted draft %s -> %s (%d chunks indexed)", slug, target, reindexed) 

507 return AcceptResult( 

508 slug=target_slug, 

509 requested_slug=slug, 

510 moved_to=target, 

511 reindexed_chunks=reindexed, 

512 ) 

513 

514 

515def _accept_target(wiki_root: Path, target_slug: str, slug: str, raw: str) -> Path: 

516 """Resolve where an accepted draft lands.""" 

517 published = _find_published(wiki_root, target_slug) 

518 if published is not None: 

519 return published 

520 marker_line, _remainder = _split_marker_line(raw) 

521 fallback_subdir = _parse_origin_subdir(marker_line) or WikiSubdir.SUMMARIES 

522 log.info("Draft %s has no published counterpart; accepting into %s", slug, fallback_subdir) 

523 return wiki_root / fallback_subdir / f"{target_slug}.md" 

524 

525 

526def _refuse_bodyless_draft(content: str, slug: str) -> list[str]: 

527 """Refuse a draft whose body would index nothing. 

528 

529 Checked before the published page is overwritten, and against what the 

530 indexer actually chunks: a body can be non-empty and still chunk to nothing 

531 ("#", "---"). Indexing such a page clears the rows of whatever it replaced, 

532 and the old order reported that as an index failure, so every retry 

533 destroyed the published page again and could never succeed. 

534 

535 Returns the chunks so the caller indexes without chunking the same body a 

536 second time inside the build lock. 

537 """ 

538 chunks = indexable_chunks(content) 

539 if not chunks: 

540 raise BodylessDraftError( 

541 f"draft {slug} has nothing to index: its body produces no searchable " 

542 "text; reject it or edit the draft to add content" 

543 ) 

544 return chunks 

545 

546 

547def _refuse_stale_draft(target: Path, draft: Path, slug: str, content: str) -> None: 

548 """Refuse a draft a later build has already outrun. 

549 

550 A published counterpart newer than the draft is a regenerated page the 

551 older proposal would overwrite. Identical content is accept's own earlier 

552 write: a retry after a failed citation or index step, which must finish. 

553 """ 

554 if not target.is_file(): 

555 return 

556 if target.read_text(encoding="utf-8") == content: 

557 return 

558 if target.stat().st_mtime > draft.stat().st_mtime: 

559 raise StaleDraftError( 

560 f"draft {slug} is older than the published page it would overwrite; " 

561 "reject it and re-run `lilbee wiki build`" 

562 ) 

563 

564 

565def _accepted_citations( 

566 content: str, wiki_source: str, slug: str, store: Store 

567) -> list[CitationRecord]: 

568 """Citation rows for an accepted draft, verified against the store's chunks. 

569 

570 Follows the same rule as lint: a cited source the store holds no chunks 

571 for was verified at build time and keeps its records, while a record whose 

572 excerpt is absent from chunks that ARE present is dropped. A draft whose 

573 citations all fail would publish provenance the store cannot back, so 

574 accept refuses it. 

575 """ 

576 parsed = parse_wiki_citations(content) 

577 source_names = _frontmatter_sources(content) 

578 chunks_by_source = {name: store.get_chunks_by_source(name) for name in source_names} 

579 records = resolve_multi_source_citations( 

580 parsed, 

581 source_names, 

582 hash_existing_sources(source_names), 

583 chunks_by_source, 

584 ) 

585 kept = [rec for rec in records if _keeps_provenance(rec, chunks_by_source, slug)] 

586 if parsed and not kept: 

587 raise UnverifiedDraftError( 

588 f"draft {slug} has no citation whose excerpt is still in its source; " 

589 "reject it and re-run `lilbee wiki build`" 

590 ) 

591 for rec in kept: 

592 rec["wiki_source"] = wiki_source 

593 return kept 

594 

595 

596def _keeps_provenance( 

597 rec: CitationRecord, chunks_by_source: dict[str, list[SearchChunk]], slug: str 

598) -> bool: 

599 """Whether an accepted draft's citation record survives re-verification.""" 

600 chunk_texts = [c.chunk for c in chunks_by_source.get(rec["source_filename"], [])] 

601 if verify_citation(rec, chunk_texts) is not CitationStatus.EXCERPT_MISSING: 

602 return True 

603 log.warning( 

604 "Dropping citation %s from draft %s: excerpt no longer in %s", 

605 rec["citation_key"], 

606 slug, 

607 rec["source_filename"], 

608 ) 

609 return False 

610 

611 

612def _render_accepted_page(content: str, records: list[CitationRecord]) -> str: 

613 """Rebuild the page body around the citations that persisted.""" 

614 body = scrub_unverified_markers(strip_citation_block(content), records) 

615 block = render_citation_block(records) 

616 return f"{body.rstrip()}\n\n{block}" if block else body 

617 

618 

619def _frontmatter_sources(content: str) -> list[str]: 

620 """Source filenames recorded in a page's ``sources`` frontmatter field.""" 

621 # Frontmatter is untyped YAML: a hand-edited page can carry anything here. 

622 raw = parse_frontmatter(content).get("sources") 

623 return [str(item) for item in raw] if isinstance(raw, list) else [] 

624 

625 

626def reject_draft(slug: str, wiki_root: Path) -> None: 

627 """Delete the draft file without touching the published page or the index.""" 

628 with WIKI_BUILD_LOCK: 

629 draft = _draft_path(wiki_root, slug) 

630 if not draft.is_file(): 

631 raise FileNotFoundError(f"draft not found: {slug}") 

632 draft.unlink() 

633 log.info("Rejected draft %s", slug) 

634 

635 

636def _wiki_source_for(target: Path, wiki_root: Path, config: Config) -> str: 

637 """Build the ``wiki_source`` identifier used in the chunks table. 

638 

639 Shape matches :attr:`PageTarget.wiki_source`: 

640 ``<wiki_dir>/<subdir>/<slug>.md``. Built from ``config.wiki_dir`` like 

641 every other producer, so a nested wiki_dir (``notes/wiki``) resolves. 

642 """ 

643 relative = target.relative_to(wiki_root) 

644 return f"{config.wiki_dir}/{relative.as_posix()}" 

645 

646 

647def _coerce_float(value: Any) -> float | None: 

648 """Return *value* as a float, or None when conversion is not sensible.""" 

649 if value is None: 

650 return None 

651 try: 

652 return float(value) 

653 except (TypeError, ValueError): 

654 return None