Coverage for src/lilbee/cli/helpers.py: 100%
160 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
1"""CLI-specific helpers: JSON formatter, Rich rendering, and CLI workflows."""
3from __future__ import annotations
5import asyncio
6import json
7import signal
8import threading
9from collections.abc import Callable, Generator, Iterator
10from contextlib import contextmanager
11from pathlib import Path
12from typing import TYPE_CHECKING
14from rich.console import Console, RenderableType
15from rich.markup import escape
16from rich.table import Table
18from lilbee.app.ingest import RegisterResult, register_sources
19from lilbee.app.status import StatusResult
20from lilbee.cli import theme
21from lilbee.core.config import cfg
23if TYPE_CHECKING:
24 from lilbee.cli.sync import SyncStatus
27def json_output(data: dict) -> None:
28 """Print a JSON object to stdout."""
29 print(json.dumps(data))
32def announce_cold_start(role: object, model: str) -> Console | None:
33 """Print a "Starting <role> engine (loading <model>)..." stderr line if cold.
35 Returns a stderr console to print the matching "ready" line through when the
36 blocking call returns, or ``None`` when the role's server is already warm (no
37 status needed) or output is JSON (machine-readable, no chatter). The role
38 parameter is a ``WorkerRole``; typed as ``object`` to keep this CLI helper
39 free of a provider-layer import at module top.
40 """
41 from lilbee.app.services import get_services
42 from lilbee.providers.roles import WorkerRole
44 if cfg.json_mode or not isinstance(role, WorkerRole):
45 return None
46 if get_services().provider.role_ready(role):
47 return None
48 err = Console(stderr=True)
49 err.print(f"[{theme.MUTED}]Starting {role.value} engine (loading {model})...[/{theme.MUTED}]")
50 return err
53def announce_ready(err: Console | None, role: object) -> None:
54 """Print the matching "<role> engine ready." stderr line, if cold-start announced.
56 A token arriving is not evidence the chat model came up: in RAG mode a grounded
57 refusal streams without it. When warm-up recorded a load failure, that reason is
58 printed instead of a readiness line.
59 """
60 from lilbee.providers.roles import WorkerRole
62 if err is None or not isinstance(role, WorkerRole):
63 return
64 failure = _chat_warm_error(role)
65 if failure is not None:
66 err.print(f"[{theme.ERROR}]{failure}[/{theme.ERROR}]")
67 return
68 err.print(f"[{theme.MUTED}]{role.value} engine ready.[/{theme.MUTED}]")
71def announce_retrieval_query(query: str) -> None:
72 """Print the "Searching for: <query>" stderr line for a rewritten follow-up."""
73 line = SEARCHING_FOR.format(query=escape(query))
74 Console(stderr=True).print(f"[{theme.MUTED}]{line}[/{theme.MUTED}]")
77def _chat_warm_error(role: object) -> str | None:
78 """The chat warm-up's recorded failure, or None when it did not fail.
80 Read from the warm tracker rather than re-probing readiness: llama-swap can
81 report a freshly loaded model as not-yet-running, which would turn a healthy
82 engine into a spurious failure line.
83 """
84 from lilbee.app.services import get_services
85 from lilbee.providers.roles import WorkerRole
86 from lilbee.providers.warm_progress import WarmPhase
88 if role is not WorkerRole.CHAT:
89 return None
90 snapshot = get_services().provider.warm_progress()
91 if snapshot is None or snapshot.phase is not WarmPhase.ERROR:
92 return None
93 return snapshot.error or "The chat model did not finish loading."
96def render_status_result(status: StatusResult) -> Generator[RenderableType, None, None]:
97 """Yield Rich renderables for a :class:`StatusResult`."""
98 yield f"[{theme.LABEL}]Documents:[/{theme.LABEL}] {status.config.documents_dir}"
99 yield f"[{theme.LABEL}]Database:[/{theme.LABEL}] {status.config.data_dir}"
100 yield f"[{theme.LABEL}]Chat model:[/{theme.LABEL}] {status.config.chat_model}"
101 yield f"[{theme.LABEL}]Embeddings:[/{theme.LABEL}] {status.config.embedding_model}"
102 vision = status.config.vision_model or "(disabled)"
103 reranker = status.config.reranker_model or "(disabled)"
104 yield f"[{theme.LABEL}]Vision:[/{theme.LABEL}] {vision}"
105 yield f"[{theme.LABEL}]Reranker:[/{theme.LABEL}] {reranker}"
106 if status.config.enable_ocr is not None:
107 ocr_label = "enabled" if status.config.enable_ocr else "disabled"
108 yield f"[{theme.LABEL}]Vision OCR:[/{theme.LABEL}] {ocr_label}"
109 if status.entities is not None:
110 names = ", ".join(status.entities.types) or "schema pending (induced on next sync)"
111 yield (
112 f"[{theme.LABEL}]Entities:[/{theme.LABEL}] "
113 f"{status.entities.rows} entities extracted ({names})"
114 )
115 yield ""
117 if status.skipped:
118 held = Table(title="Held out of the index")
119 held.add_column("File", style=theme.ACCENT)
120 held.add_column("Reason", style=theme.MUTED)
121 for skipped in status.skipped:
122 held.add_row(escape(skipped.filename), escape(skipped.reason))
123 yield held
124 b = theme.LABEL
125 hidden = status.skipped_total - len(status.skipped)
126 more = f" ({hidden} more not shown)" if hidden > 0 else ""
127 yield (
128 f"[{b}]{status.skipped_total}[/{b}] held out{more}; "
129 "run 'lilbee sync --retry-skipped' to try them again"
130 )
131 yield ""
133 if not status.sources:
134 yield (
135 "No documents indexed. Drop files into the documents directory and run 'lilbee sync'."
136 )
137 return
139 table = Table(title="Indexed Documents")
140 table.add_column("File", style=theme.ACCENT)
141 table.add_column("Hash", style=theme.MUTED, max_width=12)
142 table.add_column("Chunks", justify="right")
143 table.add_column("Ingested", style=theme.MUTED)
144 for s in status.sources:
145 table.add_row(s.filename, s.file_hash, str(s.chunk_count), s.ingested_at)
146 yield table
147 b = theme.LABEL
148 yield f"\n[{b}]{len(status.sources)}[/{b}] documents, [{b}]{status.total_chunks}[/{b}] chunks"
151def render_status(con: Console) -> None:
152 """Print status info (documents, paths, chunk counts)."""
153 from lilbee.app.status import gather_status
155 for renderable in render_status_result(gather_status()):
156 con.print(renderable)
159NAME_TAKEN_WARNING = "The name {name} is taken by another source (use --force to overwrite)."
160"""Said when a label belongs to a different source, the one case --force fixes.
162The TUI states the same thing in its own words (``messages.CMD_ADD_NAME_TAKEN``);
163the two surfaces do not share a string because ``cli.tui.messages`` pulls the
164fleet and wiki import chains that a plain CLI command has no reason to pay for.
165"""
166SEARCHING_FOR = "Searching for: {query}"
167"""The stderr line ``ask`` prints when retrieval ran on a rewritten follow-up."""
170def register_paths(paths: list[Path], con: Console, *, force: bool = False) -> RegisterResult:
171 """Register *paths* as source roots, reporting what happened to each."""
172 result = register_sources(paths, force=force)
173 for name in result.skipped:
174 warning = NAME_TAKEN_WARNING.format(name=name)
175 con.print(f"[{theme.WARNING}]Warning:[/{theme.WARNING}] {warning}")
176 return result
179def describe_registration(result: RegisterResult) -> str:
180 """One line saying what ``add`` did with the paths it was given.
182 A bare count reads as a failure when the answer is "already tracked, and
183 the sync below covers it" -- which is what re-adding a source lilbee
184 already knows about does.
185 """
186 parts = []
187 if result.registered:
188 parts.append(f"Registered {len(result.registered)} source(s)")
189 if result.tracked:
190 parts.append(f"already tracked: {', '.join(result.tracked)}")
191 return ", ".join(parts) if parts else "Registered 0 source(s)"
194def add_paths(
195 paths: list[Path],
196 con: Console,
197 *,
198 force: bool = False,
199 background: bool = False,
200 chat_mode: bool = False,
201 sync_status: SyncStatus | None = None,
202 run_sync: Callable[[], object] | None = None,
203) -> None:
204 """Register *paths* as source roots and sync (human output).
205 When *background* is True (chat ``/add``), sync runs in a background thread
206 and this function returns immediately after registering. *run_sync*
207 overrides the foreground sync call (the CLI passes a Ctrl+C-cancellable
208 runner); it defaults to a plain ``asyncio.run(sync())``.
209 """
210 summary = describe_registration(register_paths(paths, con, force=force))
211 if chat_mode:
212 print(summary)
213 else:
214 con.print(f"[{theme.MUTED}]{summary}[/{theme.MUTED}]")
216 if background:
217 from lilbee.cli.sync import run_sync_background
219 run_sync_background(con, chat_mode=chat_mode, sync_status=sync_status)
220 return
222 result = run_sync() if run_sync is not None else _run_foreground_sync()
223 con.print(result)
226def _run_foreground_sync() -> object:
227 """Run a blocking sync with no cancellation hook (default for non-CLI callers)."""
228 from lilbee.data.ingest import sync
230 return asyncio.run(sync())
233def sync_result_to_json(result: object) -> dict:
234 """Convert a SyncResult to the JSON output envelope."""
235 from lilbee.data.ingest import SyncResult
237 if not isinstance(result, SyncResult):
238 raise TypeError(f"Expected SyncResult, got {type(result).__name__}")
239 return {"command": "sync", **result.model_dump()}
242def auto_sync(con: Console, *, background: bool = False) -> None:
243 """Run document sync before queries.
244 When *background* is True, sync runs in a background thread and this
245 function returns immediately (for chat/REPL). When False (default),
246 sync blocks until complete (for ``lilbee ask``).
247 """
248 if background:
249 from lilbee.cli.sync import run_sync_background
251 run_sync_background(con)
252 return
254 from lilbee.cli.sync import _format_sync_summary
255 from lilbee.data.ingest import sync
257 try:
258 result = asyncio.run(sync())
259 except RuntimeError as exc:
260 con.print(f"[{theme.ERROR}]Error:[/{theme.ERROR}] {exc}")
261 raise SystemExit(1) from None
262 summary = _format_sync_summary(
263 len(result.added),
264 len(result.updated),
265 len(result.removed),
266 len(result.failed),
267 len(result.skipped),
268 )
269 if summary:
270 con.print(f"[{theme.MUTED}]Synced: {summary}[/{theme.MUTED}]")
273@contextmanager
274def sigint_cancel() -> Iterator[threading.Event]:
275 """Turn Ctrl-C into a token the wiki pass polls, not a mid-page abort.
277 A build runs for hours and writes pages as it goes, so the default
278 KeyboardInterrupt drops it wherever the interpreter happened to be. Setting
279 a token instead lets it stop at a source boundary with what it wrote intact.
280 The previous handler is restored as soon as it fires, so a second Ctrl-C
281 still hard-exits a pass that is not checking the token.
283 signal.signal only works on the main thread; off it (pytest-xdist workers)
284 the token is simply never set and Ctrl-C keeps its default behaviour.
285 """
286 token = threading.Event()
287 if threading.current_thread() is not threading.main_thread():
288 yield token
289 return
290 previous = signal.getsignal(signal.SIGINT)
292 def _on_sigint(_signum: int, _frame: object) -> None:
293 signal.signal(signal.SIGINT, previous)
294 token.set()
296 signal.signal(signal.SIGINT, _on_sigint)
297 try:
298 yield token
299 finally:
300 signal.signal(signal.SIGINT, previous)