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
« 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."""
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
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
32if TYPE_CHECKING:
33 from collections.abc import Callable, Iterator
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
41wiki_app = typer.Typer(
42 help="Wiki layer commands: generate, browse, lint, citations, status, prune."
43)
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
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
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")))
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)}")
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.
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
81@contextmanager
82def _wiki_progress() -> Iterator[DetailedProgressCallback]:
83 """Render a wiki run's phase and page events on a spinner line.
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
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)
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)
106 yield on_progress
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)
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.
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
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
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)
161 if report.error_count:
162 raise typer.Exit(1)
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
174 pages = list_pages(cfg.data_root / cfg.wiki_dir)
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
186 if not pages:
187 console.print("No wiki pages found.")
188 return
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)
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
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)
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)
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)
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 )
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.
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
303 if not records:
304 console.print(f"No citations found for [{theme.ACCENT}]{value}[/{theme.ACCENT}]")
305 return
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)
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)
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
352 summaries = _count_md_files(wiki_root / WikiSubdir.SUMMARIES)
353 drafts = _count_md_files(wiki_root / WikiSubdir.DRAFTS)
355 from lilbee.wiki.lint import lint_all as _lint_all
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)
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
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")
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
397 with _wiki_progress() as on_progress, sigint_cancel() as cancel:
398 result = run_full_synthesize(cfg, on_progress, cancel)
400 if cfg.json_mode:
401 json_output({"command": "wiki_synthesize", **result})
402 return
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"])
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
427 with sigint_cancel() as cancel:
428 report = prune_wiki(get_services().store, cancel=cancel)
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
442 if not report.records:
443 console.print("No pages pruned.")
444 return
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)
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.
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
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")
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
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
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)
509 if cfg.json_mode:
510 from lilbee.wiki.browse import page_slug
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}")
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.
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
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)
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)
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()
577 if dry_run:
578 from lilbee.wiki.generation import preview_build_entities
580 _wiki_build_dry_run_output(preview_build_entities(cfg))
581 return
583 _run_wiki_build("wiki_build")
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
590 with _wiki_progress() as on_progress, sigint_cancel() as cancel:
591 result = run_full_build(cfg, on_progress, cancel)
593 if cfg.json_mode:
594 json_output({"command": command_name, **result})
595 return
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"])
610def _wiki_build_dry_run_output(rows: list[WikiEntityCandidate]) -> None:
611 """Render the extraction result as JSON or table without calling any LLM.
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
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
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
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}]")
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.
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")
677drafts_app = typer.Typer(help="Review wiki drafts: list, diff, accept, reject.")
678wiki_app.add_typer(drafts_app, name="drafts")
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
690 wiki_root = cfg.data_root / cfg.wiki_dir
691 drafts = list_drafts(wiki_root)
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
703 if not drafts:
704 console.print("No drafts pending review.")
705 return
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)
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
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
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()
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)")
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
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()
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 )
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
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()
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}]")