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
« 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.
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"""
12from __future__ import annotations
14import difflib
15import logging
16import re
17from dataclasses import dataclass
18from pathlib import Path
19from typing import Any
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)
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]
65log = logging.getLogger(__name__)
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)
74# Pending-marker patterns come from wiki.shared, which owns the wording.
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)
88class DraftAcceptError(ValueError):
89 """Base for the refusals that stop a draft from being published."""
92class StaleDraftError(DraftAcceptError):
93 """Raised when a draft's published counterpart is newer than the draft itself."""
96class UnverifiedDraftError(DraftAcceptError):
97 """Raised when none of a draft's citations survive verification."""
100class UnindexedDraftError(DraftAcceptError):
101 """Raised when publishing a draft produced no chunk rows."""
104class BodylessDraftError(DraftAcceptError):
105 """Raised when a draft's body would index nothing.
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 """
113@dataclass
114class DraftInfo:
115 """Metadata about a single draft, surfaced in ``wiki drafts list``.
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 """
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
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
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 }
153@dataclass
154class AcceptResult:
155 """Outcome of accepting a draft. Returned so callers can confirm.
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 """
165 slug: str
166 requested_slug: str
167 moved_to: Path
168 reindexed_chunks: int
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 }
180def _draft_path(wiki_root: Path, slug: str) -> Path:
181 """Resolve a draft slug to a path, rejecting traversal outside the drafts dir.
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
193def _find_published(wiki_root: Path, slug: str) -> Path | None:
194 """Return the first published page matching *slug*, or None.
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
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)
219_CONTENT_SUBDIR_BY_VALUE = {s.value: s for s in WIKI_CONTENT_SUBDIRS}
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
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.
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())
244def _parse_pending_kind(text: str) -> str | None:
245 """Classify *text* as a PENDING-PARSE, PENDING-COLLISION, or neither.
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
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 )
271def _split_marker_line(text: str) -> tuple[str, str]:
272 """Split *text* into its leading run of marker lines and the untouched remainder.
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:])
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.
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
308def list_drafts(wiki_root: Path) -> list[DraftInfo]:
309 """Return one ``DraftInfo`` per draft markdown file under ``drafts/``.
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
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.
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
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)
380def diff_draft(slug: str, wiki_root: Path, store: Store) -> str:
381 """Return a unified diff of the draft against its published counterpart.
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)
404_COLLISION_SUFFIX_RE = re.compile(r"-collision-[0-9a-f]{8}$")
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)
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.
417 Behavior branches on the draft's pending kind:
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.
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.
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.
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.
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)
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)
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)
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 )
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"
526def _refuse_bodyless_draft(content: str, slug: str) -> list[str]:
527 """Refuse a draft whose body would index nothing.
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.
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
547def _refuse_stale_draft(target: Path, draft: Path, slug: str, content: str) -> None:
548 """Refuse a draft a later build has already outrun.
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 )
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.
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
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
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
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 []
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)
636def _wiki_source_for(target: Path, wiki_root: Path, config: Config) -> str:
637 """Build the ``wiki_source`` identifier used in the chunks table.
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()}"
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