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

1"""Wiki layer route handlers — page listing, reading, citations, lint, generation, pruning.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6from pathlib import Path 

7from typing import Any 

8 

9from litestar import delete, get, patch, post 

10from litestar.exceptions import NotFoundException 

11from litestar.params import Parameter 

12 

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 

48 

49 

50def _wiki_root() -> Path: 

51 """Resolve the wiki directory under data_root.""" 

52 return cfg.data_root / cfg.wiki_dir 

53 

54 

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) 

59 

60 

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) 

64 

65 

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() 

75 

76 index_path = root / "index.md" 

77 if index_path.is_file(): 

78 update_wiki_index() 

79 

80 pages = list_pages(root) 

81 return [p.to_dict() for p in pages] 

82 

83 

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())] 

90 

91 

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. 

96 

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) 

109 

110 

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. 

114 

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()) 

125 

126 

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) 

137 

138 

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] 

150 

151 

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 ) 

169 

170 

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]) 

179 

180 

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 ) 

191 

192 

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 ) 

203 

204 

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 

209 

210 

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 

217 

218 

219def _reset_wiki_build_lock() -> None: 

220 """Test hook: clear the per-process wiki-build mutex. 

221 

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 

227 

228 

229@post("/api/wiki/build") 

230async def wiki_build_route() -> WikiBuildResult: 

231 """Build the concept and entity wiki across all ingested sources. 

232 

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) 

241 

242 

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) 

250 

251 

252@post("/api/wiki/synthesize") 

253async def wiki_synthesize_route() -> WikiSynthesizeResult: 

254 """Generate synthesis pages for concept clusters spanning 3+ sources. 

255 

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) 

263 

264 

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) 

272 

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 [] 

277 

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 )