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
« 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."""
3from __future__ import annotations
5from contextlib import contextmanager
6from pathlib import Path
7from typing import TYPE_CHECKING, NoReturn
9import typer
10from rich.table import Table
11from rich.text import Text
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
36if TYPE_CHECKING:
37 from collections.abc import Callable, Iterator
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
45wiki_app = typer.Typer(
46 help="Wiki layer commands: generate, browse, lint, citations, status, prune.",
47 no_args_is_help=True,
48)
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
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
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")))
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)}")
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.
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
86@contextmanager
87def _wiki_progress() -> Iterator[DetailedProgressCallback]:
88 """Render a wiki run's phase and page events on a spinner line.
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
96 from lilbee.runtime.progress.columns import literal_text_column
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)
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)
112 yield on_progress
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
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
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)
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.
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
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
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)
182 if report.error_count:
183 raise typer.Exit(1)
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
195 pages = list_pages(cfg.data_root / cfg.wiki_dir)
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
207 if not pages:
208 console.print("No wiki pages found.")
209 return
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)
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
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)
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))
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)
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 )
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.
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
328 if not records:
329 console.print(
330 Text.assemble("No citations found for ", (value, theme.ACCENT)), soft_wrap=True
331 )
332 return
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)
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)
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
384 summaries = _count_md_files(wiki_root / WikiSubdir.SUMMARIES)
385 drafts = _count_md_files(wiki_root / WikiSubdir.DRAFTS)
387 from lilbee.wiki.lint import lint_all as _lint_all
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)
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
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")
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
433 with _library_refusal_exits(), _wiki_progress() as on_progress, sigint_cancel() as cancel:
434 result = run_full_synthesize(cfg, on_progress, cancel)
436 if cfg.json_mode:
437 json_output({"command": "wiki_synthesize", **result})
438 return
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"])
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
463 with sigint_cancel() as cancel:
464 report = prune_wiki(get_services().store, cancel=cancel)
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
478 if not report.records:
479 console.print("No pages pruned.")
480 return
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)
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.
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
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")
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
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
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)
549 if cfg.json_mode:
550 from lilbee.wiki.browse import page_slug
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)
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.
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
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)
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)
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()
617 if dry_run:
618 from lilbee.wiki.generation import preview_build_entities
620 _wiki_build_dry_run_output(preview_build_entities(cfg))
621 return
623 _run_wiki_build("wiki_build")
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
630 with _library_refusal_exits(), _wiki_progress() as on_progress, sigint_cancel() as cancel:
631 result = run_full_build(cfg, on_progress, cancel)
633 if cfg.json_mode:
634 json_output({"command": command_name, **result})
635 return
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"])
653def _wiki_build_dry_run_output(rows: list[WikiEntityCandidate]) -> None:
654 """Render the extraction result as JSON or table without calling any LLM.
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
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
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
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)
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.
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")
725drafts_app = typer.Typer(help="Review wiki drafts: list, diff, accept, reject.")
726wiki_app.add_typer(drafts_app, name="drafts")
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
738 wiki_root = cfg.data_root / cfg.wiki_dir
739 drafts = list_drafts(wiki_root)
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
751 if not drafts:
752 console.print("No drafts pending review.")
753 return
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)
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
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
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()
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)")
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
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()
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 )
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
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()
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)