Coverage for src/lilbee/cli/commands/wiki.py: 100%

399 statements  

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

1"""Wiki layer commands: build, update, browse, lint, citations, status, prune, drafts.""" 

2 

3from __future__ import annotations 

4 

5from contextlib import contextmanager 

6from pathlib import Path 

7from typing import TYPE_CHECKING, NoReturn 

8 

9import typer 

10from rich.table import Table 

11 

12from lilbee.app.services import get_services 

13from lilbee.cli import theme 

14from lilbee.cli.app import ( 

15 apply_overrides, 

16 console, 

17 data_dir_option, 

18 global_option, 

19) 

20from lilbee.cli.helpers import json_output, sigint_cancel 

21from lilbee.cli.tui import messages as msg 

22from lilbee.core.config import cfg 

23from lilbee.core.security import PathTraversalError 

24from lilbee.runtime.progress import EventType, WikiPageEvent, WikiPhaseEvent 

25from lilbee.wiki.shared import ( 

26 INVALID_DRAFT_SLUG_ERROR, 

27 WikiSubdir, 

28 total_wiki_pages, 

29) 

30from lilbee.wiki.stats import format_summary_line 

31 

32if TYPE_CHECKING: 

33 from collections.abc import Callable, Iterator 

34 

35 from lilbee.data.store import CitationRecord 

36 from lilbee.runtime.progress import DetailedProgressCallback, ProgressEvent 

37 from lilbee.wiki.generation import WikiEntityCandidate 

38 from lilbee.wiki.stats import BuildStatsDict 

39 

40 

41wiki_app = typer.Typer( 

42 help="Wiki layer commands: generate, browse, lint, citations, status, prune." 

43) 

44 

45# Citations table renders excerpts truncated to ``_CITATION_EXCERPT_MAX_CHARS``; 

46# the ellipsis insertion point is one ``...`` shorter so the visible string never 

47# exceeds the column width. 

48_CITATION_EXCERPT_MAX_CHARS = 60 

49_CITATION_EXCERPT_TRUNCATE_AT = 57 

50 

51# Dry-run NER output previews the first ``_NER_DRY_RUN_PREVIEW_LIMIT`` sources 

52# per row, with ``", ..."`` appended when more were dropped. 

53_NER_DRY_RUN_PREVIEW_LIMIT = 3 

54 

55 

56def _count_md_files(directory: Path) -> int: 

57 """Count markdown files in a directory.""" 

58 if not directory.exists(): 

59 return 0 

60 return len(list(directory.rglob("*.md"))) 

61 

62 

63def _print_build_stats(stats: BuildStatsDict) -> None: 

64 """Print what a build or synthesize run's quality gates did.""" 

65 console.print(f" Gates: {format_summary_line(stats)}") 

66 

67 

68def _wiki_progress_line(event_type: EventType, data: ProgressEvent) -> str | None: 

69 """Progress description for a wiki run event, or None when it carries no line. 

70 

71 Wording is shared with the TUI task bar so both surfaces name a phase and a 

72 page the same way. 

73 """ 

74 if event_type is EventType.WIKI_PHASE and isinstance(data, WikiPhaseEvent): 

75 return msg.WIKI_BUILD_PHASE.format(phase=data.phase.value) 

76 if event_type is EventType.WIKI_PAGE and isinstance(data, WikiPageEvent): 

77 return msg.WIKI_BUILD_PAGE.format(label=data.label, current=data.current, total=data.total) 

78 return None 

79 

80 

81@contextmanager 

82def _wiki_progress() -> Iterator[DetailedProgressCallback]: 

83 """Render a wiki run's phase and page events on a spinner line. 

84 

85 A build issues one LLM call per source and runs for hours, so the CLI shows 

86 the same events the HTTP and TUI surfaces consume. Disabled in json_mode so 

87 stdout stays a single JSON document. 

88 """ 

89 from rich.console import Console as RichConsole 

90 from rich.progress import Progress, SpinnerColumn, TextColumn 

91 

92 with Progress( 

93 SpinnerColumn(), 

94 TextColumn("{task.description}"), 

95 transient=True, 

96 console=RichConsole(stderr=True), 

97 disable=cfg.json_mode, 

98 ) as progress: 

99 task = progress.add_task(msg.WIKI_BUILD_STARTING, total=None) 

100 

101 def on_progress(event_type: EventType, data: ProgressEvent) -> None: 

102 line = _wiki_progress_line(event_type, data) 

103 if line is not None: 

104 progress.update(task, description=line) 

105 

106 yield on_progress 

107 

108 

109def _fail_wiki_disabled() -> NoReturn: 

110 """Emit the standard wiki-disabled message and exit non-zero.""" 

111 if cfg.json_mode: 

112 json_output({"error": msg.CMD_WIKI_DISABLED}) 

113 else: 

114 console.print(msg.CMD_WIKI_DISABLED) 

115 raise typer.Exit(1) 

116 

117 

118@wiki_app.command(name="lint") 

119def wiki_lint( 

120 wiki_source: str = typer.Argument("", help="Wiki page path (empty = lint all)."), 

121 data_dir: Path | None = data_dir_option, 

122 use_global: bool = global_option, 

123) -> None: 

124 """Lint wiki pages for stale citations, missing sources, and unmarked claims. 

125 

126 Exits 1 when any issue is an error, so a script can gate on the result. 

127 """ 

128 apply_overrides(data_dir=data_dir, use_global=use_global) 

129 from lilbee.wiki.lint import IssueSeverity, LintReport, lint_wiki_page 

130 from lilbee.wiki.lint import lint_all as _lint_all 

131 

132 store = get_services().store 

133 report = ( 

134 LintReport(issues=lint_wiki_page(wiki_source, store)) if wiki_source else _lint_all(store) 

135 ) 

136 issues = report.issues 

137 

138 if cfg.json_mode: 

139 json_output( 

140 { 

141 "command": "wiki_lint", 

142 "issues": [i.to_dict() for i in issues], 

143 "total": len(issues), 

144 "errors": report.error_count, 

145 "warnings": report.warning_count, 

146 } 

147 ) 

148 elif not issues: 

149 console.print("No issues found.") 

150 else: 

151 table = Table(title="Wiki Lint Issues") 

152 table.add_column("Page", style=theme.ACCENT) 

153 table.add_column("Severity") 

154 table.add_column("Message") 

155 for issue in issues: 

156 sev_style = theme.ERROR if issue.severity is IssueSeverity.ERROR else theme.WARNING 

157 sev_text = f"[{sev_style}]{issue.severity.value}[/{sev_style}]" 

158 table.add_row(issue.wiki_source, sev_text, issue.message) 

159 console.print(table) 

160 

161 if report.error_count: 

162 raise typer.Exit(1) 

163 

164 

165@wiki_app.command(name="list") 

166def wiki_list( 

167 data_dir: Path | None = data_dir_option, 

168 use_global: bool = global_option, 

169) -> None: 

170 """List wiki pages with their type, source count, and creation date.""" 

171 apply_overrides(data_dir=data_dir, use_global=use_global) 

172 from lilbee.wiki.browse import list_pages 

173 

174 pages = list_pages(cfg.data_root / cfg.wiki_dir) 

175 

176 if cfg.json_mode: 

177 json_output( 

178 { 

179 "command": "wiki_list", 

180 "pages": [p.to_dict() for p in pages], 

181 "total": len(pages), 

182 } 

183 ) 

184 return 

185 

186 if not pages: 

187 console.print("No wiki pages found.") 

188 return 

189 

190 table = Table(title=f"Wiki Pages ({len(pages)})") 

191 table.add_column("Slug", style=theme.ACCENT) 

192 table.add_column("Title") 

193 table.add_column("Type", style=theme.MUTED) 

194 table.add_column("Sources") 

195 table.add_column("Created", style=theme.MUTED) 

196 for page in pages: 

197 table.add_row( 

198 page.slug, page.title, page.page_type, str(page.source_count), page.created_at 

199 ) 

200 console.print(table) 

201 

202 

203@wiki_app.command(name="read") 

204def wiki_read( 

205 slug: str = typer.Argument(..., help="Page slug, e.g. entities/chevrolet."), 

206 data_dir: Path | None = data_dir_option, 

207 use_global: bool = global_option, 

208) -> None: 

209 """Print a wiki page's markdown.""" 

210 apply_overrides(data_dir=data_dir, use_global=use_global) 

211 from lilbee.wiki.browse import read_page 

212 

213 page = read_page(cfg.data_root / cfg.wiki_dir, slug) 

214 if page is None: 

215 message = f"wiki page not found: {slug}" 

216 if cfg.json_mode: 

217 json_output({"error": message}) 

218 else: 

219 console.print(f"[{theme.ERROR}]{message}[/{theme.ERROR}]") 

220 raise typer.Exit(1) 

221 

222 if cfg.json_mode: 

223 json_output( 

224 { 

225 "command": "wiki_read", 

226 "slug": page.slug, 

227 "title": page.title, 

228 "content": page.content, 

229 "frontmatter": page.frontmatter, 

230 } 

231 ) 

232 return 

233 console.print(page.content) 

234 

235 

236@wiki_app.command(name="citations") 

237def wiki_citations( 

238 wiki_source: str = typer.Argument("", help="Wiki page path, e.g. wiki/summaries/doc.md."), 

239 data_dir: Path | None = data_dir_option, 

240 use_global: bool = global_option, 

241 source: str = typer.Option( 

242 "", 

243 "--source", 

244 help="Reverse lookup: list the wiki pages citing this source document.", 

245 ), 

246) -> None: 

247 """Show a wiki page's citations, or the pages citing a source document.""" 

248 apply_overrides(data_dir=data_dir, use_global=use_global) 

249 if bool(wiki_source) == bool(source): 

250 message = "Pass either a wiki page path or --source, not both." 

251 if cfg.json_mode: 

252 json_output({"error": message}) 

253 else: 

254 console.print(f"[{theme.ERROR}]{message}[/{theme.ERROR}]") 

255 raise typer.Exit(1) 

256 

257 store = get_services().store 

258 if source: 

259 _render_citations( 

260 store.get_citations_for_source(source), 

261 key="source", 

262 value=source, 

263 title=f"Pages citing: {source}", 

264 column_header="Page", 

265 column_value=lambda rec: rec["wiki_source"], 

266 ) 

267 return 

268 _render_citations( 

269 store.get_citations_for_wiki(wiki_source), 

270 key="wiki_source", 

271 value=wiki_source, 

272 title=f"Citations: {wiki_source}", 

273 column_header="Source", 

274 column_value=lambda rec: rec["source_filename"], 

275 ) 

276 

277 

278def _render_citations( 

279 records: list[CitationRecord], 

280 *, 

281 key: str, 

282 value: str, 

283 title: str, 

284 column_header: str, 

285 column_value: Callable[[CitationRecord], str], 

286) -> None: 

287 """Render citation rows as JSON or a table, in whichever direction was asked. 

288 

289 The second column differs by direction: the forward lookup names the source 

290 a page cites, the reverse one names the page citing a source. 

291 """ 

292 if cfg.json_mode: 

293 json_output( 

294 { 

295 "command": "wiki_citations", 

296 key: value, 

297 "citations": [dict(r) for r in records], 

298 "total": len(records), 

299 } 

300 ) 

301 return 

302 

303 if not records: 

304 console.print(f"No citations found for [{theme.ACCENT}]{value}[/{theme.ACCENT}]") 

305 return 

306 

307 table = Table(title=title) 

308 table.add_column("Key", style=theme.ACCENT) 

309 table.add_column(column_header) 

310 table.add_column("Type", style=theme.MUTED) 

311 table.add_column("Excerpt", max_width=_CITATION_EXCERPT_MAX_CHARS) 

312 for rec in records: 

313 excerpt = ( 

314 rec["excerpt"][:_CITATION_EXCERPT_TRUNCATE_AT] + "..." 

315 if len(rec["excerpt"]) > _CITATION_EXCERPT_MAX_CHARS 

316 else rec["excerpt"] 

317 ) 

318 table.add_row(rec["citation_key"], column_value(rec), rec["claim_type"], excerpt) 

319 console.print(table) 

320 

321 

322@wiki_app.command(name="status") 

323def wiki_status( 

324 data_dir: Path | None = data_dir_option, 

325 use_global: bool = global_option, 

326) -> None: 

327 """Show wiki layer status: page counts and lint summary.""" 

328 apply_overrides(data_dir=data_dir, use_global=use_global) 

329 

330 wiki_root = cfg.data_root / cfg.wiki_dir 

331 if not cfg.wiki or not wiki_root.exists(): 

332 # A disabled wiki can still have a tree left over from an earlier build; 

333 # report the disabled state rather than linting it. 

334 if cfg.json_mode: 

335 json_output( 

336 { 

337 "wiki_enabled": cfg.wiki, 

338 WikiSubdir.SUMMARIES: 0, 

339 WikiSubdir.DRAFTS: 0, 

340 "pages": 0, 

341 "lint_errors": 0, 

342 "lint_warnings": 0, 

343 } 

344 ) 

345 return 

346 if not cfg.wiki: 

347 console.print(f"Wiki: [{theme.ERROR}]disabled[/{theme.ERROR}]") 

348 else: 

349 console.print("Wiki directory does not exist yet. Run `lilbee wiki build`.") 

350 return 

351 

352 summaries = _count_md_files(wiki_root / WikiSubdir.SUMMARIES) 

353 drafts = _count_md_files(wiki_root / WikiSubdir.DRAFTS) 

354 

355 from lilbee.wiki.lint import lint_all as _lint_all 

356 

357 # Read-only status: lint for counts without appending to the audit log. 

358 with sigint_cancel() as cancel: 

359 report = _lint_all(get_services().store, record_log=False, cancel=cancel) 

360 

361 if cfg.json_mode: 

362 json_output( 

363 { 

364 "wiki_enabled": cfg.wiki, 

365 WikiSubdir.SUMMARIES: summaries, 

366 WikiSubdir.DRAFTS: drafts, 

367 "pages": total_wiki_pages(wiki_root), 

368 "lint_errors": report.error_count, 

369 "lint_warnings": report.warning_count, 

370 } 

371 ) 

372 return 

373 

374 console.print(f"Wiki: [{theme.SUCCESS}]enabled[/{theme.SUCCESS}]") 

375 console.print(f" Summaries: [{theme.LABEL}]{summaries}[/{theme.LABEL}]") 

376 console.print(f" Drafts: [{theme.LABEL}]{drafts}[/{theme.LABEL}]") 

377 if report.error_count or report.warning_count: 

378 console.print( 

379 f" Lint: [{theme.ERROR}]{report.error_count} error(s)[/{theme.ERROR}], " 

380 f"[{theme.WARNING}]{report.warning_count} warning(s)[/{theme.WARNING}]" 

381 ) 

382 else: 

383 console.print(" Lint: all clean") 

384 

385 

386@wiki_app.command(name="synthesize") 

387def wiki_synthesize( 

388 data_dir: Path | None = data_dir_option, 

389 use_global: bool = global_option, 

390) -> None: 

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

392 apply_overrides(data_dir=data_dir, use_global=use_global) 

393 if not cfg.wiki: 

394 _fail_wiki_disabled() 

395 from lilbee.wiki import run_full_synthesize 

396 

397 with _wiki_progress() as on_progress, sigint_cancel() as cancel: 

398 result = run_full_synthesize(cfg, on_progress, cancel) 

399 

400 if cfg.json_mode: 

401 json_output({"command": "wiki_synthesize", **result}) 

402 return 

403 

404 paths = result["paths"] 

405 if paths: 

406 console.print( 

407 f"Generated [{theme.LABEL}]{result['count']}[/{theme.LABEL}] synthesis pages:" 

408 ) 

409 for path in paths: 

410 console.print(f" {path}") 

411 else: 

412 console.print("No synthesis pages generated (need 3+ sources per cluster).") 

413 _print_build_stats(result["stats"]) 

414 

415 

416@wiki_app.command(name="prune") 

417def wiki_prune( 

418 data_dir: Path | None = data_dir_option, 

419 use_global: bool = global_option, 

420) -> None: 

421 """Prune stale and orphaned wiki pages.""" 

422 apply_overrides(data_dir=data_dir, use_global=use_global) 

423 if not cfg.wiki: 

424 _fail_wiki_disabled() 

425 from lilbee.wiki.prune import prune_wiki 

426 

427 with sigint_cancel() as cancel: 

428 report = prune_wiki(get_services().store, cancel=cancel) 

429 

430 if cfg.json_mode: 

431 json_output( 

432 { 

433 "command": "wiki_prune", 

434 "records": [r.to_dict() for r in report.records], 

435 "archived": report.archived_count, 

436 "flagged": report.flagged_count, 

437 "reconciled": report.reconciled_count, 

438 } 

439 ) 

440 return 

441 

442 if not report.records: 

443 console.print("No pages pruned.") 

444 return 

445 

446 table = Table(title="Wiki Prune Results") 

447 table.add_column("Page", style=theme.ACCENT) 

448 table.add_column("Action") 

449 table.add_column("Reason") 

450 for rec in report.records: 

451 action_style = theme.ERROR if rec.action.value == "archived" else theme.WARNING 

452 action_text = f"[{action_style}]{rec.action.value}[/{action_style}]" 

453 table.add_row(rec.wiki_source, action_text, rec.reason) 

454 console.print(table) 

455 

456 

457@wiki_app.command(name="index") 

458def wiki_index( 

459 data_dir: Path | None = data_dir_option, 

460 use_global: bool = global_option, 

461) -> None: 

462 """Rebuild the browse index of pages the corpus could have. 

463 

464 Spends no LLM call. A sync refreshes this for you; run it to repair an 

465 index that was deleted or written by an older version. 

466 """ 

467 apply_overrides(data_dir=data_dir, use_global=use_global) 

468 if not cfg.wiki: 

469 _fail_wiki_disabled() 

470 from lilbee.wiki.stubs import refresh_stub_index 

471 

472 stubs = refresh_stub_index(get_services().store) 

473 if cfg.json_mode: 

474 json_output({"command": "wiki_index", "entries": len(stubs)}) 

475 else: 

476 console.print(f"Wiki index: {len(stubs)} page(s) the corpus names") 

477 

478 

479@wiki_app.command(name="generate") 

480def wiki_generate( 

481 slug: str = typer.Argument(..., help="Indexed page slug, as `wiki list` shows it."), 

482 data_dir: Path | None = data_dir_option, 

483 use_global: bool = global_option, 

484) -> None: 

485 """Generate one indexed page. Costs a single LLM call and is GPU-heavy.""" 

486 apply_overrides(data_dir=data_dir, use_global=use_global) 

487 if not cfg.wiki: 

488 _fail_wiki_disabled() 

489 from lilbee.wiki.lazy import UnknownStubError, generate_stub_page 

490 

491 try: 

492 with _wiki_progress(): 

493 path = generate_stub_page(slug, get_services().store) 

494 except UnknownStubError as exc: 

495 if cfg.json_mode: 

496 json_output({"error": str(exc)}) 

497 else: 

498 console.print(str(exc)) 

499 raise typer.Exit(1) from exc 

500 

501 if path is None: 

502 message = msg.CMD_WIKI_GENERATE_NO_EVIDENCE.format(slug=slug) 

503 if cfg.json_mode: 

504 json_output({"error": message}) 

505 else: 

506 console.print(message) 

507 raise typer.Exit(1) 

508 

509 if cfg.json_mode: 

510 from lilbee.wiki.browse import page_slug 

511 

512 # The read surfaces address pages by section, so answer with that slug. 

513 read_slug = page_slug(path, cfg.data_root / cfg.wiki_dir) 

514 json_output({"command": "wiki_generate", "slug": read_slug, "path": str(path)}) 

515 else: 

516 console.print(f"Wrote {path}") 

517 

518 

519@wiki_app.command(name="wipe") 

520def wiki_wipe( 

521 data_dir: Path | None = data_dir_option, 

522 use_global: bool = global_option, 

523 yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), 

524) -> None: 

525 """Delete every generated wiki page and its indexed rows. 

526 

527 Available with the wiki disabled: turning the setting off stops new pages 

528 being written but leaves the ones already generated in place. 

529 """ 

530 apply_overrides(data_dir=data_dir, use_global=use_global) 

531 from lilbee.wiki.wipe import wipe_wiki 

532 

533 wiki_root = cfg.data_root / cfg.wiki_dir 

534 if not yes: 

535 if cfg.json_mode: 

536 json_output({"error": msg.CMD_WIKI_WIPE_NEEDS_YES}) 

537 raise typer.Exit(1) 

538 console.print(msg.CMD_WIKI_WIPE_WARNING.format(path=wiki_root)) 

539 if not typer.confirm("Delete the wiki?", default=False): 

540 console.print("Aborted.") 

541 raise typer.Exit(0) 

542 

543 report = wipe_wiki(get_services().store) 

544 if cfg.json_mode: 

545 json_output( 

546 { 

547 "command": "wiki_wipe", 

548 "pages_removed": report.pages_removed, 

549 "sources_cleared": report.sources_cleared, 

550 "rows_deleted": report.rows_deleted, 

551 } 

552 ) 

553 else: 

554 console.print(report.summary()) 

555 if not report.rows_deleted: 

556 raise typer.Exit(1) 

557 

558 

559@wiki_app.command(name="build") 

560def wiki_build( 

561 data_dir: Path | None = data_dir_option, 

562 use_global: bool = global_option, 

563 dry_run: bool = typer.Option( 

564 False, 

565 "--dry-run", 

566 help=( 

567 "Run extraction only; skip every LLM call. Prints the NER entity candidates. " 

568 "LLM-curated concept pages require a build call and are not shown in dry-run." 

569 ), 

570 ), 

571) -> None: 

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

573 apply_overrides(data_dir=data_dir, use_global=use_global) 

574 if not cfg.wiki: 

575 _fail_wiki_disabled() 

576 

577 if dry_run: 

578 from lilbee.wiki.generation import preview_build_entities 

579 

580 _wiki_build_dry_run_output(preview_build_entities(cfg)) 

581 return 

582 

583 _run_wiki_build("wiki_build") 

584 

585 

586def _run_wiki_build(command_name: str) -> None: 

587 """Run the full build and render its result under *command_name*.""" 

588 from lilbee.wiki import run_full_build 

589 

590 with _wiki_progress() as on_progress, sigint_cancel() as cancel: 

591 result = run_full_build(cfg, on_progress, cancel) 

592 

593 if cfg.json_mode: 

594 json_output({"command": command_name, **result}) 

595 return 

596 

597 pages = result["paths"] 

598 if pages: 

599 console.print( 

600 f"Generated [{theme.LABEL}]{result['count']}[/{theme.LABEL}] " 

601 f"wiki pages from {result['entities']} extracted records:" 

602 ) 

603 for path in pages: 

604 console.print(f" {path}") 

605 else: 

606 console.print("No concept or entity pages generated.") 

607 _print_build_stats(result["stats"]) 

608 

609 

610def _wiki_build_dry_run_output(rows: list[WikiEntityCandidate]) -> None: 

611 """Render the extraction result as JSON or table without calling any LLM. 

612 

613 Concepts come from the per-source batched LLM call, so listing 

614 them would require the call we are trying to avoid. The dry-run 

615 surface is NER-entity only, with a trailing note so a user who 

616 expected concepts in the output knows why they are missing. 

617 """ 

618 from lilbee.wiki.generation import DRY_RUN_CONCEPT_NOTE 

619 

620 if cfg.json_mode: 

621 json_output( 

622 { 

623 "command": "wiki_build", 

624 "dry_run": True, 

625 "entities": rows, 

626 "count": len(rows), 

627 "note": DRY_RUN_CONCEPT_NOTE, 

628 } 

629 ) 

630 return 

631 

632 if not rows: 

633 console.print("No candidate entities extracted. Run sync first.") 

634 console.print(f"[{theme.MUTED}]{DRY_RUN_CONCEPT_NOTE}[/{theme.MUTED}]") 

635 return 

636 

637 table = Table(title=f"Wiki build dry-run ({len(rows)} NER entity candidates)") 

638 table.add_column("Slug", style=theme.ACCENT) 

639 table.add_column("Kind", style=theme.MUTED) 

640 table.add_column("Type") 

641 table.add_column("Mentions") 

642 table.add_column("Sources") 

643 for row in rows: 

644 sources_list: list[str] = row["sources"] 

645 table.add_row( 

646 str(row["slug"]), 

647 str(row["kind"]), 

648 str(row["type_hint"]), 

649 str(row["mentions"]), 

650 ", ".join(sources_list[:_NER_DRY_RUN_PREVIEW_LIMIT]) 

651 + (", ..." if len(sources_list) > _NER_DRY_RUN_PREVIEW_LIMIT else ""), 

652 ) 

653 console.print(table) 

654 console.print( 

655 f"Dry run: [{theme.LABEL}]{len(rows)}[/{theme.LABEL}] candidate entities. " 

656 "No LLM calls were made." 

657 ) 

658 console.print(f"[{theme.MUTED}]{DRY_RUN_CONCEPT_NOTE}[/{theme.MUTED}]") 

659 

660 

661@wiki_app.command(name="update") 

662def wiki_update( 

663 data_dir: Path | None = data_dir_option, 

664 use_global: bool = global_option, 

665) -> None: 

666 """Refresh the concept and entity wiki after an ingest. 

667 

668 A full rebuild: every source is re-extracted and regenerated. The capped 

669 touched-slug regeneration only runs from the ingest hook. 

670 """ 

671 apply_overrides(data_dir=data_dir, use_global=use_global) 

672 if not cfg.wiki: 

673 _fail_wiki_disabled() 

674 _run_wiki_build("wiki_update") 

675 

676 

677drafts_app = typer.Typer(help="Review wiki drafts: list, diff, accept, reject.") 

678wiki_app.add_typer(drafts_app, name="drafts") 

679 

680 

681@drafts_app.command(name="list") 

682def wiki_drafts_list( 

683 data_dir: Path | None = data_dir_option, 

684 use_global: bool = global_option, 

685) -> None: 

686 """List pending wiki drafts with drift, faithfulness, and pairing info.""" 

687 apply_overrides(data_dir=data_dir, use_global=use_global) 

688 from lilbee.wiki.drafts import PendingKind, list_drafts 

689 

690 wiki_root = cfg.data_root / cfg.wiki_dir 

691 drafts = list_drafts(wiki_root) 

692 

693 if cfg.json_mode: 

694 json_output( 

695 { 

696 "command": "wiki_drafts_list", 

697 "drafts": [d.to_dict() for d in drafts], 

698 "total": len(drafts), 

699 } 

700 ) 

701 return 

702 

703 if not drafts: 

704 console.print("No drafts pending review.") 

705 return 

706 

707 table = Table(title="Wiki Drafts") 

708 table.add_column("Slug", style=theme.ACCENT) 

709 table.add_column("Kind", style=theme.MUTED) 

710 table.add_column("Drift") 

711 table.add_column("Faithfulness") 

712 table.add_column("Published?", style=theme.MUTED) 

713 for d in drafts: 

714 kind = d.pending_kind or PendingKind.DRIFT 

715 drift = f"{d.drift_ratio:.0%}" if d.drift_ratio is not None else "-" 

716 faith = f"{d.faithfulness_score:.2f}" if d.faithfulness_score is not None else "-" 

717 published = "yes" if d.published_exists else "no" 

718 table.add_row(d.slug, kind, drift, faith, published) 

719 console.print(table) 

720 

721 

722def _draft_slug_error() -> None: 

723 """Report a rejected (traversal) draft slug generically, without leaking paths.""" 

724 message = INVALID_DRAFT_SLUG_ERROR 

725 if cfg.json_mode: 

726 json_output({"error": message}) 

727 else: 

728 console.print(f"[{theme.ERROR}]{message}[/{theme.ERROR}]") 

729 raise typer.Exit(1) from None 

730 

731 

732@drafts_app.command(name="diff") 

733def wiki_drafts_diff( 

734 slug: str = typer.Argument(..., help="Draft slug (e.g. chevrolet)."), 

735 data_dir: Path | None = data_dir_option, 

736 use_global: bool = global_option, 

737) -> None: 

738 """Show a unified diff of the draft against its published counterpart.""" 

739 apply_overrides(data_dir=data_dir, use_global=use_global) 

740 from lilbee.wiki.drafts import diff_draft 

741 

742 wiki_root = cfg.data_root / cfg.wiki_dir 

743 try: 

744 diff = diff_draft(slug, wiki_root) 

745 except FileNotFoundError as exc: 

746 if cfg.json_mode: 

747 json_output({"error": str(exc)}) 

748 else: 

749 console.print(f"[{theme.ERROR}]{exc}[/{theme.ERROR}]") 

750 raise typer.Exit(1) from None 

751 except PathTraversalError: 

752 _draft_slug_error() 

753 

754 if cfg.json_mode: 

755 json_output({"command": "wiki_drafts_diff", "slug": slug, "diff": diff}) 

756 return 

757 console.print(diff or "(no differences)") 

758 

759 

760@drafts_app.command(name="accept") 

761def wiki_drafts_accept( 

762 slug: str = typer.Argument(..., help="Draft slug to accept."), 

763 data_dir: Path | None = data_dir_option, 

764 use_global: bool = global_option, 

765) -> None: 

766 """Overwrite the published page with the draft and re-index its chunks.""" 

767 apply_overrides(data_dir=data_dir, use_global=use_global) 

768 if not cfg.wiki: 

769 _fail_wiki_disabled() 

770 from lilbee.wiki.drafts import DraftAcceptError, accept_draft 

771 

772 wiki_root = cfg.data_root / cfg.wiki_dir 

773 try: 

774 result = accept_draft(slug, wiki_root, get_services().store) 

775 except (FileNotFoundError, DraftAcceptError) as exc: 

776 if cfg.json_mode: 

777 json_output({"error": str(exc)}) 

778 else: 

779 console.print(f"[{theme.ERROR}]{exc}[/{theme.ERROR}]") 

780 raise typer.Exit(1) from None 

781 except PathTraversalError: 

782 _draft_slug_error() 

783 

784 if cfg.json_mode: 

785 json_output({"command": "wiki_drafts_accept", **result.to_dict()}) 

786 return 

787 console.print( 

788 f"Accepted [{theme.ACCENT}]{slug}[/{theme.ACCENT}] -> " 

789 f"{result.moved_to} ({result.reindexed_chunks} chunks re-indexed)" 

790 ) 

791 

792 

793@drafts_app.command(name="reject") 

794def wiki_drafts_reject( 

795 slug: str = typer.Argument(..., help="Draft slug to reject."), 

796 data_dir: Path | None = data_dir_option, 

797 use_global: bool = global_option, 

798) -> None: 

799 """Delete the draft file. Does not touch the published page or index.""" 

800 apply_overrides(data_dir=data_dir, use_global=use_global) 

801 if not cfg.wiki: 

802 _fail_wiki_disabled() 

803 from lilbee.wiki.drafts import reject_draft 

804 

805 wiki_root = cfg.data_root / cfg.wiki_dir 

806 try: 

807 reject_draft(slug, wiki_root) 

808 except FileNotFoundError as exc: 

809 if cfg.json_mode: 

810 json_output({"error": str(exc)}) 

811 else: 

812 console.print(f"[{theme.ERROR}]{exc}[/{theme.ERROR}]") 

813 raise typer.Exit(1) from None 

814 except PathTraversalError: 

815 _draft_slug_error() 

816 

817 if cfg.json_mode: 

818 json_output({"command": "wiki_drafts_reject", "slug": slug}) 

819 return 

820 console.print(f"Rejected [{theme.ACCENT}]{slug}[/{theme.ACCENT}]")