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

402 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +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 

11from rich.text import Text 

12 

13from lilbee.app.services import get_services 

14from lilbee.cli import theme 

15from lilbee.cli.app import ( 

16 apply_overrides, 

17 console, 

18 data_dir_option, 

19 global_option, 

20) 

21from lilbee.cli.helpers import json_output, sigint_cancel 

22from lilbee.cli.tui import messages as msg 

23from lilbee.core.config import cfg 

24from lilbee.core.security import PathTraversalError 

25from lilbee.runtime.console import PlainConsole, styled 

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

27from lilbee.wiki.shared import ( 

28 INVALID_DRAFT_SLUG_ERROR, 

29 WikiSubdir, 

30 total_wiki_pages, 

31) 

32from lilbee.wiki.stats import format_summary_line 

33 

34if TYPE_CHECKING: 

35 from collections.abc import Callable, Iterator 

36 

37 from lilbee.data.store import CitationRecord 

38 from lilbee.runtime.progress import DetailedProgressCallback, ProgressEvent 

39 from lilbee.wiki.generation import WikiEntityCandidate 

40 from lilbee.wiki.stats import BuildStatsDict 

41 

42 

43wiki_app = typer.Typer( 

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

45) 

46 

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

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

49# exceeds the column width. 

50_CITATION_EXCERPT_MAX_CHARS = 60 

51_CITATION_EXCERPT_TRUNCATE_AT = 57 

52 

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

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

55_NER_DRY_RUN_PREVIEW_LIMIT = 3 

56 

57 

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

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

60 if not directory.exists(): 

61 return 0 

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

63 

64 

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

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

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

68 

69 

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

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

72 

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

74 page the same way. 

75 """ 

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

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

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

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

80 return None 

81 

82 

83@contextmanager 

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

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

86 

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

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

89 stdout stays a single JSON document. 

90 """ 

91 from rich.progress import Progress, SpinnerColumn 

92 

93 from lilbee.runtime.progress.columns import literal_text_column 

94 

95 with Progress( 

96 SpinnerColumn(), 

97 literal_text_column("{task.description}"), 

98 transient=True, 

99 console=PlainConsole(stderr=True), 

100 disable=cfg.json_mode, 

101 ) as progress: 

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

103 

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

105 line = _wiki_progress_line(event_type, data) 

106 if line is not None: 

107 progress.update(task, description=line) 

108 

109 yield on_progress 

110 

111 

112def _fail_wiki_disabled() -> NoReturn: 

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

114 if cfg.json_mode: 

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

116 else: 

117 console.print(msg.CMD_WIKI_DISABLED) 

118 raise typer.Exit(1) 

119 

120 

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

122def wiki_lint( 

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

124 data_dir: Path | None = data_dir_option, 

125 use_global: bool = global_option, 

126) -> None: 

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

128 

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

130 """ 

131 apply_overrides(data_dir=data_dir, use_global=use_global) 

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

133 from lilbee.wiki.lint import lint_all as _lint_all 

134 

135 store = get_services().store 

136 report = ( 

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

138 ) 

139 issues = report.issues 

140 

141 if cfg.json_mode: 

142 json_output( 

143 { 

144 "command": "wiki_lint", 

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

146 "total": len(issues), 

147 "errors": report.error_count, 

148 "warnings": report.warning_count, 

149 } 

150 ) 

151 elif not issues: 

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

153 else: 

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

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

156 table.add_column("Severity") 

157 table.add_column("Message") 

158 for issue in issues: 

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

160 sev_text = Text.assemble((issue.severity.value, sev_style)) 

161 table.add_row(Text(issue.wiki_source), sev_text, Text(issue.message)) 

162 console.print(table) 

163 

164 if report.error_count: 

165 raise typer.Exit(1) 

166 

167 

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

169def wiki_list( 

170 data_dir: Path | None = data_dir_option, 

171 use_global: bool = global_option, 

172) -> None: 

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

174 apply_overrides(data_dir=data_dir, use_global=use_global) 

175 from lilbee.wiki.browse import list_pages 

176 

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

178 

179 if cfg.json_mode: 

180 json_output( 

181 { 

182 "command": "wiki_list", 

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

184 "total": len(pages), 

185 } 

186 ) 

187 return 

188 

189 if not pages: 

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

191 return 

192 

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

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

195 table.add_column("Title") 

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

197 table.add_column("Sources") 

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

199 for page in pages: 

200 table.add_row( 

201 Text(page.slug), 

202 Text(page.title), 

203 page.page_type, 

204 str(page.source_count), 

205 page.created_at, 

206 ) 

207 console.print(table) 

208 

209 

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

211def wiki_read( 

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

213 data_dir: Path | None = data_dir_option, 

214 use_global: bool = global_option, 

215) -> None: 

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

217 apply_overrides(data_dir=data_dir, use_global=use_global) 

218 from lilbee.wiki.browse import read_page 

219 

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

221 if page is None: 

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

223 if cfg.json_mode: 

224 json_output({"error": message}) 

225 else: 

226 console.print(Text(message, style=theme.ERROR), soft_wrap=True) 

227 raise typer.Exit(1) 

228 

229 if cfg.json_mode: 

230 json_output( 

231 { 

232 "command": "wiki_read", 

233 "slug": page.slug, 

234 "title": page.title, 

235 "content": page.content, 

236 "frontmatter": page.frontmatter, 

237 } 

238 ) 

239 return 

240 console.print(page.content) 

241 

242 

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

244def wiki_citations( 

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

246 data_dir: Path | None = data_dir_option, 

247 use_global: bool = global_option, 

248 source: str = typer.Option( 

249 "", 

250 "--source", 

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

252 ), 

253) -> None: 

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

255 apply_overrides(data_dir=data_dir, use_global=use_global) 

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

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

258 if cfg.json_mode: 

259 json_output({"error": message}) 

260 else: 

261 console.print(message, style=theme.ERROR) 

262 raise typer.Exit(1) 

263 

264 store = get_services().store 

265 if source: 

266 _render_citations( 

267 store.get_citations_for_source(source), 

268 key="source", 

269 value=source, 

270 title=Text(f"Pages citing: {source}"), 

271 column_header="Page", 

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

273 ) 

274 return 

275 _render_citations( 

276 store.get_citations_for_wiki(wiki_source), 

277 key="wiki_source", 

278 value=wiki_source, 

279 title=Text(f"Citations: {wiki_source}"), 

280 column_header="Source", 

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

282 ) 

283 

284 

285def _render_citations( 

286 records: list[CitationRecord], 

287 *, 

288 key: str, 

289 value: str, 

290 title: Text, 

291 column_header: str, 

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

293) -> None: 

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

295 

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

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

298 """ 

299 if cfg.json_mode: 

300 json_output( 

301 { 

302 "command": "wiki_citations", 

303 key: value, 

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

305 "total": len(records), 

306 } 

307 ) 

308 return 

309 

310 if not records: 

311 console.print( 

312 Text.assemble("No citations found for ", (value, theme.ACCENT)), soft_wrap=True 

313 ) 

314 return 

315 

316 table = Table(title=title) 

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

318 table.add_column(column_header) 

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

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

321 for rec in records: 

322 excerpt = ( 

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

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

325 else rec["excerpt"] 

326 ) 

327 table.add_row( 

328 Text(rec["citation_key"]), Text(column_value(rec)), rec["claim_type"], Text(excerpt) 

329 ) 

330 console.print(table) 

331 

332 

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

334def wiki_status( 

335 data_dir: Path | None = data_dir_option, 

336 use_global: bool = global_option, 

337) -> None: 

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

339 apply_overrides(data_dir=data_dir, use_global=use_global) 

340 

341 wiki_root = cfg.data_root / cfg.wiki_dir 

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

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

344 # report the disabled state rather than linting it. 

345 if cfg.json_mode: 

346 json_output( 

347 { 

348 "wiki_enabled": cfg.wiki, 

349 WikiSubdir.SUMMARIES: 0, 

350 WikiSubdir.DRAFTS: 0, 

351 "pages": 0, 

352 "lint_errors": 0, 

353 "lint_warnings": 0, 

354 } 

355 ) 

356 return 

357 if not cfg.wiki: 

358 console.print(styled("Wiki: ", ("disabled", theme.ERROR))) 

359 else: 

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

361 return 

362 

363 summaries = _count_md_files(wiki_root / WikiSubdir.SUMMARIES) 

364 drafts = _count_md_files(wiki_root / WikiSubdir.DRAFTS) 

365 

366 from lilbee.wiki.lint import lint_all as _lint_all 

367 

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

369 with sigint_cancel() as cancel: 

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

371 

372 if cfg.json_mode: 

373 json_output( 

374 { 

375 "wiki_enabled": cfg.wiki, 

376 WikiSubdir.SUMMARIES: summaries, 

377 WikiSubdir.DRAFTS: drafts, 

378 "pages": total_wiki_pages(wiki_root), 

379 "lint_errors": report.error_count, 

380 "lint_warnings": report.warning_count, 

381 } 

382 ) 

383 return 

384 

385 console.print(styled("Wiki: ", ("enabled", theme.SUCCESS))) 

386 console.print(styled(" Summaries: ", (str(summaries), theme.LABEL))) 

387 console.print(styled(" Drafts: ", (str(drafts), theme.LABEL))) 

388 if report.error_count or report.warning_count: 

389 console.print( 

390 styled( 

391 " Lint: ", 

392 (f"{report.error_count} error(s)", theme.ERROR), 

393 ", ", 

394 (f"{report.warning_count} warning(s)", theme.WARNING), 

395 ) 

396 ) 

397 else: 

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

399 

400 

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

402def wiki_synthesize( 

403 data_dir: Path | None = data_dir_option, 

404 use_global: bool = global_option, 

405) -> None: 

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

407 apply_overrides(data_dir=data_dir, use_global=use_global) 

408 if not cfg.wiki: 

409 _fail_wiki_disabled() 

410 from lilbee.wiki import run_full_synthesize 

411 

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

413 result = run_full_synthesize(cfg, on_progress, cancel) 

414 

415 if cfg.json_mode: 

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

417 return 

418 

419 paths = result["paths"] 

420 if paths: 

421 console.print( 

422 styled("Generated ", (str(result["count"]), theme.LABEL), " synthesis pages:") 

423 ) 

424 for path in paths: 

425 console.print(Text.assemble(" ", str(path)), soft_wrap=True) 

426 else: 

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

428 _print_build_stats(result["stats"]) 

429 

430 

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

432def wiki_prune( 

433 data_dir: Path | None = data_dir_option, 

434 use_global: bool = global_option, 

435) -> None: 

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

437 apply_overrides(data_dir=data_dir, use_global=use_global) 

438 if not cfg.wiki: 

439 _fail_wiki_disabled() 

440 from lilbee.wiki.prune import prune_wiki 

441 

442 with sigint_cancel() as cancel: 

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

444 

445 if cfg.json_mode: 

446 json_output( 

447 { 

448 "command": "wiki_prune", 

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

450 "archived": report.archived_count, 

451 "flagged": report.flagged_count, 

452 "reconciled": report.reconciled_count, 

453 } 

454 ) 

455 return 

456 

457 if not report.records: 

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

459 return 

460 

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

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

463 table.add_column("Action") 

464 table.add_column("Reason") 

465 for rec in report.records: 

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

467 action_text = Text.assemble((rec.action.value, action_style)) 

468 table.add_row(Text(rec.wiki_source), action_text, Text(rec.reason)) 

469 console.print(table) 

470 

471 

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

473def wiki_index( 

474 data_dir: Path | None = data_dir_option, 

475 use_global: bool = global_option, 

476) -> None: 

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

478 

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

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

481 """ 

482 apply_overrides(data_dir=data_dir, use_global=use_global) 

483 if not cfg.wiki: 

484 _fail_wiki_disabled() 

485 from lilbee.wiki.stubs import refresh_stub_index 

486 

487 stubs = refresh_stub_index(get_services().store) 

488 if cfg.json_mode: 

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

490 else: 

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

492 

493 

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

495def wiki_generate( 

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

497 data_dir: Path | None = data_dir_option, 

498 use_global: bool = global_option, 

499) -> None: 

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

501 apply_overrides(data_dir=data_dir, use_global=use_global) 

502 if not cfg.wiki: 

503 _fail_wiki_disabled() 

504 from lilbee.wiki.lazy import UnknownStubError, generate_stub_page 

505 

506 try: 

507 with _wiki_progress(): 

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

509 except UnknownStubError as exc: 

510 if cfg.json_mode: 

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

512 else: 

513 console.print(str(exc), soft_wrap=True) 

514 raise typer.Exit(1) from exc 

515 

516 if path is None: 

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

518 if cfg.json_mode: 

519 json_output({"error": message}) 

520 else: 

521 console.print(message, soft_wrap=True) 

522 raise typer.Exit(1) 

523 

524 if cfg.json_mode: 

525 from lilbee.wiki.browse import page_slug 

526 

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

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

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

530 else: 

531 console.print(Text.assemble("Wrote ", str(path)), soft_wrap=True) 

532 

533 

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

535def wiki_wipe( 

536 data_dir: Path | None = data_dir_option, 

537 use_global: bool = global_option, 

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

539) -> None: 

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

541 

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

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

544 """ 

545 apply_overrides(data_dir=data_dir, use_global=use_global) 

546 from lilbee.wiki.wipe import wipe_wiki 

547 

548 wiki_root = cfg.data_root / cfg.wiki_dir 

549 if not yes: 

550 if cfg.json_mode: 

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

552 raise typer.Exit(1) 

553 console.print(msg.CMD_WIKI_WIPE_WARNING.format(path=wiki_root), soft_wrap=True) 

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

555 console.print("Aborted.") 

556 raise typer.Exit(0) 

557 

558 report = wipe_wiki(get_services().store) 

559 if cfg.json_mode: 

560 json_output( 

561 { 

562 "command": "wiki_wipe", 

563 "pages_removed": report.pages_removed, 

564 "sources_cleared": report.sources_cleared, 

565 "rows_deleted": report.rows_deleted, 

566 } 

567 ) 

568 else: 

569 console.print(report.summary()) 

570 if not report.rows_deleted: 

571 raise typer.Exit(1) 

572 

573 

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

575def wiki_build( 

576 data_dir: Path | None = data_dir_option, 

577 use_global: bool = global_option, 

578 dry_run: bool = typer.Option( 

579 False, 

580 "--dry-run", 

581 help=( 

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

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

584 ), 

585 ), 

586) -> None: 

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

588 apply_overrides(data_dir=data_dir, use_global=use_global) 

589 if not cfg.wiki: 

590 _fail_wiki_disabled() 

591 

592 if dry_run: 

593 from lilbee.wiki.generation import preview_build_entities 

594 

595 _wiki_build_dry_run_output(preview_build_entities(cfg)) 

596 return 

597 

598 _run_wiki_build("wiki_build") 

599 

600 

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

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

603 from lilbee.wiki import run_full_build 

604 

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

606 result = run_full_build(cfg, on_progress, cancel) 

607 

608 if cfg.json_mode: 

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

610 return 

611 

612 pages = result["paths"] 

613 if pages: 

614 console.print( 

615 styled( 

616 "Generated ", 

617 (str(result["count"]), theme.LABEL), 

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

619 ) 

620 ) 

621 for path in pages: 

622 console.print(Text.assemble(" ", str(path)), soft_wrap=True) 

623 else: 

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

625 _print_build_stats(result["stats"]) 

626 

627 

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

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

630 

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

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

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

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

635 """ 

636 from lilbee.wiki.generation import DRY_RUN_CONCEPT_NOTE 

637 

638 if cfg.json_mode: 

639 json_output( 

640 { 

641 "command": "wiki_build", 

642 "dry_run": True, 

643 "entities": rows, 

644 "count": len(rows), 

645 "note": DRY_RUN_CONCEPT_NOTE, 

646 } 

647 ) 

648 return 

649 

650 if not rows: 

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

652 console.print(DRY_RUN_CONCEPT_NOTE, style=theme.MUTED) 

653 return 

654 

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

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

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

658 table.add_column("Type") 

659 table.add_column("Mentions") 

660 table.add_column("Sources") 

661 for row in rows: 

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

663 sources_preview = ", ".join(sources_list[:_NER_DRY_RUN_PREVIEW_LIMIT]) + ( 

664 ", ..." if len(sources_list) > _NER_DRY_RUN_PREVIEW_LIMIT else "" 

665 ) 

666 table.add_row( 

667 Text(str(row["slug"])), 

668 str(row["kind"]), 

669 Text(str(row["type_hint"])), 

670 str(row["mentions"]), 

671 Text(sources_preview), 

672 ) 

673 console.print(table) 

674 console.print( 

675 styled( 

676 "Dry run: ", 

677 (str(len(rows)), theme.LABEL), 

678 " candidate entities. No LLM calls were made.", 

679 ) 

680 ) 

681 console.print(DRY_RUN_CONCEPT_NOTE, style=theme.MUTED) 

682 

683 

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

685def wiki_update( 

686 data_dir: Path | None = data_dir_option, 

687 use_global: bool = global_option, 

688) -> None: 

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

690 

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

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

693 """ 

694 apply_overrides(data_dir=data_dir, use_global=use_global) 

695 if not cfg.wiki: 

696 _fail_wiki_disabled() 

697 _run_wiki_build("wiki_update") 

698 

699 

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

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

702 

703 

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

705def wiki_drafts_list( 

706 data_dir: Path | None = data_dir_option, 

707 use_global: bool = global_option, 

708) -> None: 

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

710 apply_overrides(data_dir=data_dir, use_global=use_global) 

711 from lilbee.wiki.drafts import PendingKind, list_drafts 

712 

713 wiki_root = cfg.data_root / cfg.wiki_dir 

714 drafts = list_drafts(wiki_root) 

715 

716 if cfg.json_mode: 

717 json_output( 

718 { 

719 "command": "wiki_drafts_list", 

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

721 "total": len(drafts), 

722 } 

723 ) 

724 return 

725 

726 if not drafts: 

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

728 return 

729 

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

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

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

733 table.add_column("Drift") 

734 table.add_column("Faithfulness") 

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

736 for d in drafts: 

737 kind = d.pending_kind or PendingKind.DRIFT 

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

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

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

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

742 console.print(table) 

743 

744 

745def _draft_slug_error() -> None: 

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

747 message = INVALID_DRAFT_SLUG_ERROR 

748 if cfg.json_mode: 

749 json_output({"error": message}) 

750 else: 

751 console.print(message, style=theme.ERROR) 

752 raise typer.Exit(1) from None 

753 

754 

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

756def wiki_drafts_diff( 

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

758 data_dir: Path | None = data_dir_option, 

759 use_global: bool = global_option, 

760) -> None: 

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

762 apply_overrides(data_dir=data_dir, use_global=use_global) 

763 from lilbee.wiki.drafts import diff_draft 

764 

765 wiki_root = cfg.data_root / cfg.wiki_dir 

766 try: 

767 diff = diff_draft(slug, wiki_root) 

768 except FileNotFoundError as exc: 

769 if cfg.json_mode: 

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

771 else: 

772 console.print(Text(str(exc), style=theme.ERROR), soft_wrap=True) 

773 raise typer.Exit(1) from None 

774 except PathTraversalError: 

775 _draft_slug_error() 

776 

777 if cfg.json_mode: 

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

779 return 

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

781 

782 

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

784def wiki_drafts_accept( 

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

786 data_dir: Path | None = data_dir_option, 

787 use_global: bool = global_option, 

788) -> None: 

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

790 apply_overrides(data_dir=data_dir, use_global=use_global) 

791 if not cfg.wiki: 

792 _fail_wiki_disabled() 

793 from lilbee.wiki.drafts import DraftAcceptError, accept_draft 

794 

795 wiki_root = cfg.data_root / cfg.wiki_dir 

796 try: 

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

798 except (FileNotFoundError, DraftAcceptError) as exc: 

799 if cfg.json_mode: 

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

801 else: 

802 console.print(Text(str(exc), style=theme.ERROR), soft_wrap=True) 

803 raise typer.Exit(1) from None 

804 except PathTraversalError: 

805 _draft_slug_error() 

806 

807 if cfg.json_mode: 

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

809 return 

810 console.print( 

811 Text.assemble( 

812 "Accepted ", 

813 (slug, theme.ACCENT), 

814 f" -> {result.moved_to} ({result.reindexed_chunks} chunks re-indexed)", 

815 ), 

816 soft_wrap=True, 

817 ) 

818 

819 

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

821def wiki_drafts_reject( 

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

823 data_dir: Path | None = data_dir_option, 

824 use_global: bool = global_option, 

825) -> None: 

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

827 apply_overrides(data_dir=data_dir, use_global=use_global) 

828 if not cfg.wiki: 

829 _fail_wiki_disabled() 

830 from lilbee.wiki.drafts import reject_draft 

831 

832 wiki_root = cfg.data_root / cfg.wiki_dir 

833 try: 

834 reject_draft(slug, wiki_root) 

835 except FileNotFoundError as exc: 

836 if cfg.json_mode: 

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

838 else: 

839 console.print(Text(str(exc), style=theme.ERROR), soft_wrap=True) 

840 raise typer.Exit(1) from None 

841 except PathTraversalError: 

842 _draft_slug_error() 

843 

844 if cfg.json_mode: 

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

846 return 

847 console.print(Text.assemble("Rejected ", (slug, theme.ACCENT)), soft_wrap=True)