Coverage for src / lilbee / server / wiki.py: 100%
143 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""Wiki layer route handlers — page listing, reading, citations, lint, generation, pruning."""
3from __future__ import annotations
5import asyncio
6from pathlib import Path
7from typing import Any
9from litestar import delete, get, patch, post
10from litestar.exceptions import NotFoundException
11from litestar.params import Parameter
13from lilbee import services as svc_mod
14from lilbee.config import cfg
15from lilbee.server.auth import read_only
16from lilbee.server.models import (
17 DraftInfoResponse,
18 WikiBuildResult,
19 WikiCitationRecord,
20 WikiCitationsResult,
21 WikiDraftAcceptResponse,
22 WikiDraftDiffResponse,
23 WikiDraftRejectResponse,
24 WikiLintIssueItem,
25 WikiLintResult,
26 WikiPageDetail,
27 WikiPruneRecordResponse,
28 WikiPruneResult,
29 WikiStatusResult,
30 WikiSynthesizeResult,
31)
32from lilbee.wiki import lint as lint_mod
33from lilbee.wiki import prune as prune_mod
34from lilbee.wiki import run_full_build, run_full_synthesize
35from lilbee.wiki.browse import (
36 find_page,
37 list_pages,
38 read_page,
39)
40from lilbee.wiki.drafts import (
41 accept_draft,
42 diff_draft,
43 list_drafts,
44 reject_draft,
45)
46from lilbee.wiki.index import update_wiki_index
47from lilbee.wiki.shared import DRAFTS_SUBDIR, SUMMARIES_SUBDIR, WIKI_DISABLED_ERROR
50def _wiki_root() -> Path:
51 """Resolve the wiki directory under data_root."""
52 return cfg.data_root / cfg.wiki_dir
55def _require_wiki() -> None:
56 """Raise 404 if the wiki feature is disabled."""
57 if not cfg.wiki:
58 raise NotFoundException(detail=WIKI_DISABLED_ERROR)
61def _find_page(slug: str) -> Path | None:
62 """Resolve a slug to a wiki page path via the browse module."""
63 return find_page(_wiki_root(), slug)
66@get("/api/wiki")
67@read_only
68async def wiki_list_route() -> list[dict[str, Any]]:
69 """List all wiki pages across subdirectories.
70 If wiki/index.md exists, regenerate it first to ensure freshness,
71 then build the page list from disk.
72 """
73 _require_wiki()
74 root = _wiki_root()
76 index_path = root / "index.md"
77 if index_path.is_file():
78 update_wiki_index()
80 pages = list_pages(root)
81 return [p.to_dict() for p in pages]
84@get("/api/wiki/drafts")
85@read_only
86async def wiki_drafts_route() -> list[DraftInfoResponse]:
87 """List pending wiki drafts with drift, faithfulness, and pending-marker info."""
88 _require_wiki()
89 return [DraftInfoResponse(**d.to_dict()) for d in list_drafts(_wiki_root())]
92@get("/api/wiki/drafts/diff/{slug:path}")
93@read_only
94async def wiki_draft_diff_route(slug: str) -> WikiDraftDiffResponse:
95 """Return the unified diff of a draft against its published counterpart.
97 The ``diff`` action prefix precedes the slug because Litestar's
98 ``{slug:path}`` parameter is greedy and does not support a fixed
99 trailing segment. Keeping the action as a literal prefix lets
100 nested slugs (``cars/caprice``) flow through unchanged.
101 """
102 _require_wiki()
103 slug = slug.lstrip("/")
104 try:
105 diff = diff_draft(slug, _wiki_root())
106 except FileNotFoundError as exc:
107 raise NotFoundException(detail=f"draft not found: {slug}") from exc
108 return WikiDraftDiffResponse(slug=slug, diff=diff)
111@post("/api/wiki/drafts/accept/{slug:path}")
112async def wiki_draft_accept_route(slug: str) -> WikiDraftAcceptResponse:
113 """Accept a draft: overwrite the published page and re-index its chunks.
115 See :func:`wiki_draft_diff_route` for the action-prefix rationale.
116 """
117 _require_wiki()
118 slug = slug.lstrip("/")
119 store = svc_mod.get_services().store
120 try:
121 result = accept_draft(slug, _wiki_root(), store)
122 except FileNotFoundError as exc:
123 raise NotFoundException(detail=f"draft not found: {slug}") from exc
124 return WikiDraftAcceptResponse(**result.to_dict())
127@delete("/api/wiki/drafts/{slug:path}", status_code=200)
128async def wiki_draft_reject_route(slug: str) -> WikiDraftRejectResponse:
129 """Reject a draft: delete the draft file without touching the published page."""
130 _require_wiki()
131 slug = slug.lstrip("/")
132 try:
133 reject_draft(slug, _wiki_root())
134 except FileNotFoundError as exc:
135 raise NotFoundException(detail=f"draft not found: {slug}") from exc
136 return WikiDraftRejectResponse(slug=slug)
139@get("/api/wiki/citations")
140@read_only
141async def wiki_citations_reverse_route(
142 source: str = Parameter(query="source", default=""),
143) -> list[WikiCitationRecord]:
144 """Reverse citation lookup: which wiki pages cite a given source."""
145 _require_wiki()
146 if not source:
147 return []
148 records = svc_mod.get_services().store.get_citations_for_source(source)
149 return [WikiCitationRecord(**r) for r in records]
152@get("/api/wiki/{slug:path}")
153@read_only
154async def wiki_read_route(slug: str) -> WikiPageDetail | WikiCitationsResult:
155 """Read a specific wiki page as markdown, or its citations."""
156 _require_wiki()
157 slug = slug.lstrip("/")
158 if slug.endswith("/citations"):
159 real_slug = slug.removesuffix("/citations")
160 return _citations_for_slug(real_slug)
161 result = read_page(_wiki_root(), slug)
162 if result is None:
163 raise NotFoundException(detail=f"wiki page not found: {slug}")
164 return WikiPageDetail(
165 slug=result.slug,
166 title=result.title,
167 content=result.content,
168 )
171def _citations_for_slug(slug: str) -> WikiCitationsResult:
172 """Return citation chain for a wiki page."""
173 path = _find_page(slug)
174 if path is None:
175 raise NotFoundException(detail=f"wiki page not found: {slug}")
176 wiki_source = f"{cfg.wiki_dir}/{slug}.md"
177 records = svc_mod.get_services().store.get_citations_for_wiki(wiki_source)
178 return WikiCitationsResult(slug=slug, citations=[WikiCitationRecord(**r) for r in records])
181@post("/api/wiki/lint")
182async def wiki_lint_route() -> WikiLintResult:
183 """Trigger a full wiki lint."""
184 _require_wiki()
185 report = lint_mod.lint_all(svc_mod.get_services().store)
186 return WikiLintResult(
187 issues=[WikiLintIssueItem(**i.to_dict()) for i in report.issues],
188 errors=report.error_count,
189 warnings=report.warning_count,
190 )
193@post("/api/wiki/prune")
194async def wiki_prune_route() -> WikiPruneResult:
195 """Trigger pruning of stale/orphaned wiki pages."""
196 _require_wiki()
197 report = prune_mod.prune_wiki(svc_mod.get_services().store)
198 return WikiPruneResult(
199 records=[WikiPruneRecordResponse(**r.to_dict()) for r in report.records],
200 archived=report.archived_count,
201 flagged=report.flagged_count,
202 )
205# Serialize wiki builds: ``run_full_build`` writes pages, the wiki index, and
206# the wiki log; two concurrent calls would corrupt those. The lock is created
207# lazily because ``Lock()`` requires a running event loop.
208_WIKI_BUILD_LOCK: asyncio.Lock | None = None
211def _wiki_build_lock() -> asyncio.Lock:
212 """Return the per-process wiki-build mutex, creating it on first call."""
213 global _WIKI_BUILD_LOCK
214 if _WIKI_BUILD_LOCK is None:
215 _WIKI_BUILD_LOCK = asyncio.Lock()
216 return _WIKI_BUILD_LOCK
219def _reset_wiki_build_lock() -> None:
220 """Test hook: clear the per-process wiki-build mutex.
222 Mirrors ``handlers._reset_ingest_locks`` so a test that creates the
223 lock under one event loop doesn't leak it into the next test.
224 """
225 global _WIKI_BUILD_LOCK
226 _WIKI_BUILD_LOCK = None
229@post("/api/wiki/build")
230async def wiki_build_route() -> WikiBuildResult:
231 """Build the concept and entity wiki across all ingested sources.
233 The build is CPU- and IO-bound (LLM calls, embeddings, file writes) so
234 it runs in a worker thread; concurrent build/update requests serialize
235 on a per-process lock so they don't corrupt the wiki index.
236 """
237 _require_wiki()
238 async with _wiki_build_lock():
239 result = await asyncio.to_thread(run_full_build, cfg)
240 return WikiBuildResult(**result)
243@patch("/api/wiki/update")
244async def wiki_update_route() -> WikiBuildResult:
245 """Refresh the concept and entity wiki after an ingest. Currently a full rebuild."""
246 _require_wiki()
247 async with _wiki_build_lock():
248 result = await asyncio.to_thread(run_full_build, cfg)
249 return WikiBuildResult(**result)
252@post("/api/wiki/synthesize")
253async def wiki_synthesize_route() -> WikiSynthesizeResult:
254 """Generate synthesis pages for concept clusters spanning 3+ sources.
256 Shares the wiki-build mutex so synthesis can't race a build/update
257 over the same on-disk wiki tree.
258 """
259 _require_wiki()
260 async with _wiki_build_lock():
261 result = await asyncio.to_thread(run_full_synthesize, cfg)
262 return WikiSynthesizeResult(**result)
265@get("/api/wiki/status")
266@read_only
267async def wiki_status_route() -> WikiStatusResult:
268 """Wiki layer status: page counts and recent lint counts."""
269 root = _wiki_root()
270 if not root.exists():
271 return WikiStatusResult(wiki_enabled=cfg.wiki)
273 summaries_dir = root / SUMMARIES_SUBDIR
274 drafts_dir = root / DRAFTS_SUBDIR
275 summaries = list(summaries_dir.rglob("*.md")) if summaries_dir.exists() else []
276 drafts = list(drafts_dir.rglob("*.md")) if drafts_dir.exists() else []
278 report = lint_mod.lint_all(svc_mod.get_services().store)
279 return WikiStatusResult(
280 wiki_enabled=cfg.wiki,
281 summaries=len(summaries),
282 drafts=len(drafts),
283 pages=len(summaries) + len(drafts),
284 lint_errors=report.error_count,
285 lint_warnings=report.warning_count,
286 )