Coverage for src / lilbee / wiki / shared.py: 100%
60 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Shared wiki utilities: frontmatter parsing, constants, page targets."""
3from __future__ import annotations
5from dataclasses import dataclass
6from enum import StrEnum
7from pathlib import Path
8from typing import Any
10import yaml
12MIN_CLUSTER_SOURCES = 3 # minimum unique sources for a synthesis page
15class WikiSubdir(StrEnum):
16 """Filesystem subdirectory under ``$data_root/$wiki_dir/``."""
18 SUMMARIES = "summaries"
19 SYNTHESIS = "synthesis"
20 CONCEPTS = "concepts"
21 ENTITIES = "entities"
22 DRAFTS = "drafts"
23 ARCHIVE = "archive"
26class WikiPageType(StrEnum):
27 """Kind of wiki page. Values are used as frontmatter/API labels."""
29 SUMMARY = "summary"
30 SYNTHESIS = "synthesis"
31 CONCEPT = "concept"
32 ENTITY = "entity"
33 DRAFT = "draft"
34 ARCHIVE = "archive"
37WIKI_CONTENT_SUBDIRS: tuple[WikiSubdir, ...] = (
38 WikiSubdir.SUMMARIES,
39 WikiSubdir.SYNTHESIS,
40 WikiSubdir.CONCEPTS,
41 WikiSubdir.ENTITIES,
42)
44WIKI_DISABLED_ERROR = "wiki not enabled"
46# PENDING-marker keyword phrases written into ``drafts/<slug>.md`` by the
47# batched generator and matched by the drafts-review surface. Centralized
48# here so the gen-side writer and the drafts-side reader agree on the
49# exact wording. Changing a keyword here requires updating any cached
50# markers on disk (one-shot find -delete or a regen).
51PENDING_MARKER_KEYWORD_PARSE = "PENDING: batch parse failed"
52PENDING_MARKER_KEYWORD_COLLISION = "PENDING: concept slug collision"
55class PendingKind(StrEnum):
56 """Reason a wiki draft is in ``drafts/`` instead of a published page.
58 The string value is what lands in the ``pending_kind`` YAML
59 frontmatter field and is surfaced verbatim through
60 ``DraftInfo.pending_kind`` to CLI / HTTP / MCP callers.
61 StrEnum members serialise as their string value, so the YAML/JSON
62 round-trip stays a plain string. ``DRIFT`` is display-only, never
63 written to disk, but exposed so consumers don't hard-code ``"drift"``.
64 """
66 PARSE = "parse"
67 COLLISION = "collision"
68 DRIFT = "drift"
71class WikiLogAction(StrEnum):
72 """Verbs written into ``wiki/log.md`` audit-trail entries.
74 Distinct from WIKI_STATUS_* (which are result statuses returned to
75 CLI/MCP/HTTP callers); these label internal audit-trail rows.
76 """
78 GENERATED = "generated"
79 BUILD = "build"
80 INGEST = "ingest"
81 LINT = "lint"
84SUBDIR_TO_TYPE: dict[str, WikiPageType] = {
85 WikiSubdir.SUMMARIES.value: WikiPageType.SUMMARY,
86 WikiSubdir.SYNTHESIS.value: WikiPageType.SYNTHESIS,
87 WikiSubdir.CONCEPTS.value: WikiPageType.CONCEPT,
88 WikiSubdir.ENTITIES.value: WikiPageType.ENTITY,
89 WikiSubdir.DRAFTS.value: WikiPageType.DRAFT,
90 WikiSubdir.ARCHIVE.value: WikiPageType.ARCHIVE,
91}
93# One source of truth for sidebar-style headings keyed by page type.
94# Consumed by ``wiki/index.py`` and the TUI sidebar via
95# ``cli/tui/messages.WIKI_TYPE_HEADINGS``.
96WIKI_TYPE_HEADINGS: dict[WikiPageType, str] = {
97 WikiPageType.CONCEPT: "Concepts",
98 WikiPageType.ENTITY: "Entities",
99 WikiPageType.SUMMARY: "Source Summaries",
100 WikiPageType.SYNTHESIS: "Synthesis",
101}
104@dataclass(frozen=True)
105class PageTarget:
106 """Grouping of page location fields for wiki generation."""
108 wiki_root: Path
109 subdir: str
110 slug: str
111 wiki_source: str
112 page_type: str
113 label: str
116def parse_frontmatter(text: str) -> dict[str, Any]:
117 """Extract YAML frontmatter fields from a wiki page string.
118 Uses line-by-line scanning so ``---`` inside YAML content is not
119 mistaken for the closing delimiter.
120 """
121 lines = text.splitlines()
122 if not lines or lines[0].strip() != "---":
123 return {}
124 end_idx: int | None = None
125 for i in range(1, len(lines)):
126 if lines[i].strip() == "---":
127 end_idx = i
128 break
129 if end_idx is None:
130 return {}
131 block = "\n".join(lines[1:end_idx])
132 try:
133 return yaml.safe_load(block) or {}
134 except yaml.YAMLError:
135 return {}