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

414 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +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, print_prefixed 

22from lilbee.cli.tui import messages as msg 

23from lilbee.core.config import cfg 

24from lilbee.core.security import PathTraversalError 

25from lilbee.core.text import strip_control_characters 

26from lilbee.runtime.cancellation import sigint_cancel 

27from lilbee.runtime.console import PlainConsole, styled 

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

29from lilbee.wiki.shared import ( 

30 INVALID_DRAFT_SLUG_ERROR, 

31 WikiSubdir, 

32 total_wiki_pages, 

33) 

34from lilbee.wiki.stats import format_summary_line 

35 

36if TYPE_CHECKING: 

37 from collections.abc import Callable, Iterator 

38 

39 from lilbee.data.store import CitationRecord 

40 from lilbee.runtime.progress import DetailedProgressCallback, ProgressEvent 

41 from lilbee.wiki.generation import WikiEntityCandidate 

42 from lilbee.wiki.stats import BuildStatsDict 

43 

44 

45wiki_app = typer.Typer( 

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

47 no_args_is_help=True, 

48) 

49 

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

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

52# exceeds the column width. 

53_CITATION_EXCERPT_MAX_CHARS = 60 

54_CITATION_EXCERPT_TRUNCATE_AT = 57 

55 

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

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

58_NER_DRY_RUN_PREVIEW_LIMIT = 3 

59 

60 

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

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

63 if not directory.exists(): 

64 return 0 

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

66 

67 

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

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

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

71 

72 

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

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

75 

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

77 page the same way. 

78 """ 

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

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

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

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

83 return None 

84 

85 

86@contextmanager 

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

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

89 

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

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

92 stdout stays a single JSON document. 

93 """ 

94 from rich.progress import Progress, SpinnerColumn 

95 

96 from lilbee.runtime.progress.columns import literal_text_column 

97 

98 with Progress( 

99 SpinnerColumn(), 

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

101 transient=True, 

102 console=PlainConsole(stderr=True), 

103 disable=cfg.json_mode, 

104 ) as progress: 

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

106 

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

108 line = _wiki_progress_line(event_type, data) 

109 if line is not None: 

110 progress.update(task, description=line) 

111 

112 yield on_progress 

113 

114 

115@contextmanager 

116def _library_refusal_exits() -> Iterator[None]: 

117 """Exit 1 with the refusal as the error when a wiki write cannot lock the library.""" 

118 from lilbee.app.ingest import LIBRARY_REFUSALS 

119 

120 try: 

121 yield 

122 except LIBRARY_REFUSALS as exc: 

123 if cfg.json_mode: 

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

125 else: 

126 print_prefixed(console, "Error: ", exc, style=theme.ERROR) 

127 raise SystemExit(1) from None 

128 

129 

130def _fail_wiki_disabled() -> NoReturn: 

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

132 if cfg.json_mode: 

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

134 else: 

135 console.print(msg.CMD_WIKI_DISABLED) 

136 raise typer.Exit(1) 

137 

138 

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

140def wiki_lint( 

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

142 data_dir: Path | None = data_dir_option, 

143 use_global: bool = global_option, 

144) -> None: 

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

146 

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

148 """ 

149 apply_overrides(data_dir=data_dir, use_global=use_global) 

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

151 from lilbee.wiki.lint import lint_all as _lint_all 

152 

153 store = get_services().store 

154 report = ( 

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

156 ) 

157 issues = report.issues 

158 

159 if cfg.json_mode: 

160 json_output( 

161 { 

162 "command": "wiki_lint", 

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

164 "total": len(issues), 

165 "errors": report.error_count, 

166 "warnings": report.warning_count, 

167 } 

168 ) 

169 elif not issues: 

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

171 else: 

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

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

174 table.add_column("Severity") 

175 table.add_column("Message") 

176 for issue in issues: 

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

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

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

180 console.print(table) 

181 

182 if report.error_count: 

183 raise typer.Exit(1) 

184 

185 

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

187def wiki_list( 

188 data_dir: Path | None = data_dir_option, 

189 use_global: bool = global_option, 

190) -> None: 

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

192 apply_overrides(data_dir=data_dir, use_global=use_global) 

193 from lilbee.wiki.browse import list_pages 

194 

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

196 

197 if cfg.json_mode: 

198 json_output( 

199 { 

200 "command": "wiki_list", 

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

202 "total": len(pages), 

203 } 

204 ) 

205 return 

206 

207 if not pages: 

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

209 return 

210 

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

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

213 table.add_column("Title") 

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

215 table.add_column("Sources") 

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

217 for page in pages: 

218 table.add_row( 

219 Text(page.slug), 

220 Text(strip_control_characters(page.title)), 

221 page.page_type, 

222 str(page.source_count), 

223 page.created_at, 

224 ) 

225 console.print(table) 

226 

227 

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

229def wiki_read( 

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

231 data_dir: Path | None = data_dir_option, 

232 use_global: bool = global_option, 

233) -> None: 

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

235 apply_overrides(data_dir=data_dir, use_global=use_global) 

236 from lilbee.wiki.browse import read_page 

237 

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

239 if page is None: 

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

241 if cfg.json_mode: 

242 json_output({"error": message}) 

243 else: 

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

245 raise typer.Exit(1) 

246 

247 if cfg.json_mode: 

248 json_output( 

249 { 

250 "command": "wiki_read", 

251 "slug": page.slug, 

252 "title": page.title, 

253 "content": page.content, 

254 "frontmatter": page.frontmatter, 

255 } 

256 ) 

257 return 

258 console.print(strip_control_characters(page.content, keep_lines=True)) 

259 

260 

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

262def wiki_citations( 

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

264 data_dir: Path | None = data_dir_option, 

265 use_global: bool = global_option, 

266 source: str = typer.Option( 

267 "", 

268 "--source", 

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

270 ), 

271) -> None: 

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

273 apply_overrides(data_dir=data_dir, use_global=use_global) 

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

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

276 if cfg.json_mode: 

277 json_output({"error": message}) 

278 else: 

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

280 raise typer.Exit(1) 

281 

282 store = get_services().store 

283 if source: 

284 _render_citations( 

285 store.get_citations_for_source(source), 

286 key="source", 

287 value=source, 

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

289 column_header="Page", 

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

291 ) 

292 return 

293 _render_citations( 

294 store.get_citations_for_wiki(wiki_source), 

295 key="wiki_source", 

296 value=wiki_source, 

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

298 column_header="Source", 

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

300 ) 

301 

302 

303def _render_citations( 

304 records: list[CitationRecord], 

305 *, 

306 key: str, 

307 value: str, 

308 title: Text, 

309 column_header: str, 

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

311) -> None: 

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

313 

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

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

316 """ 

317 if cfg.json_mode: 

318 json_output( 

319 { 

320 "command": "wiki_citations", 

321 key: value, 

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

323 "total": len(records), 

324 } 

325 ) 

326 return 

327 

328 if not records: 

329 console.print( 

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

331 ) 

332 return 

333 

334 table = Table(title=title) 

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

336 table.add_column(column_header) 

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

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

339 for rec in records: 

340 excerpt = ( 

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

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

343 else rec["excerpt"] 

344 ) 

345 table.add_row( 

346 Text(rec["citation_key"]), 

347 Text(strip_control_characters(column_value(rec))), 

348 rec["claim_type"], 

349 Text(strip_control_characters(excerpt, keep_lines=True)), 

350 ) 

351 console.print(table) 

352 

353 

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

355def wiki_status( 

356 data_dir: Path | None = data_dir_option, 

357 use_global: bool = global_option, 

358) -> None: 

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

360 apply_overrides(data_dir=data_dir, use_global=use_global) 

361 

362 wiki_root = cfg.data_root / cfg.wiki_dir 

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

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

365 # report the disabled state rather than linting it. 

366 if cfg.json_mode: 

367 json_output( 

368 { 

369 "wiki_enabled": cfg.wiki, 

370 WikiSubdir.SUMMARIES: 0, 

371 WikiSubdir.DRAFTS: 0, 

372 "pages": 0, 

373 "lint_errors": 0, 

374 "lint_warnings": 0, 

375 } 

376 ) 

377 return 

378 if not cfg.wiki: 

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

380 else: 

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

382 return 

383 

384 summaries = _count_md_files(wiki_root / WikiSubdir.SUMMARIES) 

385 drafts = _count_md_files(wiki_root / WikiSubdir.DRAFTS) 

386 

387 from lilbee.wiki.lint import lint_all as _lint_all 

388 

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

390 with sigint_cancel() as cancel: 

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

392 

393 if cfg.json_mode: 

394 json_output( 

395 { 

396 "wiki_enabled": cfg.wiki, 

397 WikiSubdir.SUMMARIES: summaries, 

398 WikiSubdir.DRAFTS: drafts, 

399 "pages": total_wiki_pages(wiki_root), 

400 "lint_errors": report.error_count, 

401 "lint_warnings": report.warning_count, 

402 } 

403 ) 

404 return 

405 

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

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

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

409 if report.error_count or report.warning_count: 

410 console.print( 

411 styled( 

412 " Lint: ", 

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

414 ", ", 

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

416 ) 

417 ) 

418 else: 

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

420 

421 

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

423def wiki_synthesize( 

424 data_dir: Path | None = data_dir_option, 

425 use_global: bool = global_option, 

426) -> None: 

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

428 apply_overrides(data_dir=data_dir, use_global=use_global) 

429 if not cfg.wiki: 

430 _fail_wiki_disabled() 

431 from lilbee.wiki import run_full_synthesize 

432 

433 with _library_refusal_exits(), _wiki_progress() as on_progress, sigint_cancel() as cancel: 

434 result = run_full_synthesize(cfg, on_progress, cancel) 

435 

436 if cfg.json_mode: 

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

438 return 

439 

440 paths = result["paths"] 

441 if paths: 

442 console.print( 

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

444 ) 

445 for path in paths: 

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

447 else: 

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

449 _print_build_stats(result["stats"]) 

450 

451 

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

453def wiki_prune( 

454 data_dir: Path | None = data_dir_option, 

455 use_global: bool = global_option, 

456) -> None: 

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

458 apply_overrides(data_dir=data_dir, use_global=use_global) 

459 if not cfg.wiki: 

460 _fail_wiki_disabled() 

461 from lilbee.wiki.prune import prune_wiki 

462 

463 with sigint_cancel() as cancel: 

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

465 

466 if cfg.json_mode: 

467 json_output( 

468 { 

469 "command": "wiki_prune", 

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

471 "archived": report.archived_count, 

472 "flagged": report.flagged_count, 

473 "reconciled": report.reconciled_count, 

474 } 

475 ) 

476 return 

477 

478 if not report.records: 

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

480 return 

481 

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

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

484 table.add_column("Action") 

485 table.add_column("Reason") 

486 for rec in report.records: 

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

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

489 table.add_row( 

490 Text(strip_control_characters(rec.wiki_source)), 

491 action_text, 

492 Text(strip_control_characters(rec.reason)), 

493 ) 

494 console.print(table) 

495 

496 

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

498def wiki_index( 

499 data_dir: Path | None = data_dir_option, 

500 use_global: bool = global_option, 

501) -> None: 

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

503 

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

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

506 """ 

507 apply_overrides(data_dir=data_dir, use_global=use_global) 

508 if not cfg.wiki: 

509 _fail_wiki_disabled() 

510 from lilbee.wiki.stubs import refresh_stub_index 

511 

512 stubs = refresh_stub_index(get_services().store) 

513 if cfg.json_mode: 

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

515 else: 

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

517 

518 

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

520def wiki_generate( 

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

522 data_dir: Path | None = data_dir_option, 

523 use_global: bool = global_option, 

524) -> None: 

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

526 apply_overrides(data_dir=data_dir, use_global=use_global) 

527 if not cfg.wiki: 

528 _fail_wiki_disabled() 

529 from lilbee.wiki.lazy import UnknownStubError, generate_stub_page 

530 

531 try: 

532 with _library_refusal_exits(), _wiki_progress(): 

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

534 except UnknownStubError as exc: 

535 if cfg.json_mode: 

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

537 else: 

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

539 raise typer.Exit(1) from exc 

540 

541 if path is None: 

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

543 if cfg.json_mode: 

544 json_output({"error": message}) 

545 else: 

546 console.print(message, soft_wrap=True) 

547 raise typer.Exit(1) 

548 

549 if cfg.json_mode: 

550 from lilbee.wiki.browse import page_slug 

551 

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

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

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

555 else: 

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

557 

558 

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

560def wiki_wipe( 

561 data_dir: Path | None = data_dir_option, 

562 use_global: bool = global_option, 

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

564) -> None: 

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

566 

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

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

569 """ 

570 apply_overrides(data_dir=data_dir, use_global=use_global) 

571 from lilbee.wiki.wipe import wipe_wiki 

572 

573 wiki_root = cfg.data_root / cfg.wiki_dir 

574 if not yes: 

575 if cfg.json_mode: 

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

577 raise typer.Exit(1) 

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

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

580 console.print("Aborted.") 

581 raise typer.Exit(0) 

582 

583 report = wipe_wiki(get_services().store) 

584 if cfg.json_mode: 

585 json_output( 

586 { 

587 "command": "wiki_wipe", 

588 "pages_removed": report.pages_removed, 

589 "sources_cleared": report.sources_cleared, 

590 "rows_deleted": report.rows_deleted, 

591 } 

592 ) 

593 else: 

594 console.print(report.summary()) 

595 if not report.rows_deleted: 

596 raise typer.Exit(1) 

597 

598 

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

600def wiki_build( 

601 data_dir: Path | None = data_dir_option, 

602 use_global: bool = global_option, 

603 dry_run: bool = typer.Option( 

604 False, 

605 "--dry-run", 

606 help=( 

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

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

609 ), 

610 ), 

611) -> None: 

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

613 apply_overrides(data_dir=data_dir, use_global=use_global) 

614 if not cfg.wiki: 

615 _fail_wiki_disabled() 

616 

617 if dry_run: 

618 from lilbee.wiki.generation import preview_build_entities 

619 

620 _wiki_build_dry_run_output(preview_build_entities(cfg)) 

621 return 

622 

623 _run_wiki_build("wiki_build") 

624 

625 

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

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

628 from lilbee.wiki import run_full_build 

629 

630 with _library_refusal_exits(), _wiki_progress() as on_progress, sigint_cancel() as cancel: 

631 result = run_full_build(cfg, on_progress, cancel) 

632 

633 if cfg.json_mode: 

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

635 return 

636 

637 pages = result["paths"] 

638 if pages: 

639 console.print( 

640 styled( 

641 "Generated ", 

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

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

644 ) 

645 ) 

646 for path in pages: 

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

648 else: 

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

650 _print_build_stats(result["stats"]) 

651 

652 

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

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

655 

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

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

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

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

660 """ 

661 from lilbee.wiki.generation import DRY_RUN_CONCEPT_NOTE 

662 

663 if cfg.json_mode: 

664 json_output( 

665 { 

666 "command": "wiki_build", 

667 "dry_run": True, 

668 "entities": rows, 

669 "count": len(rows), 

670 "note": DRY_RUN_CONCEPT_NOTE, 

671 } 

672 ) 

673 return 

674 

675 if not rows: 

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

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

678 return 

679 

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

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

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

683 table.add_column("Type") 

684 table.add_column("Mentions") 

685 table.add_column("Sources") 

686 for row in rows: 

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

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

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

690 ) 

691 table.add_row( 

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

693 str(row["kind"]), 

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

695 str(row["mentions"]), 

696 Text(strip_control_characters(sources_preview)), 

697 ) 

698 console.print(table) 

699 console.print( 

700 styled( 

701 "Dry run: ", 

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

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

704 ) 

705 ) 

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

707 

708 

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

710def wiki_update( 

711 data_dir: Path | None = data_dir_option, 

712 use_global: bool = global_option, 

713) -> None: 

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

715 

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

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

718 """ 

719 apply_overrides(data_dir=data_dir, use_global=use_global) 

720 if not cfg.wiki: 

721 _fail_wiki_disabled() 

722 _run_wiki_build("wiki_update") 

723 

724 

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

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

727 

728 

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

730def wiki_drafts_list( 

731 data_dir: Path | None = data_dir_option, 

732 use_global: bool = global_option, 

733) -> None: 

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

735 apply_overrides(data_dir=data_dir, use_global=use_global) 

736 from lilbee.wiki.drafts import PendingKind, list_drafts 

737 

738 wiki_root = cfg.data_root / cfg.wiki_dir 

739 drafts = list_drafts(wiki_root) 

740 

741 if cfg.json_mode: 

742 json_output( 

743 { 

744 "command": "wiki_drafts_list", 

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

746 "total": len(drafts), 

747 } 

748 ) 

749 return 

750 

751 if not drafts: 

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

753 return 

754 

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

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

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

758 table.add_column("Drift") 

759 table.add_column("Faithfulness") 

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

761 for d in drafts: 

762 kind = d.pending_kind or PendingKind.DRIFT 

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

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

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

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

767 console.print(table) 

768 

769 

770def _draft_slug_error() -> None: 

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

772 message = INVALID_DRAFT_SLUG_ERROR 

773 if cfg.json_mode: 

774 json_output({"error": message}) 

775 else: 

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

777 raise typer.Exit(1) from None 

778 

779 

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

781def wiki_drafts_diff( 

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

783 data_dir: Path | None = data_dir_option, 

784 use_global: bool = global_option, 

785) -> None: 

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

787 apply_overrides(data_dir=data_dir, use_global=use_global) 

788 from lilbee.wiki.drafts import diff_draft 

789 

790 wiki_root = cfg.data_root / cfg.wiki_dir 

791 try: 

792 diff = diff_draft(slug, wiki_root, get_services().store) 

793 except FileNotFoundError as exc: 

794 if cfg.json_mode: 

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

796 else: 

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

798 raise typer.Exit(1) from None 

799 except PathTraversalError: 

800 _draft_slug_error() 

801 

802 if cfg.json_mode: 

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

804 return 

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

806 

807 

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

809def wiki_drafts_accept( 

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

811 data_dir: Path | None = data_dir_option, 

812 use_global: bool = global_option, 

813) -> None: 

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

815 apply_overrides(data_dir=data_dir, use_global=use_global) 

816 if not cfg.wiki: 

817 _fail_wiki_disabled() 

818 from lilbee.wiki.drafts import DraftAcceptError, accept_draft 

819 

820 wiki_root = cfg.data_root / cfg.wiki_dir 

821 try: 

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

823 except (FileNotFoundError, DraftAcceptError) as exc: 

824 if cfg.json_mode: 

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

826 else: 

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

828 raise typer.Exit(1) from None 

829 except PathTraversalError: 

830 _draft_slug_error() 

831 

832 if cfg.json_mode: 

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

834 return 

835 console.print( 

836 Text.assemble( 

837 "Accepted ", 

838 (slug, theme.ACCENT), 

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

840 ), 

841 soft_wrap=True, 

842 ) 

843 

844 

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

846def wiki_drafts_reject( 

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

848 data_dir: Path | None = data_dir_option, 

849 use_global: bool = global_option, 

850) -> None: 

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

852 apply_overrides(data_dir=data_dir, use_global=use_global) 

853 if not cfg.wiki: 

854 _fail_wiki_disabled() 

855 from lilbee.wiki.drafts import reject_draft 

856 

857 wiki_root = cfg.data_root / cfg.wiki_dir 

858 try: 

859 reject_draft(slug, wiki_root) 

860 except FileNotFoundError as exc: 

861 if cfg.json_mode: 

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

863 else: 

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

865 raise typer.Exit(1) from None 

866 except PathTraversalError: 

867 _draft_slug_error() 

868 

869 if cfg.json_mode: 

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

871 return 

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