Coverage for src/lilbee/core/config/model.py: 100%
514 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-04 17:08 +0000
1"""The :class:`Config` dataclass and the ``cfg`` singleton.
3The settings sources, TOML parser, and the resilient builder that falls
4back to defaults on stale-config validation failures live here too. Every
5``from lilbee.core.config import cfg`` resolves through ``lilbee.core.config.__init__``
6to the same instance defined at module bottom.
7"""
9import logging
10import os
11from pathlib import Path
12from typing import Any, ClassVar
14from pydantic import Field, ValidationInfo, field_validator, model_validator
15from pydantic_settings import BaseSettings, SettingsConfigDict
17from lilbee.core.system import scaled_chat_ctx_target_default
19from .defaults import (
20 CONFIG_FILE_NAME,
21 DEFAULT_ALLOWED_NER_LABELS,
22 DEFAULT_CORS_ORIGIN_REGEX,
23 DEFAULT_CRAWL_EXCLUDE_PATTERNS,
24 DEFAULT_GENERAL_SYSTEM_PROMPT,
25 DEFAULT_IGNORE_DIRS,
26 DEFAULT_RAG_SYSTEM_PROMPT,
27)
28from .enums import (
29 ChatMode,
30 ClustererBackend,
31 CrawlRenderMode,
32 KvCacheType,
33 LlmProvider,
34 ReasoningMode,
35 RerankerType,
36 TableModel,
37 WikiEntityMode,
38)
39from .parsing import parse_bool
40from .validators import ConfigField
42log = logging.getLogger(__name__)
44# Sentinel for unset Path-typed fields. ``Field(default=Path())`` produces an
45# instance equal to this, so the model_validator can distinguish "user passed
46# the default" from "user explicitly set a value".
47_UNSET_PATH = Path()
49# Snowball stemmer languages LanceDB's FTS accepts (lancedb.index.lang_mapping);
50# hardcoded so config validation does not import lancedb.
51FTS_LANGUAGES = frozenset(
52 {
53 "Arabic",
54 "Danish",
55 "Dutch",
56 "English",
57 "Finnish",
58 "French",
59 "German",
60 "Greek",
61 "Hungarian",
62 "Italian",
63 "Norwegian",
64 "Portuguese",
65 "Romanian",
66 "Russian",
67 "Spanish",
68 "Swedish",
69 "Tamil",
70 "Turkish",
71 }
72)
75class Config(BaseSettings):
76 """Runtime configuration: one singleton instance, mutated by CLI overrides."""
78 model_config = SettingsConfigDict(
79 env_prefix="LILBEE_",
80 validate_assignment=True,
81 arbitrary_types_allowed=True,
82 extra="ignore",
83 )
85 # Paths: resolved from env/defaults in model_validator(mode='before')
86 data_root: Path = Field(
87 default=Path(),
88 description=(
89 "Root directory for this library. Resolved at start from LILBEE_DATA, "
90 "then a .lilbee/ directory walked up from the working directory, then "
91 "the platform default. Every other path below hangs off it"
92 ),
93 )
94 # Writable so plugin-managed servers can pivot storage to a vault path on
95 # first boot; rebuild the index after migrating.
96 documents_dir: Path = ConfigField(default=Path(), writable=True)
97 # External source roots ``add`` registered, mapping label -> absolute path.
98 # lilbee indexes the files where they live (no copy, no symlink); the label
99 # prefixes their source keys so a root at /data/corpus keys as ``corpus/…``.
100 # Managed by ``add`` / ``remove``, so it is writable (persisted to
101 # config.toml) but not surfaced in the settings UI.
102 linked_roots: dict[str, str] = ConfigField(
103 default_factory=dict,
104 writable=True,
105 public=False,
106 description=(
107 "External source roots that `add` registered, as label -> absolute path. "
108 "`add` and `remove` maintain it; do not edit it by hand"
109 ),
110 )
111 data_dir: Path = Field(
112 default=Path(),
113 description="Directory holding the database. Defaults to data_root/data",
114 )
115 lancedb_dir: Path = Field(
116 default=Path(),
117 description=(
118 "Directory holding the LanceDB vector tables. Defaults to data_root/data/lancedb"
119 ),
120 )
121 models_dir: Path = Field(
122 default=Path(),
123 description=(
124 "Directory holding downloaded model files. Shared across libraries, so a "
125 "model pulled for one is available to all"
126 ),
127 )
128 # Markdown vault root; when set, search results carry a vault-relative
129 # ``vault_path`` so a host UI can deep-link into the vault.
130 vault_base: Path | None = ConfigField(default=None, writable=True)
132 # Human-readable label for the active lilbee. Empty falls back to
133 # "global" for the platform default dir, otherwise the project path
134 # (~-substituted and left-truncated to a hard cap).
135 lilbee_name: str = ConfigField(default="", writable=True)
136 # If True, the status bar pill shows the full absolute path: expands
137 # "global" to the on-disk platform-default path and skips the
138 # ~-substitution / left-truncation for project paths. Toggled by F4.
139 show_lilbee_path: bool = ConfigField(default=False, writable=True)
141 # Whether an agent launcher (opencode, hermes) registers lilbee's MCP search
142 # tool into the agent's config. Per-launch --mcp/--no-mcp overrides it.
143 agent_mcp_enabled: bool = ConfigField(default=True, writable=True)
145 # Empty = not configured, same convention as vision_model. A fresh install
146 # has no models; the catalog assigns these on the first download.
147 chat_model: str = Field(default="")
148 embedding_model: str = Field(default="")
149 # Vision OCR model for scanned PDFs and image-only pages. Empty = disabled;
150 # there is no cross-role fallback onto the chat model even if multimodal.
151 vision_model: str = ConfigField(default="", public=True)
152 embedding_dim: int = Field(
153 default=768,
154 ge=1,
155 description=(
156 "Vector width the index is built with. The embedding model sets it; "
157 "change it only to match a model lilbee cannot introspect"
158 ),
159 )
160 chunk_size: int = ConfigField(default=512, ge=64, writable=True, reindex=True)
161 chunk_overlap: int = ConfigField(default=100, ge=0, writable=True, reindex=True)
162 # A file over this many chunks is skipped before embedding; 0 lifts the ceiling.
163 max_chunks_per_file: int = ConfigField(default=3_000, ge=0, writable=True)
164 # Workers for the parallel discovery/hash planning pass. 0 = auto, sized to
165 # the container-aware CPU budget (see runtime.cpu.available_cpu_count).
166 # `add --max-cpus N` sets this per invocation. Sizes only the planning pass,
167 # not the GPU-fed extract/embed batch.
168 ingest_workers: int = ConfigField(default=0, ge=0, writable=True)
169 # Worker PROCESSES for a bulk ingest (distinct from ingest_workers, which sizes
170 # the planning pass's threads). Each owns a GPU, a private store and its own
171 # slice of the corpus, and the shards are folded into one index at the end.
172 # 0 = auto: one worker per visible card, used once the corpus is big enough to
173 # pay for them. N pins the count; worker i takes card i % card_count, so more
174 # workers than cards share a card's engine rather than double-booking it.
175 ingest_processes: int = ConfigField(default=0, ge=0, writable=True)
176 # Passages packed into one embed request. Larger batches keep a GPU's
177 # continuous-batching slots full: small per-passage requests leave the card
178 # batch-starved (~96% util, low throughput). The engine still re-splits to
179 # its physical batch, so raising this only helps up to the server's --batch.
180 embed_batch_sequences: int = ConfigField(
181 default=64,
182 ge=1,
183 writable=True,
184 description=(
185 "Passages packed into one embed request. Larger batches keep a GPU's "
186 "continuous-batching slots full. The engine re-splits to its physical "
187 "batch, so raising this helps only up to the server's --batch"
188 ),
189 )
190 # Files allowed in their compute phase at once during ingest. 0 = auto: the
191 # ceiling scales with the detected embed fleet (replicas x per-replica
192 # in-flight) so a multi-GPU box is kept fed without a manual cap, falling back
193 # to the CPU quota on a single card. Set a positive value only to override the
194 # auto sizing. Sizes the extract+embed fan-out, not the plan pass.
195 ingest_max_inflight: int = ConfigField(
196 default=0,
197 ge=0,
198 writable=True,
199 description=(
200 "Files allowed in their compute phase at once during ingest. 0 = auto, "
201 "scaled to the detected embed fleet. Sizes the extract and embed fan-out, "
202 "not the planning pass"
203 ),
204 )
205 # Gate for the pre-ask sync; --no-sync overrides per invocation.
206 auto_sync: bool = ConfigField(default=True, writable=True)
207 max_embed_chars: int = Field(
208 default=2000,
209 ge=1,
210 description="Maximum characters sent to the embedding model per chunk. Longer text is cut",
211 )
212 top_k: int = ConfigField(default=12, ge=1, writable=True)
213 max_distance: float = ConfigField(default=0.75, ge=0.0, writable=True)
214 # Abstention floor against the [0, 1] fused relevance score (0.0 = no
215 # filtering). When every retrieved chunk falls below it, ask refuses instead
216 # of feeding noise as context. The fused score normalizes against the
217 # configured weight budget (a constant), so an arm's top hit scores a stable
218 # share of it; useful floors start around 0.4. Tune against your own corpus.
219 min_relevance_score: float = ConfigField(default=0.0, ge=0.0, writable=True)
220 adaptive_threshold: bool = ConfigField(default=False, writable=True)
221 rag_system_prompt: str = ConfigField(
222 default=DEFAULT_RAG_SYSTEM_PROMPT, min_length=1, writable=True
223 )
224 general_system_prompt: str = ConfigField(
225 default=DEFAULT_GENERAL_SYSTEM_PROMPT, min_length=1, writable=True
226 )
227 chat_mode: str = ConfigField(default=ChatMode.SEARCH.value, writable=True)
228 ignore_dirs: frozenset[str] = Field(
229 default=DEFAULT_IGNORE_DIRS,
230 description=(
231 "Directory names ingest never walks (.git, node_modules, and similar). "
232 "Use a .lilbeeignore file for per-library patterns"
233 ),
234 )
235 # OCR for scanned PDFs via vision-capable chat model.
236 # None = auto-detect (use OCR if chat model is vision-capable).
237 # True = force OCR regardless of detection.
238 # False = disable OCR entirely.
239 enable_ocr: bool | None = ConfigField(default=None, writable=True)
240 # Per-page timeout in seconds for vision OCR (0 = no limit). Sized so a dense
241 # full-page scan finishes on modest hardware; a raised vision_ocr_max_tokens
242 # needs matching headroom here.
243 ocr_timeout: float = ConfigField(default=300.0, ge=0.0, writable=True)
244 # Outer wall-clock budget for the streamed pool drain: load grace plus
245 # per_page * pages. Tune up for slow hardware (M1 Pro vision is
246 # ~5min/page) or down for fast hardware. ocr_timeout still governs the
247 # per-page expectation that drives the total budget.
248 vision_load_budget_s: float = ConfigField(default=300.0, ge=0.0, writable=True)
249 # Hard cap on tokens generated per OCR page. A real page is well under this;
250 # the cap bounds the occasional runaway repetition loop (a page that loops to
251 # tens of thousands of chars) which otherwise dominates a scan's OCR time.
252 # Raising it lengthens per-page generation on dense scans, so give ocr_timeout
253 # matching headroom.
254 vision_ocr_max_tokens: int = ConfigField(default=4096, ge=256, writable=True)
255 # Pages OCR'd concurrently, and the vision server's continuous-batching slots.
256 # A single-page decode underutilizes a modern GPU (~half SM); batching several
257 # pages raises throughput. Each slot adds KV cache, so lower it on small GPUs.
258 vision_ocr_concurrency: int = ConfigField(default=4, ge=1, writable=True)
260 # Tesseract fallback wall-clock timeout per file, seconds. 0 = no cap.
261 tesseract_timeout: float = ConfigField(default=60.0, ge=0.0, writable=True)
262 # Tesseract OCR language codes for the scanned-document fallback (used when no
263 # vision model is set), e.g. ["eng"] or ["eng", "deu"]. Set via env as
264 # LILBEE_OCR_LANGUAGE="eng+deu". xberg requires a non-empty list.
265 ocr_language: list[str] = ConfigField(default_factory=lambda: ["eng"], writable=True)
266 # Typed entity table for exact counting/cross-referencing; corpus-scale pass, off by default.
267 entity_extraction: bool = ConfigField(default=False, writable=True)
268 semantic_chunking: bool = ConfigField(default=False, writable=True)
269 topic_threshold: float = ConfigField(default=0.75, ge=0.0, le=1.0, writable=True)
270 # Size chunks in real tokens via the embedder's tokenizer backend, not the
271 # chars-per-token heuristic. Plain/heading chunkers only; semantic sizes by chars.
272 token_sizing: bool = ConfigField(default=False, writable=True, reindex=True)
273 # Index each recognized table as its own markdown-serialized chunk.
274 table_extraction: bool = ConfigField(default=False, writable=True, reindex=True)
275 # Layout-aware PDF extraction (reading-order sort, header/footer stripping),
276 # run in xberg's AUTO strategy so detection only fires when it helps. Off by
277 # default: enabling it downloads the ONNX layout and table-structure models
278 # and adds per-page inference, which a CPU-only ingest pays for.
279 layout_detection: bool = ConfigField(default=False, writable=True, reindex=True)
280 # Table structure model; only applied when layout_detection is on.
281 table_model: TableModel = ConfigField(
282 default=TableModel.SLANET_AUTO, writable=True, reindex=True
283 )
284 # Coalesce concurrent extractions into one xberg extract_batch call.
285 batch_extraction: bool = ConfigField(default=False, writable=True)
286 batch_extraction_size: int = ConfigField(default=8, ge=1, writable=True)
287 # Size of anyio's thread pool: synchronous handlers (MCP tools, sync routes)
288 # that may run off the event loop at once. The ceiling on agents one daemon
289 # serves before their calls queue.
290 mcp_tool_threads: int = ConfigField(default=40, ge=1, writable=True)
291 # Crawled pages converted to markdown on anyio's thread pool at once. The
292 # conversion is synchronous, so this keeps it off the event loop that serves
293 # requests. 0 converts inline on the loop.
294 crawl_convert_workers: int = ConfigField(default=2, ge=0, writable=True)
295 server_host: str = Field(
296 default="127.0.0.1",
297 description=(
298 "Address `lilbee serve` binds. Loopback by default; set 0.0.0.0 to accept "
299 "connections from the network"
300 ),
301 )
302 server_port: int = Field(
303 default=0,
304 ge=0,
305 le=65535,
306 description="Port `lilbee serve` binds. 0 picks a free port and prints it",
307 )
308 cors_origins: list[str] = Field(
309 default_factory=list,
310 description=(
311 "Extra browser origins the HTTP server accepts, in addition to cors_origin_regex"
312 ),
313 )
314 cors_origin_regex: str = Field(
315 default=DEFAULT_CORS_ORIGIN_REGEX,
316 description="Regular expression matching browser origins the HTTP server accepts",
317 )
318 # Seconds between SSE heartbeat events when the producer queue is idle.
319 # Must stay well below the plugin's STREAM_IDLE_TIMEOUT_MS (120s) so a
320 # single long-running vision OCR page can't starve the client into aborting.
321 sse_heartbeat_interval: float = ConfigField(default=30.0, ge=0.0, writable=True)
322 json_mode: bool = Field(
323 default=False,
324 description=(
325 "Emit structured JSON from CLI commands. The --json flag sets it for one invocation"
326 ),
327 )
328 temperature: float | None = ConfigField(default=0.1, ge=0.0, writable=True)
329 top_p: float | None = ConfigField(default=0.9, ge=0.0, le=1.0, writable=True)
330 top_k_sampling: int | None = ConfigField(default=40, ge=1, writable=True)
331 # 1.1 is llama.cpp's default. Leaving this at None caused n-gram loops
332 # ("tire tire tire...") on some open-weights models.
333 repeat_penalty: float | None = ConfigField(default=1.1, ge=0.0, writable=True)
334 num_ctx: int | None = ConfigField(default=None, ge=1, writable=True)
335 max_tokens: int | None = ConfigField(default=4096, ge=1, writable=True)
336 seed: int | None = ConfigField(default=None, writable=True)
337 llm_provider: LlmProvider = ConfigField(default=LlmProvider.AUTO, writable=True)
338 # Path to a llama-server binary. Empty = use the bundled lilbee-engine
339 # wheel binary, else a llama-server on PATH.
340 llama_server_path: str = ConfigField(default="", writable=True)
341 # Per-server local model-manager URLs. Blank means "use the server's spec
342 # default" (resolved in providers.local_servers.config_urls); the default
343 # URL literal lives only in the spec, which core must not import.
344 ollama_base_url: str = ConfigField(default="", writable=True)
345 lm_studio_base_url: str = ConfigField(default="", writable=True)
346 llm_api_key: str = ConfigField(default="", writable=True, write_only=True)
347 openrouter_api_key: str = ConfigField(default="", writable=True, write_only=True)
348 gemini_api_key: str = ConfigField(default="", writable=True, write_only=True)
349 anthropic_api_key: str = ConfigField(default="", writable=True, write_only=True)
350 openai_api_key: str = ConfigField(default="", writable=True, write_only=True)
351 mistral_api_key: str = ConfigField(default="", writable=True, write_only=True)
352 deepseek_api_key: str = ConfigField(default="", writable=True, write_only=True)
353 hf_token: str = ConfigField(default="", writable=True, write_only=True)
355 # Retrieval quality knobs.
357 # Max chunks per source in top-k; prevents one large file monopolizing results.
358 diversity_max_per_source: int = ConfigField(default=5, ge=1, writable=True)
360 # MMR relevance/diversity tradeoff; 0 = max diversity, 1 = pure relevance
361 # (Carbonell & Goldstein 1998).
362 mmr_lambda: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True)
364 # Vector-only search retrieves this many candidates per final result so
365 # MMR reranking has a pool to diversify from. Hybrid search ignores it:
366 # fusion arms stay exactly top_k deep.
367 candidate_multiplier: int = ConfigField(default=3, ge=1, writable=True)
369 # Third lexical arm in hybrid search: BM25 over document titles, fused with
370 # the vector and chunk arms so a query naming a document by title surfaces
371 # its chunks. Off by default until the eval harness measures it.
372 title_search: bool = ConfigField(default=False, writable=True)
374 # Title arm weight relative to a full arm in rank fusion (1.0 = equal voice
375 # with the vector and chunk arms).
376 title_search_weight: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True)
378 # Lexical (BM25) arm weight relative to the vector arm in rank fusion.
379 # 1.0 gives the two arms equal voice; lowering it lets
380 # a strong dense embedder dominate on corpora where the lexical arm adds
381 # noise rather than signal. The right value is corpus-dependent and set by
382 # the retrieval benchmark, not guessed here.
383 lexical_fusion_weight: float = ConfigField(default=1.0, ge=0.0, le=1.0, writable=True)
385 # Adaptive fusion: scale the BM25 arm per query by vector-arm confidence
386 # instead of a fixed lexical_fusion_weight (a peaked dense ranking downweights
387 # lexical, a flat one keeps it). OFF by default, pending a benchmark run to
388 # confirm it beats the fixed weight. lexical_fusion_weight is the ceiling the
389 # rule scales down from. Set adaptive_fusion=true to enable it.
390 adaptive_fusion: bool = ConfigField(default=False, writable=True)
392 # Vector-similarity margin at which the lexical arm is fully silenced; smaller
393 # = more aggressive downweighting. 0 disables adaptation entirely (the lexical
394 # arm keeps its full fixed weight).
395 adaptive_fusion_margin: float = ConfigField(default=0.15, ge=0.0, le=2.0, writable=True)
397 # Stemmer/stop-word language for the BM25 (FTS) indexes, a tantivy language
398 # name ("English", "German", "French", ...). Applied when an index is
399 # (re)built, so changing it needs `lilbee rebuild` on an existing store.
400 # Validated against FTS_LANGUAGES: a bad name would otherwise fail index
401 # creation quietly and hybrid search would degrade to vector-only.
402 fts_language: str = ConfigField(default="English", min_length=1, writable=True, reindex=True)
404 @field_validator("fts_language", mode="after")
405 @classmethod
406 def _validate_fts_language(cls, value: str) -> str:
407 normalized = value.strip().title()
408 if normalized not in FTS_LANGUAGES:
409 raise ValueError(f"fts_language must be one of: {', '.join(sorted(FTS_LANGUAGES))}")
410 return normalized
412 # Prefix each chunk's document title to its embedding input (the stored
413 # chunk text is unchanged). Changes the embedding space: toggling it needs
414 # `lilbee rebuild`, so it ships off.
415 embed_titles: bool = ConfigField(default=False, writable=True, reindex=True)
417 # Contextual retrieval: prepend one LLM-written sentence situating each
418 # chunk in its document to the embedding input. One generation per chunk,
419 # so ingest slows substantially; stored text and citations stay verbatim.
420 # Toggling needs `lilbee rebuild`.
421 contextual_enrichment: bool = ConfigField(default=False, writable=True, reindex=True)
423 # Drop tables-of-contents and classification-banner cover/title pages from
424 # search results. OFF by default; validate per corpus, since the cover-page
425 # heuristic can also fire on short banner-carrying body pages. A query-matched
426 # or top-ranked page is never dropped, so removal is limited to structural
427 # chunks the query did not hit.
428 filter_structural_chunks: bool = ConfigField(default=False, writable=True)
430 # Chunk count at/above which sync builds an approximate (ANN) vector index
431 # so search stays fast at millions of vectors. Below this, search uses exact
432 # flat scan (faster and exact for small vaults). 0 disables the ANN index.
433 ann_index_threshold: int = ConfigField(default=50_000, ge=0, writable=True)
435 # Condense a follow-up question into a standalone retrieval query using
436 # the chat history (one LLM call; skipped when there is no history).
437 # Without it, "what about his brother?" is embedded and BM25-matched
438 # with its pronouns.
439 history_rewrite: bool = ConfigField(default=False, writable=True)
441 # Route questions by shape before top-k retrieval: a question naming a
442 # document resolves to that document's chunks; a count-shaped question
443 # runs a full-corpus scan (a count is a corpus property top-k cannot
444 # answer). Unrecognized shapes take the topical path unchanged.
445 intent_routing: bool = ConfigField(default=True, writable=True)
447 # Ask the chat model to classify count questions the deterministic
448 # patterns miss (phrasing variants, other languages). Adds one short LLM
449 # call to every turn the patterns don't already route, so it's opt-in.
450 intent_llm: bool = ConfigField(default=False, writable=True)
452 # LLM-generated alternative queries for expansion. 0 disables.
453 query_expansion_count: int = ConfigField(default=3, ge=0, writable=True)
455 # Skip LLM expansion when tokenized query length ≤ this. The LLM round-trip
456 # dominates latency on small local models; short queries already have strong
457 # BM25/vector signal. Concept-graph expansion still runs. 0 disables the skip.
458 expansion_short_query_tokens: int = ConfigField(default=2, ge=0, writable=True)
460 # Cosine-distance step when adaptive-widening retry kicks in.
461 adaptive_threshold_step: float = ConfigField(default=0.2, gt=0.0, writable=True)
463 # Reject expansion variants below expansion_similarity_threshold.
464 expansion_guardrails: bool = ConfigField(default=True, writable=True)
466 # Min cosine similarity between question and variant embeddings.
467 expansion_similarity_threshold: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True)
469 # Saturating BM25 confidence (s / (s + 5)) above which query expansion is
470 # skipped; 0.8 corresponds to a raw BM25 score of 20.
471 expansion_skip_threshold: float = Field(
472 default=0.8,
473 ge=0.0,
474 le=1.0,
475 description=(
476 "Saturating BM25 confidence above which query expansion is skipped. "
477 "0.8 corresponds to a raw BM25 score of 20"
478 ),
479 )
481 # Min relative BM25 top-1 vs top-2 gap ((top - second) / top) to skip expansion.
482 expansion_skip_gap: float = Field(
483 default=0.15,
484 ge=0.0,
485 le=1.0,
486 description=(
487 "Minimum relative BM25 gap between the top two hits that skips query expansion"
488 ),
489 )
491 # Chunks included in LLM context after adaptive selection.
492 max_context_sources: int = ConfigField(default=8, ge=1, writable=True)
494 # Adjacent chunks pulled from the same source on each side of every
495 # selected chunk and merged into one contiguous passage, so a hit that
496 # lands mid-argument regains the text before and after it. 0 disables.
497 # Capped: it is a small chunk radius (useful values are single digits), and
498 # the merged text is token-budget-bounded anyway, so a large value only
499 # inflates per-query fetch cost -- and a misread as a token count (e.g.
500 # 50000) would build a megabyte-long IN-predicate per source.
501 neighbor_expansion: int = ConfigField(default=0, ge=0, le=100, writable=True)
503 # HyDE (Gao et al. 2022): hypothetical-answer embedding search. +~500ms.
504 hyde: bool = ConfigField(default=False, writable=True)
506 # HyDE result weight relative to real-doc search (0.0-1.0).
507 hyde_weight: float = ConfigField(default=0.7, ge=0.0, le=1.0, writable=True)
509 # HyDE prompt template. Must contain {question} placeholder.
510 hyde_prompt: str = Field(
511 default=(
512 "Write a 50-100 word passage that directly answers this question as if "
513 "it were an excerpt from a real document. Do not include any preamble, "
514 "just write the passage.\n\nQuestion: {question}"
515 ),
516 description=(
517 "Prompt template HyDE uses to write the hypothetical answer. Must contain {question}"
518 ),
519 )
521 # Reranker model ref. Empty disables reranking. Native GGUFs run on
522 # llama-server (rank pooling or LLM logprob scoring); hosted refs
523 # (cohere/voyage/jina/together/hf-tei) need the backend extra.
524 reranker_model: str = ConfigField(default="", public=True)
526 # auto detects cross-encoder vs LLM reranker by GGUF arch; override forces one.
527 reranker_type: RerankerType = ConfigField(default=RerankerType.AUTO, writable=True, public=True)
528 # Relevance prompt for LLM rerankers; empty uses the built-in generic template.
529 # A format string with {query} and {document} placeholders.
530 reranker_prompt: str = ConfigField(default="", writable=True, public=True)
532 # Long-term chat memory. Off by default (opt-in): when disabled the whole
533 # subsystem is dormant and the write surfaces respond with an enable hint.
534 memory_enabled: bool = ConfigField(default=False, writable=True)
536 # Facts recalled by similarity per turn (preferences are always injected).
537 memory_top_k: int = ConfigField(default=5, ge=0, writable=True)
539 # Cosine-distance ceiling for fact recall; stricter than the document default
540 # because a tiny memory corpus floods at the wider document threshold.
541 memory_max_distance: float = ConfigField(default=0.6, ge=0.0, le=1.0, writable=True)
543 # Char/4 token budget for the injected memory block.
544 memory_token_budget: int = ConfigField(default=512, ge=0, writable=True)
546 # Per-owner soft cap; oldest memories evicted past it (runaway-write guard).
547 memory_max_per_owner: int = ConfigField(default=200, ge=1, writable=True)
549 # Cosine distance below which a new memory is treated as a duplicate of an
550 # existing same-owner memory and updates it in place instead of inserting.
551 memory_dedup_distance: float = ConfigField(default=0.05, ge=0.0, le=1.0, writable=True)
553 # LLM pass that extracts memories from the chat loop. Off by default; extracted
554 # memories are saved directly and recalled like any other memory.
555 memory_auto_extract: bool = ConfigField(default=False, writable=True)
557 # Candidate count sent to the reranker.
558 rerank_candidates: int = ConfigField(default=60, ge=1, writable=True, public=True)
560 # Blend reranker scores with the retrieval fusion signal (position-aware).
561 # Off = the cross-encoder's own ordering stands unblended, which isolates
562 # the reranker's effect when measuring it.
563 rerank_blend: bool = ConfigField(default=True, writable=True, public=True)
565 # Drop candidates whose RAW reranker score falls below this; unset = off.
566 # The scale is provider/model specific (bge logits can be negative, hosted
567 # rerankers use 0..1), so set it against observed scores.
568 rerank_min_score: float | None = ConfigField(default=None, writable=True, public=True)
570 # Date-range filter; only fires when a temporal keyword is detected.
571 temporal_filtering: bool = ConfigField(default=True, writable=True)
573 # If True, emit <think>…</think> content as separate SSE reasoning events;
574 # if False, strip it silently.
575 show_reasoning: bool = ConfigField(default=False, writable=True)
577 # How /v1/chat/completions presents a reasoning model's thinking. ``separate``
578 # reports it in ``reasoning_content`` (OpenAI-compatible); ``inline`` keeps it
579 # in ``content`` as <think> text for clients that never render
580 # ``reasoning_content``; ``off`` asks the model not to think. A request's
581 # ``reasoning`` field overrides this per call.
582 completions_reasoning: ReasoningMode = ConfigField(
583 default=ReasoningMode.SEPARATE, writable=True
584 )
586 # How /v1/messages presents a reasoning model's thinking. ``separate`` reports
587 # it as a ``thinking`` block (Anthropic-compatible); ``inline`` folds it into
588 # the answer text for clients that never render thinking blocks; ``off`` asks
589 # the model not to think and drops any thinking it produces anyway. A
590 # request's ``thinking`` parameter overrides this per call.
591 messages_reasoning: ReasoningMode = ConfigField(default=ReasoningMode.SEPARATE, writable=True)
593 # Maximum reasoning characters before lilbee forces the model to answer.
594 # Per-model overrides apply on top of this default. Approx N/4 tokens.
595 # 0 disables the cap (unlimited reasoning; accept the runaway-loop risk).
596 max_reasoning_chars: int = ConfigField(default=64_000, ge=0, writable=True)
598 # Web crawling.
600 # How crawls fetch pages. ``http`` (default) uses a plain HTTP client with
601 # no browser, the lightweight path for static / server-rendered sites.
602 # ``browser`` launches a tuned Chromium with JavaScript enabled for sites
603 # that render content client-side, at a much higher memory cost.
604 crawl_render_mode: CrawlRenderMode = ConfigField(default=CrawlRenderMode.HTTP, writable=True)
606 # Browser-mode memory levers (only used when crawl_render_mode is browser).
607 # Recycle the Chromium process every N fetched pages to cap RSS growth on a
608 # long recursive crawl; 0 disables recycling. Raise on a roomy machine for
609 # fewer restarts, lower it if memory is tight.
610 crawl_browser_recycle_pages: int = ConfigField(default=50, ge=0, writable=True)
612 # Extra Chromium launch flags for browser-mode crawls. Defaults trim shared
613 # memory and GPU use; override to pass site- or environment-specific flags.
614 crawl_browser_extra_args: list[str] = ConfigField(
615 default_factory=lambda: ["--disable-dev-shm-usage", "--disable-gpu"],
616 writable=True,
617 )
619 # Optional global ceilings. None = no ceiling.
620 crawl_max_depth: int | None = ConfigField(default=None, ge=0, writable=True)
621 crawl_max_pages: int | None = ConfigField(default=None, ge=1, writable=True)
623 # Default page bound for an unbounded crawl (no explicit max_pages /
624 # crawl_max_pages), so a hostile site can't exhaust the disk by default.
625 # An explicit limit overrides it; raise this to crawl larger sites unbounded.
626 crawl_safety_max_pages: int = ConfigField(default=5_000, ge=1, writable=True)
628 # Per-URL fetch timeout, seconds.
629 crawl_timeout: int = ConfigField(default=30, ge=1, writable=True)
631 # 0 = unlimited, default = CPU count.
632 crawl_max_concurrent: int = Field(
633 default=0,
634 ge=0,
635 description="Pages fetched in parallel during a crawl. 0 = unlimited; default = CPU count",
636 )
638 # Seconds between periodic syncs during crawl. 0 = sync only at end.
639 crawl_sync_interval: int = ConfigField(default=30, ge=0, writable=True)
641 # Per-request delay + jitter (defaults chosen to be gentler than crawl4ai's).
642 crawl_mean_delay: float = ConfigField(default=0.5, ge=0.0, writable=True)
643 crawl_max_delay_range: float = ConfigField(default=0.5, ge=0.0, writable=True)
645 # In-flight requests per crawl.
646 crawl_concurrent_requests: int = ConfigField(default=3, ge=1, writable=True)
648 # Per-domain rate-limiter that backs off on HTTP 429/503 and retries.
649 crawl_retry_on_rate_limit: bool = ConfigField(default=True, writable=True)
650 crawl_retry_base_delay_min: float = ConfigField(default=1.0, ge=0.0, writable=True)
651 crawl_retry_base_delay_max: float = ConfigField(default=3.0, ge=0.0, writable=True)
652 crawl_retry_max_backoff: float = ConfigField(default=30.0, ge=0.0, writable=True)
653 crawl_retry_max_attempts: int = ConfigField(default=3, ge=0, writable=True)
655 # Regex patterns dropped at link-discovery time. Defaults block CMS
656 # scaffolding (WordPress admin, archives, tracking params, etc.).
657 crawl_exclude_patterns: list[str] = ConfigField(
658 default_factory=lambda: list(DEFAULT_CRAWL_EXCLUDE_PATTERNS),
659 writable=True,
660 )
662 # Fraction of GPU/unified memory reserved for loaded models.
663 gpu_memory_fraction: float = ConfigField(default=0.75, ge=0.1, le=1.0, writable=True)
665 # Share of a card placement may charge, leaving room for allocator
666 # fragmentation and driver overhead. Tunable because it decides admission: at
667 # the default, a 16 GB machine whose chat model needs 12-13 GB can be refused
668 # chat entirely, and the owner is the one who knows whether that card has the
669 # room. Raising it trades safety margin for the ability to serve at all.
670 usable_vram_fraction: float = ConfigField(default=0.9, ge=0.5, le=1.0, writable=True)
672 # RAM held back for the OS when placing against system memory, in GiB. Capped
673 # at a quarter of total RAM either way, so a small host keeps its proportional
674 # reserve however this is set.
675 system_memory_reserve_gb: float = ConfigField(default=4.0, ge=0.0, le=64.0, writable=True)
677 # Data-parallel replicas of the embed / vision role across GPUs: N independent
678 # servers, round-robined, so large-scale ingest fans the embedding / OCR work
679 # across the whole box. 0 means "auto": one replica per detected GPU, capped by
680 # the VRAM left after the persistent query fleet (chat, one embed, rerank, one
681 # vision) is reserved. A positive value pins the count. The extra replicas are
682 # ingest-only and reclaimed when ingest ends; the persistent query embedder /
683 # vision (replica 0) always exists if its model fits.
684 embed_replicas: int = ConfigField(default=0, ge=0, writable=True)
685 vision_replicas: int = ConfigField(default=0, ge=0, writable=True)
687 # Seconds a model stays loaded after last use. 0 = unload immediately.
688 model_keep_alive: int = ConfigField(default=300, ge=0, writable=True)
690 # Spawn every configured role server at startup instead of on first use.
691 # Trades a slower TUI mount (the role servers cold-start in parallel) for a
692 # responsive first interaction. Roles whose model is unset are skipped, so a
693 # setup with only chat + embed never spawns rerank or vision. Set to false
694 # for headless / scripted use where the first call doesn't need to be fast.
695 worker_pool_eager_start: bool = ConfigField(default=True, writable=True)
697 # Leave the engine fleet running on quit so the next launch adopts it warm.
698 # On keeps the engine process alive across app close so the next launch
699 # binds instantly; its weights still follow engine_idle_ttl_minutes. Off
700 # (default): the engine stops when the last lilbee process exits, leaving
701 # the machine clean.
702 keep_engine_warm: bool = ConfigField(default=False, writable=True)
704 # Hugging Face's high-performance transfer mode: more connections and much
705 # larger in-flight buffers. Off by default, those ceilings suit a server
706 # rather than a laptop also holding a model in memory.
707 fast_model_downloads: bool = ConfigField(default=False, writable=True)
709 # Idle minutes before the engine unloads its weights (llama-swap ttl), in
710 # every mode: even a persistent engine naps when unused. 0 keeps weights
711 # loaded until the engine stops.
712 engine_idle_ttl_minutes: int = ConfigField(default=5, writable=True)
714 # Working n_ctx the dynamic picker aims for. Default scales with
715 # total host RAM (see core.system.chat_ctx_target_for_total_bytes):
716 # <16 GiB -> 8192, 16-32 -> 12288, 32-64 -> 16384, 64-128 -> 24576,
717 # >=128 -> 65536 (an agent-capable window on server-class hosts).
718 # 8192 is the floor; the picker still clamps to training_ctx and
719 # host headroom.
720 chat_n_ctx_target: int = ConfigField(
721 default_factory=scaled_chat_ctx_target_default,
722 ge=512,
723 writable=True,
724 )
726 # Condense turns that outgrow chat_n_ctx_target into carried notes instead
727 # of dropping them. Off: zero model calls; the oldest turns drop and the
728 # context chip shows it. On: each firing blocks on a summarize call
729 # (measured: 1.3-2.5s per 60-turn fold on a datacenter GPU, 0.7-2s on an
730 # 8-core CPU with a 0.6B-4B model).
731 chat_compaction: bool = ConfigField(default=False, writable=True)
733 # Persist conversations and expose the Sessions drawer, tab, and commands.
734 # On by default; turning it off stops chats being written to disk, hides the
735 # ctrl+o binding from the footer, and gates the Sessions view behind a notice.
736 # Governs the human surfaces (TUI, HTTP, CLI); agent sessions have their own
737 # flag below, so the two domains the store already separates stay separate.
738 sessions_enabled: bool = ConfigField(default=True, writable=True)
740 # The agent (MCP) half of the same feature, off by default: agent hosts
741 # generally track their own conversation history, and the seven session
742 # tools cost schema on every request whether or not anything uses them.
743 mcp_sessions_enabled: bool = ConfigField(default=False, writable=True)
745 # Explicit ceiling for the dynamic n_ctx picker. ``None`` (default)
746 # lets the model's training_ctx from GGUF metadata be the ceiling,
747 # so a 128K-context model can reach for it on a host with the RAM
748 # to back it. Set explicitly to cap below the model's training_ctx.
749 num_ctx_max: int | None = ConfigField(default=None, ge=512, writable=True)
751 # Flash attention. None (default) = on, True = force on, False = off
752 # for backends or models where it misbehaves.
753 # Resolves the 'padding V cache to 1024' warning on models with
754 # uneven per-layer V dims (e.g. Gemma3) and saves ~25% KV memory.
755 flash_attention: bool | None = ConfigField(default=None, writable=True)
757 # KV cache element type. q8_0 (default) halves cache memory vs f16
758 # with no measurable quality loss for chat; q4_0 quarters it with a
759 # small quality cost. Both require flash attention to be enabled.
760 kv_cache_type: KvCacheType = ConfigField(default=KvCacheType.Q8_0, writable=True)
762 # Number of model layers to offload to GPU. None (default) = all
763 # layers, 0 = CPU only, positive int = partial offload. Useful when a
764 # discrete GPU has less VRAM than the model needs.
765 n_gpu_layers: int | None = ConfigField(default=None, writable=True)
767 # Keep a MoE model's expert weights in system memory, attention and shared
768 # layers on the GPU. Lets a sparse model run on a card too small to hold it.
769 # No effect on dense models, which have no expert tensors.
770 cpu_moe: bool = ConfigField(default=False, writable=True)
772 # Offload only the first N layers' experts. Takes precedence over cpu_moe;
773 # a smaller N keeps more of the model resident.
774 n_cpu_moe: int | None = ConfigField(default=None, writable=True)
776 # GPU device picker for dual-GPU machines (typical laptop case:
777 # discrete NVIDIA + integrated Intel/AMD). The Vulkan backend
778 # enumerates every adapter the system exposes and may pick the
779 # integrated one first, producing stalls or OOMs that look like
780 # llama.cpp bugs. Setting ``gpu_devices`` constrains visibility
781 # before the servers spawn, pinning inference to the chosen device(s).
782 #
783 # Accepts a comma-separated list of device indexes ("0", "1",
784 # "0,1") and applies it to every backend simultaneously:
785 # ``GGML_VK_VISIBLE_DEVICES`` for Vulkan, ``CUDA_VISIBLE_DEVICES``
786 # for CUDA, ``HIP_VISIBLE_DEVICES`` / ``ROCR_VISIBLE_DEVICES`` for
787 # ROCm. Setting one variable that the active backend ignores is
788 # harmless, so we set all four rather than detecting the build.
789 #
790 # Must be set before the first llama.cpp call; in practice that
791 # means via ``LILBEE_GPU_DEVICES`` or ``config.toml`` (TUI edits
792 # only take effect after a restart). ``None`` (default) hands off
793 # to the autodetect in ``providers/fleet/gpu_select.py``,
794 # which parses ``vulkaninfo --summary`` and pins the discrete
795 # adapter when one is present. The autodetect is silent on failure
796 # (no vulkaninfo, single device, parse error), leaving the
797 # Vulkan-loader's default ordering in place.
798 gpu_devices: str | None = ConfigField(default=None, writable=True)
800 # Primary GPU index passed to ``Llama(main_gpu=...)``. Only matters
801 # when multiple devices remain visible after ``gpu_devices``; with
802 # a single visible device, llama.cpp ignores this. ``None``
803 # (default) lets llama.cpp pick (index 0).
804 main_gpu: int | None = ConfigField(default=None, writable=True)
806 # Manual GPU placement override stored as a JSON scalar (the config.toml store
807 # is flat, and core must not depend on the provider PlacementSpec type). When
808 # set, it fully replaces the automatic placement planner: each active role pins
809 # to the listed device indices, with an optional tensor_split and replica count.
810 # Edited via the placement CLI/MCP/HTTP/TUI surfaces rather than the generic
811 # settings list, so public=False. None hands off to the VRAM-aware auto planner.
812 placement: str | None = ConfigField(
813 default=None,
814 writable=True,
815 public=False,
816 description=(
817 "Manual multi-GPU placement spec. It fully replaces the automatic planner: "
818 "each active role pins to the listed device indices. Edit it with the "
819 "placement commands, not the settings list. Empty uses the VRAM-aware planner"
820 ),
821 )
823 # Allow PUT/DELETE /api/placement to apply or clear placement over HTTP.
824 # Off by default because applying placement restarts the shared fleet's moved roles, which
825 # is unsafe across concurrent HTTP clients. Turn it on (LILBEE_ALLOW_HTTP_PLACEMENT=1)
826 # only for a single-client / owned deployment: the plugin's managed local
827 # server, or a personally-owned pod where one operator runs `lilbee serve`.
828 allow_http_placement: bool = Field(
829 default=False,
830 description=(
831 "Let PUT and DELETE /api/placement change model placement over HTTP. Off by "
832 "default because applying placement restarts the moved roles, which is unsafe "
833 "with concurrent clients. Turn it on only for a deployment you alone use"
834 ),
835 )
837 # True = Markdown widget for chat; False = plain Static (faster).
838 markdown_rendering: bool = Field(
839 default=True,
840 description=(
841 "Render chat replies as Markdown in the TUI. Off draws plain text, which is faster"
842 ),
843 )
845 # TUI theme name; persists the last Ctrl+T pick across sessions.
846 theme: str = ConfigField(default="rose-pine", writable=True)
848 # Per-model generation defaults set via apply_model_defaults().
849 _model_defaults: Any = None
851 # Wiki layer. LLM-maintained synthesis pages with citation provenance.
852 # Off by default; flip to True (or set LILBEE_WIKI=1) to enable. When off,
853 # the Wiki view tab and the chat ModelBar's scope picker are both hidden.
854 wiki: bool = ConfigField(default=False, writable=True)
855 # Whether a sync regenerates touched wiki pages on its own. Off by
856 # default: enabling the wiki never starts generating by itself, the
857 # user wikifies explicitly via `lilbee wiki build` / `wiki update`.
858 wiki_auto_update: bool = ConfigField(default=False, writable=True)
859 # Read-only: changing the directory at runtime strands prior wiki pages
860 # under the old path. Users who want a different location set it via
861 # LILBEE_WIKI_DIR / config.toml before the first wiki_build.
862 wiki_dir: str = "wiki"
863 wiki_prune_raw: bool = ConfigField(default=False, writable=True)
865 # Minimum cosine similarity between a page body and the mean of its
866 # source chunk vectors before a page is published (below → drafts).
867 # Replaces the old LLM-based faithfulness score: mean-of-chunks is a
868 # deterministic, zero-LLM-call signal that routes topic-drifted
869 # pages to drafts without the 0.0 to 1.0 ambiguity of a model-emitted
870 # number. Tuning knob: swap to per-chunk max or top-K-mean if the
871 # default 0.5 produces false drafts.
872 wiki_embedding_faithfulness_threshold: float = ConfigField(
873 default=0.5, ge=0.0, le=1.0, writable=True
874 )
876 # Per-call output token cap for wiki generation. Without this a
877 # reasoning model (Qwen3, DeepSeek-R1) can burn the full context
878 # window emitting <think> tokens before the actual answer, taking
879 # minutes per page. Default leaves headroom for a typical reasoning
880 # budget plus a real response (~1000 output + ~1000 slack).
881 wiki_summary_max_tokens: int = ConfigField(default=2048, ge=256, writable=True)
883 # Wiki generation is a structured-output task: the model must emit the
884 # block separators, the citation footnotes, and verbatim quotes. The
885 # usual chat default (~0.8) is too creative for that. Lowering the
886 # sampling temperature makes the model stick to the template and quote
887 # more faithfully. 0.1 leaves just enough slack to avoid hard loops.
888 wiki_temperature: float = ConfigField(default=0.1, ge=0.0, le=2.0, writable=True)
890 # Fraction of citations that must be stale before a wiki page is flagged.
891 wiki_stale_citation_threshold: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True)
893 # Fraction of content changed that triggers human-review drift guard.
894 wiki_drift_threshold: float = ConfigField(default=0.3, ge=0.0, le=1.0, writable=True)
896 # LLM prompt templates for wiki page generation: wiki_synthesis_prompt
897 # for cross-source synthesis pages, wiki_entity_batch_prompt (below)
898 # for the per-source batched call. Writable so advanced users can
899 # override them from /settings, config.toml, or ``LILBEE_WIKI_*_PROMPT``
900 # env vars. Templates must keep the expected ``{placeholders}``. If you
901 # remove one the generator will crash on first use.
902 wiki_synthesis_prompt: str = ConfigField(
903 writable=True,
904 default=(
905 "You are a knowledge compiler. Given source chunks from MULTIPLE documents "
906 "about related concepts, write a synthesis wiki page in markdown that connects "
907 "ideas across sources.\n\n"
908 "Rules:\n"
909 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. "
910 "Never cite by chunk label: [Chunk N] labels only organize the "
911 "chunks below and must not appear in the page.\n"
912 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n"
913 "3. For connections, interpretations, or patterns you identify across sources, "
914 "mark with [*inference*].\n"
915 "4. Use blockquotes (>) for directly cited facts.\n"
916 "5. Reference each source by its filename when drawing connections.\n"
917 "6. End with a citation block in this format:\n\n"
918 "---\n"
919 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
920 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n'
921 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n'
922 "Topic: {topic}\n\n"
923 "Sources:\n{source_list}\n\n"
924 "Chunks:\n{chunks_text}\n\n"
925 "Write the synthesis page now. Start with a heading."
926 ),
927 )
929 # Wiki synthesis clusterer backend. CONCEPTS requires the [graph] extra
930 # and falls back to EMBEDDING when unavailable.
931 wiki_clusterer: ClustererBackend = ConfigField(
932 default=ClustererBackend.EMBEDDING, writable=True
933 )
935 # Neighborhood size for the mutual-kNN graph. 0 = auto-scale from corpus size.
936 wiki_clusterer_k: int = ConfigField(default=0, ge=0, writable=True)
938 # LazyGraphRAG-style concept graph. Requires the [graph] extra.
939 concept_graph: bool = ConfigField(default=True, writable=True)
941 # Weight of concept overlap boost relative to vector similarity.
942 concept_boost_weight: float = ConfigField(default=0.3, ge=0.0, le=1.0, writable=True)
944 # Max noun-phrase concepts extracted per chunk.
945 concept_max_per_chunk: int = ConfigField(default=5, ge=1, writable=True)
947 # spaCy NER labels kept by the wiki entity extractor. Anything not
948 # in this set (QUANTITY, CARDINAL, DATE, TIME, MONEY, PERCENT,
949 # ORDINAL, ...) is dropped before aggregation. Override via
950 # LILBEE_CONCEPT_ALLOWED_ENT_TYPES as a comma-separated list.
951 concept_allowed_ent_types: frozenset[str] = Field(
952 default=DEFAULT_ALLOWED_NER_LABELS,
953 description=(
954 "spaCy NER labels the wiki entity extractor keeps. It drops everything else "
955 "(QUANTITY, CARDINAL, DATE, and so on) before aggregation"
956 ),
957 )
959 # Strategy used to extract entities for the concept/entity wiki.
960 # NER_ENTITIES (default) pulls typed NER entities with spaCy; concept
961 # pages are proposed by the LLM inside the per-source batched call,
962 # not by the extractor. NER_CONCEPTS_PLUS_LLM_TYPES layers an
963 # LLM-proposed domain schema on top. LLM_TAGGED asks the LLM to tag
964 # every chunk (most expensive). Unimplemented modes fall back to
965 # NER_ENTITIES.
966 wiki_entity_mode: WikiEntityMode = ConfigField(
967 default=WikiEntityMode.NER_ENTITIES, writable=True
968 )
970 # Minimum distinct chunk mentions before an entity or concept earns
971 # its own wiki page. Filters one-off noise.
972 wiki_entity_min_mentions: int = ConfigField(default=3, ge=1, writable=True)
973 wiki_stub_max_chunk_refs: int = ConfigField(default=50, ge=1, writable=True)
975 # Auto-update cap: if a single sync touches more than this many
976 # concept or entity pages, skip the per-slug regeneration and tell
977 # the user to run `lilbee wiki update` explicitly. Keeps a surprise
978 # bulk import from firing hundreds of LLM calls.
979 wiki_ingest_update_cap: int = ConfigField(default=20, ge=1, writable=True)
981 # Whether the per-source batched call asks the LLM to curate
982 # concept pages alongside the pre-extracted entity list. False →
983 # entity sections only, no concept curation (incremental ingest
984 # path uses this to avoid churning concept slugs per source-touch).
985 wiki_extract_concepts: bool = ConfigField(default=True, writable=True)
987 # Minimum chunk count a source must contribute before it is eligible
988 # for concept curation. Sources below the floor still get a batched
989 # call when they have entities (the prompt writes entity-only
990 # sections); sources below the floor with zero entities are skipped
991 # entirely. Prevents boilerplate / TOC / appendix documents from
992 # burning an LLM call to invent "concepts".
993 wiki_batch_min_chunks: int = ConfigField(default=3, ge=1, writable=True)
995 # Prompt template for the per-source batched call. Placeholders:
996 # {source}, {entity_list}, {chunks_text}, {concept_instruction}.
997 # {concept_instruction} is filled with a concept-curation paragraph
998 # when concepts are requested, or the empty string otherwise.
999 # Single-entity page written on demand from that entity's chunks across
1000 # every source naming it. The batched prompt above cannot serve this: it
1001 # writes every section for one source in one call.
1002 wiki_entity_page_prompt: str = ConfigField(
1003 writable=True,
1004 default=(
1005 "You are a knowledge compiler. Given source chunks that mention "
1006 "ONE subject, write a wiki page about that subject in markdown.\n\n"
1007 "Rules:\n"
1008 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. "
1009 "Never cite by chunk label: [Chunk N] labels only organize the "
1010 "chunks below and must not appear in the page.\n"
1011 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n"
1012 "3. Write only what the chunks support. Mark anything you infer with "
1013 "[*inference*].\n"
1014 "4. Use blockquotes (>) for directly cited facts.\n"
1015 "5. When sources disagree, say so and cite both.\n"
1016 "6. End with a citation block in this format:\n\n"
1017 "---\n"
1018 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
1019 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n'
1020 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n'
1021 "Subject: {topic}\n\n"
1022 "Sources:\n{source_list}\n\n"
1023 "Chunks:\n{chunks_text}\n\n"
1024 "Write the page now. Start with a heading naming the subject."
1025 ),
1026 )
1027 wiki_entity_batch_prompt: str = ConfigField(
1028 writable=True,
1029 default=(
1030 "You are writing wiki sections based on these chunks from {source}.\n\n"
1031 "{concept_instruction}"
1032 "Write a wiki section for each of these NER ENTITIES: {entity_list}\n\n"
1033 "Format each section exactly as:\n"
1034 "## Name\n"
1035 "{{content with [^src1]-style citations}}\n\n"
1036 "Rules:\n"
1037 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. "
1038 "Never cite by chunk label: [Chunk N] labels only organize the "
1039 "chunks below and must not appear in the page.\n"
1040 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n"
1041 "3. For interpretations or connections not directly stated, mark with [*inference*].\n"
1042 "4. Use blockquotes (>) for directly cited facts.\n"
1043 "5. End the response with a citation block in this format:\n\n"
1044 "---\n"
1045 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
1046 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n'
1047 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n'
1048 "Source chunks:\n{chunks_text}\n"
1049 ),
1050 )
1052 # Class variable: not a settings field
1053 _toml_cache: ClassVar[dict[str, Any]] = {}
1055 @field_validator("lilbee_name", mode="after")
1056 @classmethod
1057 def _strip_lilbee_name(cls, value: str) -> str:
1058 """Strip whitespace; an empty string signals 'use the path-derived label'."""
1059 return value.strip()
1061 @field_validator(
1062 "temperature",
1063 "top_p",
1064 "repeat_penalty",
1065 "top_k_sampling",
1066 "num_ctx",
1067 "seed",
1068 mode="before",
1069 )
1070 @classmethod
1071 def _empty_string_to_none(cls, v: Any) -> Any:
1072 if isinstance(v, str) and v.strip() == "":
1073 return None
1074 return v
1076 @field_validator("chat_mode", mode="before")
1077 @classmethod
1078 def _normalize_chat_mode(cls, v: Any) -> str:
1079 """Coerce chat_mode to a ChatMode value; default ChatMode.SEARCH."""
1080 if v is None or v == "":
1081 return ChatMode.SEARCH.value
1082 candidate = str(v).strip().lower()
1083 try:
1084 return ChatMode(candidate).value
1085 except ValueError as exc:
1086 valid = ", ".join(repr(m.value) for m in ChatMode)
1087 raise ValueError(f"chat_mode must be one of {{{valid}}}, got {v!r}") from exc
1089 @field_validator("enable_ocr", mode="before")
1090 @classmethod
1091 def _parse_enable_ocr(cls, v: Any) -> bool | None:
1092 """Parse enable_ocr from env var string or direct value.
1094 Accepts: true/false/1/0/yes/no (case-insensitive), empty string
1095 or None for auto-detect.
1096 """
1097 if v is None:
1098 return None
1099 if isinstance(v, bool):
1100 return v
1101 if isinstance(v, str):
1102 if v.strip().lower() in ("", "auto", "none"):
1103 return None
1104 try:
1105 return parse_bool(v)
1106 except ValueError:
1107 # bool() on a non-empty string is True, so falling through here
1108 # turned an unparseable value into "on". Warn and auto-detect,
1109 # matching the sibling validators.
1110 log.warning("Invalid LILBEE_ENABLE_OCR=%r, using auto", v)
1111 return None
1112 return bool(v)
1114 @field_validator("ocr_language", mode="before")
1115 @classmethod
1116 def _parse_ocr_language(cls, v: Any) -> list[str]:
1117 """Accept a list or a ``+``/comma/newline-separated string; never empty.
1119 Tesseract joins languages with ``+`` (e.g. ``eng+deu``), so that is the
1120 canonical user-facing form. Commas are also accepted. Newlines are
1121 accepted because ``app.settings`` joins list values with ``\\n`` when it
1122 persists them to config.toml; without splitting on it a multi-language
1123 value would reload as one malformed token. Blank input falls back to
1124 English, since xberg errors on an empty list.
1125 """
1126 if isinstance(v, str):
1127 v = v.replace("+", ",").replace("\n", ",").split(",")
1128 items = v or []
1129 langs = [s.strip() for s in items if isinstance(s, str) and s.strip()]
1130 return langs or ["eng"]
1132 @field_validator("flash_attention", mode="before")
1133 @classmethod
1134 def _parse_flash_attention(cls, v: Any) -> bool | None:
1135 """Auto/on/off tri-state: empty/auto/none -> None, else parse bool."""
1136 if v is None:
1137 return None
1138 if isinstance(v, bool):
1139 return v
1140 if isinstance(v, str):
1141 if v.strip().lower() in ("", "auto", "none"):
1142 return None
1143 try:
1144 return parse_bool(v)
1145 except ValueError:
1146 log.warning("Invalid flash_attention=%r, using auto", v)
1147 return None
1148 return bool(v)
1150 @field_validator("n_gpu_layers", mode="before")
1151 @classmethod
1152 def _parse_n_gpu_layers(cls, v: Any) -> int | None:
1153 """Auto -> None, ``cpu`` alias -> 0, integers parsed verbatim."""
1154 if v is None:
1155 return None
1156 if isinstance(v, str):
1157 label = v.strip().lower()
1158 if label in ("", "auto", "none"):
1159 return None
1160 if label == "cpu":
1161 return 0
1162 try:
1163 return int(label)
1164 except ValueError:
1165 log.warning("Invalid LILBEE_N_GPU_LAYERS=%r, using auto", v)
1166 return None
1167 return int(v)
1169 @field_validator("main_gpu", mode="before")
1170 @classmethod
1171 def _parse_main_gpu(cls, v: Any) -> int | None:
1172 """Empty/auto strings -> None, integers parsed verbatim."""
1173 if v is None:
1174 return None
1175 if isinstance(v, str):
1176 label = v.strip().lower()
1177 if label in ("", "auto", "none"):
1178 return None
1179 try:
1180 return int(label)
1181 except ValueError:
1182 log.warning("Invalid LILBEE_MAIN_GPU=%r, using auto", v)
1183 return None
1184 return int(v)
1186 @field_validator("gpu_devices", mode="before")
1187 @classmethod
1188 def _parse_gpu_devices(cls, v: Any) -> str | None:
1189 """Normalize device list: strip whitespace, drop empties, keep order."""
1190 if v is None:
1191 return None
1192 if isinstance(v, str):
1193 label = v.strip().lower()
1194 if label in ("", "auto", "all", "none"):
1195 return None
1196 parts = [p.strip() for p in v.split(",") if p.strip()]
1197 if not parts:
1198 return None
1199 for part in parts:
1200 if not part.lstrip("-").isdigit():
1201 log.warning("Invalid LILBEE_GPU_DEVICES=%r, ignoring", v)
1202 return None
1203 return ",".join(parts)
1204 return str(v)
1206 @field_validator("placement", mode="before")
1207 @classmethod
1208 def _parse_placement(cls, v: Any) -> str | None:
1209 """Blank/None -> None; validate a JSON string or PlacementSpec; store JSON."""
1210 from lilbee.providers.fleet.placement_spec import PlacementError, PlacementSpec
1212 if v is None:
1213 return None
1214 if isinstance(v, PlacementSpec):
1215 json_str = v.to_json()
1216 PlacementSpec.from_json(json_str) # re-validate a directly-built spec
1217 return json_str
1218 if isinstance(v, str):
1219 if v.strip() == "":
1220 return None
1221 PlacementSpec.from_json(v)
1222 return v
1223 raise PlacementError("placement must be a JSON string or PlacementSpec")
1225 @field_validator("semantic_chunking", mode="before")
1226 @classmethod
1227 def _parse_semantic_chunking(cls, v: Any) -> bool:
1228 """Parse from env string; invalid values warn and fall back to False."""
1229 if isinstance(v, bool):
1230 return v
1231 if isinstance(v, str):
1232 try:
1233 return parse_bool(v)
1234 except ValueError:
1235 log.warning("Invalid LILBEE_SEMANTIC_CHUNKING=%r, using default False", v)
1236 return False
1237 return bool(v)
1239 @field_validator(
1240 "chat_model", "embedding_model", "vision_model", "reranker_model", mode="after"
1241 )
1242 @classmethod
1243 def _normalize_model_tag(cls, v: str, info: ValidationInfo) -> str:
1244 """Validate and canonicalize a model ref; blank means the role is unconfigured."""
1245 if not v or not v.strip():
1246 return ""
1247 from lilbee.providers.model_ref import parse_model_ref
1249 return parse_model_ref(v).for_openai_prefix()
1251 @field_validator("ollama_base_url", "lm_studio_base_url", mode="after")
1252 @classmethod
1253 def _strip_trailing_slash(cls, v: str) -> str:
1254 """Canonicalize a local-server URL once at the write boundary."""
1255 return v.rstrip("/")
1257 @field_validator("cors_origins", mode="before")
1258 @classmethod
1259 def _split_cors_origins(cls, v: Any) -> Any:
1260 if isinstance(v, str):
1261 return [o.strip() for o in v.split(",") if o.strip()]
1262 return v
1264 @field_validator("crawl_browser_extra_args", mode="before")
1265 @classmethod
1266 def _split_crawl_browser_extra_args(cls, v: Any) -> Any:
1267 """Accept a newline-separated string, matching how the field is persisted.
1269 ``app.settings`` joins list values with newlines before writing them to
1270 ``config.toml`` as a scalar string. Without this inverse, reload cannot
1271 coerce that string to ``list[str]`` and the whole config.toml is dropped.
1272 TOML lists and JSON arrays pass through unchanged.
1273 """
1274 if isinstance(v, str):
1275 return [a.strip() for a in v.splitlines() if a.strip()]
1276 return v
1278 @field_validator("crawl_exclude_patterns", mode="before")
1279 @classmethod
1280 def _split_crawl_exclude_patterns(cls, v: Any) -> Any:
1281 """Accept newline-separated strings from env vars / plain-text config.
1283 Regex commonly uses commas (e.g. `{2,4}`) and pipes (alternation), so
1284 newline is the only separator safe to use for this field. TOML lists
1285 and JSON arrays pass through unchanged.
1286 """
1287 if isinstance(v, str):
1288 return [p.strip() for p in v.splitlines() if p.strip()]
1289 return v
1291 @field_validator("crawl_exclude_patterns", mode="after")
1292 @classmethod
1293 def _validate_crawl_exclude_patterns(cls, v: list[str]) -> list[str]:
1294 """Reject any entry that isn't a valid Python regex.
1296 These patterns are compiled at crawl time. An invalid pattern there
1297 surfaces as an opaque mid-crawl error; catching it at PATCH time gives
1298 the user a 400 with a pointer to the bad entry.
1299 """
1300 import re
1302 bad: list[str] = []
1303 for i, pattern in enumerate(v):
1304 try:
1305 re.compile(pattern)
1306 except re.error as exc:
1307 bad.append(f"[{i}] {pattern!r}: {exc}")
1308 if bad:
1309 raise ValueError("invalid regex in crawl_exclude_patterns:\n " + "\n ".join(bad))
1310 return v
1312 @field_validator("ignore_dirs", mode="before")
1313 @classmethod
1314 def _merge_ignore_dirs(cls, v: Any) -> frozenset[str]:
1315 if isinstance(v, str):
1316 extra = frozenset(name.strip() for name in v.split(",") if name.strip())
1317 return DEFAULT_IGNORE_DIRS | extra
1318 if isinstance(v, (set, frozenset, list)):
1319 return DEFAULT_IGNORE_DIRS | frozenset(v)
1320 return DEFAULT_IGNORE_DIRS
1322 @field_validator("concept_allowed_ent_types", mode="before")
1323 @classmethod
1324 def _parse_ent_types(cls, v: Any) -> frozenset[str]:
1325 """Replace-semantics override: a narrowed set is used as-is,
1326 not unioned with defaults. A user asking for ``PERSON,ORG``
1327 wants exactly those kinds. Accepts comma-separated strings
1328 from env and list / set / frozenset from code. Empty input
1329 falls back to :data:`DEFAULT_ALLOWED_NER_LABELS` so an empty
1330 env var does not silently disable the gate.
1331 """
1332 if isinstance(v, str):
1333 parts = frozenset(name.strip().upper() for name in v.split(",") if name.strip())
1334 return parts or DEFAULT_ALLOWED_NER_LABELS
1335 if isinstance(v, (set, frozenset, list)):
1336 parts = frozenset(str(x).upper() for x in v)
1337 return parts or DEFAULT_ALLOWED_NER_LABELS
1338 return DEFAULT_ALLOWED_NER_LABELS
1340 @model_validator(mode="before")
1341 @classmethod
1342 def _resolve_defaults(cls, data: Any) -> Any:
1343 from lilbee.core.system import (
1344 canonical_data_root,
1345 canonical_models_dir,
1346 default_data_dir,
1347 find_local_root,
1348 )
1350 if not isinstance(data, dict):
1351 return data
1353 # An empty LILBEE_DATA_ROOT (delivered as "") must fall through to default
1354 # resolution like an unset one, not become Path(".") = the process cwd.
1355 if isinstance(data.get("data_root"), str) and not data["data_root"].strip():
1356 data["data_root"] = None
1357 if data.get("data_root") in (None, _UNSET_PATH):
1358 data_env = os.environ.get("LILBEE_DATA", "").strip()
1359 if data_env:
1360 data["data_root"] = Path(data_env)
1361 else:
1362 local = find_local_root()
1363 data["data_root"] = local if local is not None else default_data_dir()
1364 # Every child path below derives from this, and the server lock keys on
1365 # those, so canonicalizing here is what makes one directory key one lock.
1366 # Also coerces a raw string (LILBEE_DATA_ROOT) to Path.
1367 root = canonical_data_root(data["data_root"])
1368 data["data_root"] = root
1369 if data.get("documents_dir") in (None, _UNSET_PATH):
1370 data["documents_dir"] = root / "documents"
1371 if data.get("data_dir") in (None, _UNSET_PATH):
1372 data["data_dir"] = root / "data"
1373 if data.get("lancedb_dir") in (None, _UNSET_PATH):
1374 data["lancedb_dir"] = root / "data" / "lancedb"
1375 if data.get("models_dir") in (None, _UNSET_PATH):
1376 data["models_dir"] = canonical_models_dir()
1378 return data
1380 @classmethod
1381 def settings_customise_sources(
1382 cls,
1383 settings_cls: type[BaseSettings],
1384 init_settings: Any,
1385 env_settings: Any,
1386 dotenv_settings: Any,
1387 file_secret_settings: Any,
1388 ) -> tuple[Any, ...]:
1389 from lilbee.core.system import canonical_data_root, default_data_dir, find_local_root
1391 # .strip() to match _resolve_defaults; a padded value would otherwise
1392 # send the root and its config.toml to different directories.
1393 data_env = os.environ.get("LILBEE_DATA", "").strip()
1394 if data_env:
1395 toml_dir = Path(data_env)
1396 else:
1397 local = find_local_root()
1398 toml_dir = local if local else default_data_dir()
1399 # Same call as the root itself, so this looks where the root resolves to;
1400 # a "~/lilbee" value would otherwise search a literal ./~ and find nothing.
1401 toml_path = canonical_data_root(toml_dir) / CONFIG_FILE_NAME
1403 plain_env = _PlainEnvSource(settings_cls, env_prefix="LILBEE_", env_ignore_empty=True)
1404 sources: list[Any] = [init_settings, plain_env]
1405 if toml_path.exists() and os.environ.get("LILBEE_SKIP_TOML_CONFIG") != "1":
1406 sources.append(_TomlSource(settings_cls, toml_path))
1407 return tuple(sources)
1409 @property
1410 def model_defaults(self) -> Any:
1411 """Per-model generation defaults (read-only). Set via apply_model_defaults()."""
1412 return self._model_defaults
1414 def apply_model_defaults(self, defaults: Any) -> None:
1415 """Store per-model generation defaults for 3-layer merge."""
1416 object.__setattr__(self, "_model_defaults", defaults)
1418 def clear_model_defaults(self) -> None:
1419 """Reset per-model defaults to None."""
1420 object.__setattr__(self, "_model_defaults", None)
1422 def generation_options(self, **overrides: Any) -> dict[str, Any]:
1423 """Merge model defaults, user config, and per-call overrides, dropping None."""
1424 result = _model_defaults_dict(self._model_defaults)
1425 # One name for the output cap inside lilbee; the provider translators rename it.
1426 if "max_tokens" in result:
1427 result["num_predict"] = result.pop("max_tokens")
1428 user_fields: dict[str, Any] = {
1429 "temperature": self.temperature,
1430 "top_p": self.top_p,
1431 "top_k": self.top_k_sampling,
1432 "repeat_penalty": self.repeat_penalty,
1433 "num_ctx": self.num_ctx,
1434 "seed": self.seed,
1435 "num_predict": self.max_tokens,
1436 }
1437 for k, v in user_fields.items():
1438 if v is not None:
1439 result[k] = v
1440 for k, v in overrides.items():
1441 if v is not None:
1442 result[k] = v
1443 return result
1446def _model_defaults_dict(defaults: Any) -> dict[str, Any]:
1447 """Non-None fields of a ModelDefaults instance as a dict."""
1448 if defaults is None:
1449 return {}
1450 from dataclasses import fields as dc_fields
1452 return {
1453 f.name: getattr(defaults, f.name)
1454 for f in dc_fields(defaults)
1455 if getattr(defaults, f.name) is not None
1456 }
1459class _PlainEnvSource:
1460 """Reads LILBEE_* env vars as plain strings so field validators handle parsing."""
1462 def __init__(
1463 self,
1464 settings_cls: type[BaseSettings],
1465 env_prefix: str,
1466 env_ignore_empty: bool = True,
1467 ) -> None:
1468 self._prefix = env_prefix
1469 self._ignore_empty = env_ignore_empty
1470 self._fields = set(settings_cls.model_fields)
1472 def __call__(self) -> dict[str, Any]:
1473 result: dict[str, Any] = {}
1474 for field_name in self._fields:
1475 env_key = f"{self._prefix}{field_name.upper()}"
1476 raw = os.environ.get(env_key)
1477 if raw is None:
1478 continue
1479 if self._ignore_empty and raw == "":
1480 continue
1481 result[field_name] = raw
1482 return result
1485class _TomlSource:
1486 """Custom pydantic-settings source that reads config.toml."""
1488 def __init__(self, settings_cls: type[BaseSettings], path: Path) -> None:
1489 self._path = path
1491 def __call__(self) -> dict[str, Any]:
1492 import tomllib
1494 try:
1495 with self._path.open("rb") as f:
1496 data = tomllib.load(f)
1497 except (ValueError, OSError):
1498 log.warning("Failed to read %s, ignoring", self._path)
1499 return {}
1500 # Empty strings represent "no persisted value" for nullable scalar
1501 # fields (legacy from set_setting writing "" for None). Pydantic
1502 # can't coerce "" to int|None, so dropping them here lets the field
1503 # default apply rather than crashing the whole Config load. TOML's
1504 # native types (lists, ints, bools) pass through untouched: stringifying
1505 # turned a list field's ["a", "b"] into the literal "['a', 'b']".
1506 return {k: v for k, v in data.items() if v != ""}
1509def _build_cfg() -> tuple[Config, Exception | None]:
1510 """Build cfg; on stale-config validation failure, fall back to defaults.
1512 A persisted ``config.toml`` from before a breaking schema change can
1513 contain values the new validators reject. Crashing at module import
1514 means every command (``lilbee --help`` included) emits a Python
1515 traceback. Falling back to env+defaults lets the package load; the
1516 CLI / TUI surfaces the original error before doing real work.
1517 """
1518 try:
1519 return Config(), None
1520 except Exception as exc:
1521 os.environ["LILBEE_SKIP_TOML_CONFIG"] = "1"
1522 try:
1523 return Config(), exc
1524 finally:
1525 os.environ.pop("LILBEE_SKIP_TOML_CONFIG", None)
1528cfg, config_load_error = _build_cfg()
1530# Canonicalize LILBEE_DATA at the cfg.data_root resolution boundary so
1531# spawn-context worker subprocesses inherit the same data root.
1532# ``setdefault`` preserves a user-set value.
1533os.environ.setdefault("LILBEE_DATA", str(cfg.data_root))