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
« 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."""
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, 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
34if TYPE_CHECKING:
35 from collections.abc import Callable, Iterator
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
43wiki_app = typer.Typer(
44 help="Wiki layer commands: generate, browse, lint, citations, status, prune."
45)
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
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
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")))
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)}")
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.
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
83@contextmanager
84def _wiki_progress() -> Iterator[DetailedProgressCallback]:
85 """Render a wiki run's phase and page events on a spinner line.
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
93 from lilbee.runtime.progress.columns import literal_text_column
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)
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)
109 yield on_progress
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)
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.
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
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
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)
164 if report.error_count:
165 raise typer.Exit(1)
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
177 pages = list_pages(cfg.data_root / cfg.wiki_dir)
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
189 if not pages:
190 console.print("No wiki pages found.")
191 return
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)
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
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)
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)
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)
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 )
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.
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
310 if not records:
311 console.print(
312 Text.assemble("No citations found for ", (value, theme.ACCENT)), soft_wrap=True
313 )
314 return
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)
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)
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
363 summaries = _count_md_files(wiki_root / WikiSubdir.SUMMARIES)
364 drafts = _count_md_files(wiki_root / WikiSubdir.DRAFTS)
366 from lilbee.wiki.lint import lint_all as _lint_all
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)
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
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")
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
412 with _wiki_progress() as on_progress, sigint_cancel() as cancel:
413 result = run_full_synthesize(cfg, on_progress, cancel)
415 if cfg.json_mode:
416 json_output({"command": "wiki_synthesize", **result})
417 return
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"])
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
442 with sigint_cancel() as cancel:
443 report = prune_wiki(get_services().store, cancel=cancel)
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
457 if not report.records:
458 console.print("No pages pruned.")
459 return
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)
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.
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
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")
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
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
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)
524 if cfg.json_mode:
525 from lilbee.wiki.browse import page_slug
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)
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.
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
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)
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)
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()
592 if dry_run:
593 from lilbee.wiki.generation import preview_build_entities
595 _wiki_build_dry_run_output(preview_build_entities(cfg))
596 return
598 _run_wiki_build("wiki_build")
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
605 with _wiki_progress() as on_progress, sigint_cancel() as cancel:
606 result = run_full_build(cfg, on_progress, cancel)
608 if cfg.json_mode:
609 json_output({"command": command_name, **result})
610 return
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"])
628def _wiki_build_dry_run_output(rows: list[WikiEntityCandidate]) -> None:
629 """Render the extraction result as JSON or table without calling any LLM.
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
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
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
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)
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.
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")
700drafts_app = typer.Typer(help="Review wiki drafts: list, diff, accept, reject.")
701wiki_app.add_typer(drafts_app, name="drafts")
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
713 wiki_root = cfg.data_root / cfg.wiki_dir
714 drafts = list_drafts(wiki_root)
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
726 if not drafts:
727 console.print("No drafts pending review.")
728 return
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)
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
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
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()
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)")
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
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()
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 )
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
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()
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)