Coverage for src/lilbee/core/config/model.py: 100%

509 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""The :class:`Config` dataclass and the ``cfg`` singleton. 

2 

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""" 

8 

9import logging 

10import os 

11from pathlib import Path 

12from typing import Any, ClassVar 

13 

14from pydantic import Field, ValidationInfo, field_validator, model_validator 

15from pydantic_settings import BaseSettings, SettingsConfigDict 

16 

17from lilbee.core.system import scaled_chat_ctx_target_default 

18 

19from .defaults import ( 

20 DEFAULT_ALLOWED_NER_LABELS, 

21 DEFAULT_CORS_ORIGIN_REGEX, 

22 DEFAULT_CRAWL_EXCLUDE_PATTERNS, 

23 DEFAULT_GENERAL_SYSTEM_PROMPT, 

24 DEFAULT_IGNORE_DIRS, 

25 DEFAULT_RAG_SYSTEM_PROMPT, 

26) 

27from .enums import ( 

28 ChatMode, 

29 ClustererBackend, 

30 CrawlRenderMode, 

31 KvCacheType, 

32 LlmProvider, 

33 RerankerType, 

34 TableModel, 

35 WikiEntityMode, 

36) 

37from .parsing import parse_bool 

38from .validators import ConfigField 

39 

40log = logging.getLogger(__name__) 

41 

42# Sentinel for unset Path-typed fields. ``Field(default=Path())`` produces an 

43# instance equal to this, so the model_validator can distinguish "user passed 

44# the default" from "user explicitly set a value". 

45_UNSET_PATH = Path() 

46 

47# Snowball stemmer languages LanceDB's FTS accepts (lancedb.index.lang_mapping); 

48# hardcoded so config validation does not import lancedb. 

49FTS_LANGUAGES = frozenset( 

50 { 

51 "Arabic", 

52 "Danish", 

53 "Dutch", 

54 "English", 

55 "Finnish", 

56 "French", 

57 "German", 

58 "Greek", 

59 "Hungarian", 

60 "Italian", 

61 "Norwegian", 

62 "Portuguese", 

63 "Romanian", 

64 "Russian", 

65 "Spanish", 

66 "Swedish", 

67 "Tamil", 

68 "Turkish", 

69 } 

70) 

71 

72 

73class Config(BaseSettings): 

74 """Runtime configuration: one singleton instance, mutated by CLI overrides.""" 

75 

76 model_config = SettingsConfigDict( 

77 env_prefix="LILBEE_", 

78 validate_assignment=True, 

79 arbitrary_types_allowed=True, 

80 extra="ignore", 

81 ) 

82 

83 # Paths: resolved from env/defaults in model_validator(mode='before') 

84 data_root: Path = Field(default=Path()) 

85 # Writable so plugin-managed servers can pivot storage to a vault path on 

86 # first boot; rebuild the index after migrating. 

87 documents_dir: Path = ConfigField(default=Path(), writable=True) 

88 # External source roots ``add`` registered, mapping label -> absolute path. 

89 # lilbee indexes the files where they live (no copy, no symlink); the label 

90 # prefixes their source keys so a root at /data/corpus keys as ``corpus/…``. 

91 # Managed by ``add`` / ``remove``, so it is writable (persisted to 

92 # config.toml) but not surfaced in the settings UI. 

93 linked_roots: dict[str, str] = ConfigField(default_factory=dict, writable=True, public=False) 

94 data_dir: Path = Field(default=Path()) 

95 lancedb_dir: Path = Field(default=Path()) 

96 models_dir: Path = Field(default=Path()) 

97 # Markdown vault root; when set, search results carry a vault-relative 

98 # ``vault_path`` so a host UI can deep-link into the vault. 

99 vault_base: Path | None = ConfigField(default=None, writable=True) 

100 

101 # Human-readable label for the active lilbee. Empty falls back to 

102 # "global" for the platform default dir, otherwise the project path 

103 # (~-substituted and left-truncated to a hard cap). 

104 lilbee_name: str = ConfigField(default="", writable=True) 

105 # If True, the status bar pill shows the full absolute path: expands 

106 # "global" to the on-disk platform-default path and skips the 

107 # ~-substitution / left-truncation for project paths. Toggled by F4. 

108 show_lilbee_path: bool = ConfigField(default=False, writable=True) 

109 

110 # Whether an agent launcher (opencode, hermes) registers lilbee's MCP search 

111 # tool into the agent's config. Per-launch --mcp/--no-mcp overrides it. 

112 agent_mcp_enabled: bool = ConfigField(default=True, writable=True) 

113 

114 # Empty = not configured, same convention as vision_model. A fresh install 

115 # has no models; the catalog assigns these on the first download. 

116 chat_model: str = Field(default="") 

117 embedding_model: str = Field(default="") 

118 # Vision OCR model for scanned PDFs and image-only pages. Empty = disabled; 

119 # there is no cross-role fallback onto the chat model even if multimodal. 

120 vision_model: str = ConfigField(default="", public=True) 

121 embedding_dim: int = Field(default=768, ge=1) 

122 chunk_size: int = ConfigField(default=512, ge=64, writable=True, reindex=True) 

123 chunk_overlap: int = ConfigField(default=100, ge=0, writable=True, reindex=True) 

124 # Workers for the parallel discovery/hash planning pass. 0 = auto, sized to 

125 # the container-aware CPU budget (see runtime.cpu.available_cpu_count). 

126 # `add --max-cpus N` sets this per invocation. Sizes only the planning pass, 

127 # not the GPU-fed extract/embed batch. 

128 ingest_workers: int = ConfigField(default=0, ge=0, writable=True) 

129 # Worker PROCESSES for a bulk ingest (distinct from ingest_workers, which sizes 

130 # the planning pass's threads). Each owns a GPU, a private store and its own 

131 # slice of the corpus, and the shards are folded into one index at the end. 

132 # 0 = auto: one worker per visible card, used once the corpus is big enough to 

133 # pay for them. N pins the count; worker i takes card i % card_count, so more 

134 # workers than cards share a card's engine rather than double-booking it. 

135 ingest_processes: int = ConfigField(default=0, ge=0, writable=True) 

136 # Passages packed into one embed request. Larger batches keep a GPU's 

137 # continuous-batching slots full: small per-passage requests leave the card 

138 # batch-starved (~96% util, low throughput). The engine still re-splits to 

139 # its physical batch, so raising this only helps up to the server's --batch. 

140 embed_batch_sequences: int = ConfigField(default=64, ge=1, writable=True) 

141 # Files allowed in their compute phase at once during ingest. 0 = auto: the 

142 # ceiling scales with the detected embed fleet (replicas x per-replica 

143 # in-flight) so a multi-GPU box is kept fed without a manual cap, falling back 

144 # to the CPU quota on a single card. Set a positive value only to override the 

145 # auto sizing. Sizes the extract+embed fan-out, not the plan pass. 

146 ingest_max_inflight: int = ConfigField(default=0, ge=0, writable=True) 

147 # Gate for the pre-ask sync; --no-sync overrides per invocation. 

148 auto_sync: bool = ConfigField(default=True, writable=True) 

149 max_embed_chars: int = Field(default=2000, ge=1) 

150 top_k: int = ConfigField(default=12, ge=1, writable=True) 

151 max_distance: float = ConfigField(default=0.75, ge=0.0, writable=True) 

152 # Abstention floor against the [0, 1] fused relevance score (0.0 = no 

153 # filtering). When every retrieved chunk falls below it, ask refuses instead 

154 # of feeding noise as context. The fused score normalizes against the 

155 # configured weight budget (a constant), so an arm's top hit scores a stable 

156 # share of it; useful floors start around 0.4. Tune against your own corpus. 

157 min_relevance_score: float = ConfigField(default=0.0, ge=0.0, writable=True) 

158 adaptive_threshold: bool = ConfigField(default=False, writable=True) 

159 rag_system_prompt: str = ConfigField( 

160 default=DEFAULT_RAG_SYSTEM_PROMPT, min_length=1, writable=True 

161 ) 

162 general_system_prompt: str = ConfigField( 

163 default=DEFAULT_GENERAL_SYSTEM_PROMPT, min_length=1, writable=True 

164 ) 

165 chat_mode: str = ConfigField(default=ChatMode.SEARCH.value, writable=True) 

166 ignore_dirs: frozenset[str] = Field(default=DEFAULT_IGNORE_DIRS) 

167 # OCR for scanned PDFs via vision-capable chat model. 

168 # None = auto-detect (use OCR if chat model is vision-capable). 

169 # True = force OCR regardless of detection. 

170 # False = disable OCR entirely. 

171 enable_ocr: bool | None = ConfigField(default=None, writable=True) 

172 # Per-page timeout in seconds for vision OCR (0 = no limit). Sized so a dense 

173 # full-page scan finishes on modest hardware; a raised vision_ocr_max_tokens 

174 # needs matching headroom here. 

175 ocr_timeout: float = ConfigField(default=300.0, ge=0.0, writable=True) 

176 # Outer wall-clock budget for the streamed pool drain: load grace plus 

177 # per_page * pages. Tune up for slow hardware (M1 Pro vision is 

178 # ~5min/page) or down for fast hardware. ocr_timeout still governs the 

179 # per-page expectation that drives the total budget. 

180 vision_load_budget_s: float = ConfigField(default=300.0, ge=0.0, writable=True) 

181 # Hard cap on tokens generated per OCR page. A real page is well under this; 

182 # the cap bounds the occasional runaway repetition loop (a page that loops to 

183 # tens of thousands of chars) which otherwise dominates a scan's OCR time. 

184 # Raising it lengthens per-page generation on dense scans, so give ocr_timeout 

185 # matching headroom. 

186 vision_ocr_max_tokens: int = ConfigField(default=4096, ge=256, writable=True) 

187 # Pages OCR'd concurrently, and the vision server's continuous-batching slots. 

188 # A single-page decode underutilizes a modern GPU (~half SM); batching several 

189 # pages raises throughput. Each slot adds KV cache, so lower it on small GPUs. 

190 vision_ocr_concurrency: int = ConfigField(default=4, ge=1, writable=True) 

191 

192 # Tesseract fallback wall-clock timeout per file, seconds. 0 = no cap. 

193 tesseract_timeout: float = ConfigField(default=60.0, ge=0.0, writable=True) 

194 # Tesseract OCR language codes for the scanned-document fallback (used when no 

195 # vision model is set), e.g. ["eng"] or ["eng", "deu"]. Set via env as 

196 # LILBEE_OCR_LANGUAGE="eng+deu". xberg requires a non-empty list. 

197 ocr_language: list[str] = ConfigField(default_factory=lambda: ["eng"], writable=True) 

198 # Typed entity table for exact counting/cross-referencing; corpus-scale pass, off by default. 

199 entity_extraction: bool = ConfigField(default=False, writable=True) 

200 semantic_chunking: bool = ConfigField(default=False, writable=True) 

201 topic_threshold: float = ConfigField(default=0.75, ge=0.0, le=1.0, writable=True) 

202 # Size chunks in real tokens via the embedder's tokenizer backend, not the 

203 # chars-per-token heuristic. Plain/heading chunkers only; semantic sizes by chars. 

204 token_sizing: bool = ConfigField(default=False, writable=True, reindex=True) 

205 # Index each recognized table as its own markdown-serialized chunk. 

206 table_extraction: bool = ConfigField(default=False, writable=True, reindex=True) 

207 # Layout-aware PDF extraction (reading-order sort, header/footer stripping), 

208 # run in xberg's AUTO strategy so detection only fires when it helps. Off by 

209 # default: enabling it downloads the ONNX layout and table-structure models 

210 # and adds per-page inference, which a CPU-only ingest pays for. 

211 layout_detection: bool = ConfigField(default=False, writable=True, reindex=True) 

212 # Table structure model; only applied when layout_detection is on. 

213 table_model: TableModel = ConfigField( 

214 default=TableModel.SLANET_AUTO, writable=True, reindex=True 

215 ) 

216 # Coalesce concurrent extractions into one xberg extract_batch call. 

217 batch_extraction: bool = ConfigField(default=False, writable=True) 

218 batch_extraction_size: int = ConfigField(default=8, ge=1, writable=True) 

219 # Size of anyio's thread pool: synchronous handlers (MCP tools, sync routes) 

220 # that may run off the event loop at once. The ceiling on agents one daemon 

221 # serves before their calls queue. 

222 mcp_tool_threads: int = ConfigField(default=40, ge=1, writable=True) 

223 # Crawled pages converted to markdown on anyio's thread pool at once. The 

224 # conversion is synchronous, so this keeps it off the event loop that serves 

225 # requests. 0 converts inline on the loop. 

226 crawl_convert_workers: int = ConfigField(default=2, ge=0, writable=True) 

227 server_host: str = "127.0.0.1" 

228 server_port: int = Field(default=0, ge=0, le=65535) 

229 cors_origins: list[str] = Field(default_factory=list) 

230 cors_origin_regex: str = Field(default=DEFAULT_CORS_ORIGIN_REGEX) 

231 # Seconds between SSE heartbeat events when the producer queue is idle. 

232 # Must stay well below the plugin's STREAM_IDLE_TIMEOUT_MS (120s) so a 

233 # single long-running vision OCR page can't starve the client into aborting. 

234 sse_heartbeat_interval: float = ConfigField(default=30.0, ge=0.0, writable=True) 

235 json_mode: bool = False 

236 temperature: float | None = ConfigField(default=0.1, ge=0.0, writable=True) 

237 top_p: float | None = ConfigField(default=0.9, ge=0.0, le=1.0, writable=True) 

238 top_k_sampling: int | None = ConfigField(default=40, ge=1, writable=True) 

239 # 1.1 is llama.cpp's default. Leaving this at None caused n-gram loops 

240 # ("tire tire tire...") on some open-weights models. 

241 repeat_penalty: float | None = ConfigField(default=1.1, ge=0.0, writable=True) 

242 num_ctx: int | None = ConfigField(default=None, ge=1, writable=True) 

243 max_tokens: int | None = ConfigField(default=4096, ge=1, writable=True) 

244 seed: int | None = ConfigField(default=None, writable=True) 

245 llm_provider: LlmProvider = ConfigField(default=LlmProvider.AUTO, writable=True) 

246 # Path to a llama-server binary. Empty = use the bundled lilbee-engine 

247 # wheel binary, else a llama-server on PATH. 

248 llama_server_path: str = ConfigField(default="", writable=True) 

249 # Per-server local model-manager URLs. Blank means "use the server's spec 

250 # default" (resolved in providers.local_servers.config_urls); the default 

251 # URL literal lives only in the spec, which core must not import. 

252 ollama_base_url: str = ConfigField(default="", writable=True) 

253 lm_studio_base_url: str = ConfigField(default="", writable=True) 

254 llm_api_key: str = ConfigField(default="", writable=True, write_only=True) 

255 openrouter_api_key: str = ConfigField(default="", writable=True, write_only=True) 

256 gemini_api_key: str = ConfigField(default="", writable=True, write_only=True) 

257 anthropic_api_key: str = ConfigField(default="", writable=True, write_only=True) 

258 openai_api_key: str = ConfigField(default="", writable=True, write_only=True) 

259 mistral_api_key: str = ConfigField(default="", writable=True, write_only=True) 

260 deepseek_api_key: str = ConfigField(default="", writable=True, write_only=True) 

261 hf_token: str = ConfigField(default="", writable=True, write_only=True) 

262 

263 # Retrieval quality knobs. 

264 

265 # Max chunks per source in top-k; prevents one large file monopolizing results. 

266 diversity_max_per_source: int = ConfigField(default=5, ge=1, writable=True) 

267 

268 # MMR relevance/diversity tradeoff; 0 = max diversity, 1 = pure relevance 

269 # (Carbonell & Goldstein 1998). 

270 mmr_lambda: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True) 

271 

272 # Vector-only search retrieves this many candidates per final result so 

273 # MMR reranking has a pool to diversify from. Hybrid search ignores it: 

274 # fusion arms stay exactly top_k deep. 

275 candidate_multiplier: int = ConfigField(default=3, ge=1, writable=True) 

276 

277 # Third lexical arm in hybrid search: BM25 over document titles, fused with 

278 # the vector and chunk arms so a query naming a document by title surfaces 

279 # its chunks. Off by default until the eval harness measures it. 

280 title_search: bool = ConfigField(default=False, writable=True) 

281 

282 # Title arm weight relative to a full arm in rank fusion (1.0 = equal voice 

283 # with the vector and chunk arms). 

284 title_search_weight: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True) 

285 

286 # Lexical (BM25) arm weight relative to the vector arm in rank fusion. 

287 # 1.0 gives the two arms equal voice; lowering it lets 

288 # a strong dense embedder dominate on corpora where the lexical arm adds 

289 # noise rather than signal. The right value is corpus-dependent and set by 

290 # the retrieval benchmark, not guessed here. 

291 lexical_fusion_weight: float = ConfigField(default=1.0, ge=0.0, le=1.0, writable=True) 

292 

293 # Adaptive fusion: scale the BM25 arm per query by vector-arm confidence 

294 # instead of a fixed lexical_fusion_weight (a peaked dense ranking downweights 

295 # lexical, a flat one keeps it). OFF by default, pending a benchmark run to 

296 # confirm it beats the fixed weight. lexical_fusion_weight is the ceiling the 

297 # rule scales down from. Set adaptive_fusion=true to enable it. 

298 adaptive_fusion: bool = ConfigField(default=False, writable=True) 

299 

300 # Vector-similarity margin at which the lexical arm is fully silenced; smaller 

301 # = more aggressive downweighting. 0 disables adaptation entirely (the lexical 

302 # arm keeps its full fixed weight). 

303 adaptive_fusion_margin: float = ConfigField(default=0.15, ge=0.0, le=2.0, writable=True) 

304 

305 # Stemmer/stop-word language for the BM25 (FTS) indexes, a tantivy language 

306 # name ("English", "German", "French", ...). Applied when an index is 

307 # (re)built, so changing it needs `lilbee rebuild` on an existing store. 

308 # Validated against FTS_LANGUAGES: a bad name would otherwise fail index 

309 # creation quietly and hybrid search would degrade to vector-only. 

310 fts_language: str = ConfigField(default="English", min_length=1, writable=True, reindex=True) 

311 

312 @field_validator("fts_language", mode="after") 

313 @classmethod 

314 def _validate_fts_language(cls, value: str) -> str: 

315 normalized = value.strip().title() 

316 if normalized not in FTS_LANGUAGES: 

317 raise ValueError(f"fts_language must be one of: {', '.join(sorted(FTS_LANGUAGES))}") 

318 return normalized 

319 

320 # Prefix each chunk's document title to its embedding input (the stored 

321 # chunk text is unchanged). Changes the embedding space: toggling it needs 

322 # `lilbee rebuild`, so it ships off. 

323 embed_titles: bool = ConfigField(default=False, writable=True, reindex=True) 

324 

325 # Contextual retrieval: prepend one LLM-written sentence situating each 

326 # chunk in its document to the embedding input. One generation per chunk, 

327 # so ingest slows substantially; stored text and citations stay verbatim. 

328 # Toggling needs `lilbee rebuild`. 

329 contextual_enrichment: bool = ConfigField(default=False, writable=True, reindex=True) 

330 

331 # Drop tables-of-contents and classification-banner cover/title pages from 

332 # search results. OFF by default; validate per corpus, since the cover-page 

333 # heuristic can also fire on short banner-carrying body pages. A query-matched 

334 # or top-ranked page is never dropped, so removal is limited to structural 

335 # chunks the query did not hit. 

336 filter_structural_chunks: bool = ConfigField(default=False, writable=True) 

337 

338 # Chunk count at/above which sync builds an approximate (ANN) vector index 

339 # so search stays fast at millions of vectors. Below this, search uses exact 

340 # flat scan (faster and exact for small vaults). 0 disables the ANN index. 

341 ann_index_threshold: int = ConfigField(default=50_000, ge=0, writable=True) 

342 

343 # Condense a follow-up question into a standalone retrieval query using 

344 # the chat history (one LLM call; skipped when there is no history). 

345 # Without it, "what about his brother?" is embedded and BM25-matched 

346 # with its pronouns. 

347 history_rewrite: bool = ConfigField(default=True, writable=True) 

348 

349 # Route questions by shape before top-k retrieval: a question naming a 

350 # document resolves to that document's chunks; a count-shaped question 

351 # runs a full-corpus scan (a count is a corpus property top-k cannot 

352 # answer). Unrecognized shapes take the topical path unchanged. 

353 intent_routing: bool = ConfigField(default=True, writable=True) 

354 

355 # Ask the chat model to classify count questions the deterministic 

356 # patterns miss (phrasing variants, other languages). Adds one short LLM 

357 # call to every turn the patterns don't already route, so it's opt-in. 

358 intent_llm: bool = ConfigField(default=False, writable=True) 

359 

360 # LLM-generated alternative queries for expansion. 0 disables. 

361 query_expansion_count: int = ConfigField(default=3, ge=0, writable=True) 

362 

363 # Skip LLM expansion when tokenized query length ≤ this. The LLM round-trip 

364 # dominates latency on small local models; short queries already have strong 

365 # BM25/vector signal. Concept-graph expansion still runs. 0 disables the skip. 

366 expansion_short_query_tokens: int = ConfigField(default=2, ge=0, writable=True) 

367 

368 # Cosine-distance step when adaptive-widening retry kicks in. 

369 adaptive_threshold_step: float = ConfigField(default=0.2, gt=0.0, writable=True) 

370 

371 # Reject expansion variants below expansion_similarity_threshold. 

372 expansion_guardrails: bool = ConfigField(default=True, writable=True) 

373 

374 # Min cosine similarity between question and variant embeddings. 

375 expansion_similarity_threshold: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True) 

376 

377 # Saturating BM25 confidence (s / (s + 5)) above which query expansion is 

378 # skipped; 0.8 corresponds to a raw BM25 score of 20. 

379 expansion_skip_threshold: float = Field(default=0.8, ge=0.0, le=1.0) 

380 

381 # Min relative BM25 top-1 vs top-2 gap ((top - second) / top) to skip expansion. 

382 expansion_skip_gap: float = Field(default=0.15, ge=0.0, le=1.0) 

383 

384 # Chunks included in LLM context after adaptive selection. 

385 max_context_sources: int = ConfigField(default=8, ge=1, writable=True) 

386 

387 # Adjacent chunks pulled from the same source on each side of every 

388 # selected chunk and merged into one contiguous passage, so a hit that 

389 # lands mid-argument regains the text before and after it. 0 disables. 

390 # Capped: it is a small chunk radius (useful values are single digits), and 

391 # the merged text is token-budget-bounded anyway, so a large value only 

392 # inflates per-query fetch cost -- and a misread as a token count (e.g. 

393 # 50000) would build a megabyte-long IN-predicate per source. 

394 neighbor_expansion: int = ConfigField(default=0, ge=0, le=100, writable=True) 

395 

396 # HyDE (Gao et al. 2022): hypothetical-answer embedding search. +~500ms. 

397 hyde: bool = ConfigField(default=False, writable=True) 

398 

399 # HyDE result weight relative to real-doc search (0.0-1.0). 

400 hyde_weight: float = ConfigField(default=0.7, ge=0.0, le=1.0, writable=True) 

401 

402 # HyDE prompt template. Must contain {question} placeholder. 

403 hyde_prompt: str = ( 

404 "Write a 50-100 word passage that directly answers this question as if " 

405 "it were an excerpt from a real document. Do not include any preamble, " 

406 "just write the passage.\n\nQuestion: {question}" 

407 ) 

408 

409 # Reranker model ref. Empty disables reranking. Native GGUFs run on 

410 # llama-server (rank pooling or LLM logprob scoring); hosted refs 

411 # (cohere/voyage/jina/together/hf-tei) need the backend extra. 

412 reranker_model: str = ConfigField(default="", public=True) 

413 

414 # auto detects cross-encoder vs LLM reranker by GGUF arch; override forces one. 

415 reranker_type: RerankerType = ConfigField(default=RerankerType.AUTO, writable=True, public=True) 

416 # Relevance prompt for LLM rerankers; empty uses the built-in generic template. 

417 # A format string with {query} and {document} placeholders. 

418 reranker_prompt: str = ConfigField(default="", writable=True, public=True) 

419 

420 # Long-term chat memory. Off by default (opt-in): when disabled the whole 

421 # subsystem is dormant and the write surfaces respond with an enable hint. 

422 memory_enabled: bool = ConfigField(default=False, writable=True) 

423 

424 # Facts recalled by similarity per turn (preferences are always injected). 

425 memory_top_k: int = ConfigField(default=5, ge=0, writable=True) 

426 

427 # Cosine-distance ceiling for fact recall; stricter than the document default 

428 # because a tiny memory corpus floods at the wider document threshold. 

429 memory_max_distance: float = ConfigField(default=0.6, ge=0.0, le=1.0, writable=True) 

430 

431 # Char/4 token budget for the injected memory block. 

432 memory_token_budget: int = ConfigField(default=512, ge=0, writable=True) 

433 

434 # Per-owner soft cap; oldest memories evicted past it (runaway-write guard). 

435 memory_max_per_owner: int = ConfigField(default=200, ge=1, writable=True) 

436 

437 # Cosine distance below which a new memory is treated as a duplicate of an 

438 # existing same-owner memory and updates it in place instead of inserting. 

439 memory_dedup_distance: float = ConfigField(default=0.05, ge=0.0, le=1.0, writable=True) 

440 

441 # LLM pass that extracts memories from the chat loop. Off by default; extracted 

442 # memories are saved directly and recalled like any other memory. 

443 memory_auto_extract: bool = ConfigField(default=False, writable=True) 

444 

445 # Candidate count sent to the reranker. 

446 rerank_candidates: int = ConfigField(default=60, ge=1, writable=True, public=True) 

447 

448 # Blend reranker scores with the retrieval fusion signal (position-aware). 

449 # Off = the cross-encoder's own ordering stands unblended, which isolates 

450 # the reranker's effect when measuring it. 

451 rerank_blend: bool = ConfigField(default=True, writable=True, public=True) 

452 

453 # Drop candidates whose RAW reranker score falls below this; unset = off. 

454 # The scale is provider/model specific (bge logits can be negative, hosted 

455 # rerankers use 0..1), so set it against observed scores. 

456 rerank_min_score: float | None = ConfigField(default=None, writable=True, public=True) 

457 

458 # Date-range filter; only fires when a temporal keyword is detected. 

459 temporal_filtering: bool = ConfigField(default=True, writable=True) 

460 

461 # If True, emit <think>…</think> content as separate SSE reasoning events; 

462 # if False, strip it silently. 

463 show_reasoning: bool = ConfigField(default=False, writable=True) 

464 

465 # Maximum reasoning characters before lilbee forces the model to answer. 

466 # Per-model overrides apply on top of this default. Approx N/4 tokens. 

467 # 0 disables the cap (unlimited reasoning; accept the runaway-loop risk). 

468 max_reasoning_chars: int = ConfigField(default=64_000, ge=0, writable=True) 

469 

470 # Web crawling. 

471 

472 # How crawls fetch pages. ``http`` (default) uses a plain HTTP client with 

473 # no browser, the lightweight path for static / server-rendered sites. 

474 # ``browser`` launches a tuned Chromium with JavaScript enabled for sites 

475 # that render content client-side, at a much higher memory cost. 

476 crawl_render_mode: CrawlRenderMode = ConfigField(default=CrawlRenderMode.HTTP, writable=True) 

477 

478 # Browser-mode memory levers (only used when crawl_render_mode is browser). 

479 # Recycle the Chromium process every N fetched pages to cap RSS growth on a 

480 # long recursive crawl; 0 disables recycling. Raise on a roomy machine for 

481 # fewer restarts, lower it if memory is tight. 

482 crawl_browser_recycle_pages: int = ConfigField(default=50, ge=0, writable=True) 

483 

484 # Extra Chromium launch flags for browser-mode crawls. Defaults trim shared 

485 # memory and GPU use; override to pass site- or environment-specific flags. 

486 crawl_browser_extra_args: list[str] = ConfigField( 

487 default_factory=lambda: ["--disable-dev-shm-usage", "--disable-gpu"], 

488 writable=True, 

489 ) 

490 

491 # Optional global ceilings. None = no ceiling. 

492 crawl_max_depth: int | None = ConfigField(default=None, ge=0, writable=True) 

493 crawl_max_pages: int | None = ConfigField(default=None, ge=1, writable=True) 

494 

495 # Default page bound for an unbounded crawl (no explicit max_pages / 

496 # crawl_max_pages), so a hostile site can't exhaust the disk by default. 

497 # An explicit limit overrides it; raise this to crawl larger sites unbounded. 

498 crawl_safety_max_pages: int = ConfigField(default=5_000, ge=1, writable=True) 

499 

500 # Per-URL fetch timeout, seconds. 

501 crawl_timeout: int = ConfigField(default=30, ge=1, writable=True) 

502 

503 # 0 = unlimited, default = CPU count. 

504 crawl_max_concurrent: int = Field(default=0, ge=0) 

505 

506 # Seconds between periodic syncs during crawl. 0 = sync only at end. 

507 crawl_sync_interval: int = ConfigField(default=30, ge=0, writable=True) 

508 

509 # Per-request delay + jitter (defaults chosen to be gentler than crawl4ai's). 

510 crawl_mean_delay: float = ConfigField(default=0.5, ge=0.0, writable=True) 

511 crawl_max_delay_range: float = ConfigField(default=0.5, ge=0.0, writable=True) 

512 

513 # In-flight requests per crawl. 

514 crawl_concurrent_requests: int = ConfigField(default=3, ge=1, writable=True) 

515 

516 # Per-domain rate-limiter that backs off on HTTP 429/503 and retries. 

517 crawl_retry_on_rate_limit: bool = ConfigField(default=True, writable=True) 

518 crawl_retry_base_delay_min: float = ConfigField(default=1.0, ge=0.0, writable=True) 

519 crawl_retry_base_delay_max: float = ConfigField(default=3.0, ge=0.0, writable=True) 

520 crawl_retry_max_backoff: float = ConfigField(default=30.0, ge=0.0, writable=True) 

521 crawl_retry_max_attempts: int = ConfigField(default=3, ge=0, writable=True) 

522 

523 # Regex patterns dropped at link-discovery time. Defaults block CMS 

524 # scaffolding (WordPress admin, archives, tracking params, etc.). 

525 crawl_exclude_patterns: list[str] = ConfigField( 

526 default_factory=lambda: list(DEFAULT_CRAWL_EXCLUDE_PATTERNS), 

527 writable=True, 

528 ) 

529 

530 # Fraction of GPU/unified memory reserved for loaded models. 

531 gpu_memory_fraction: float = ConfigField(default=0.75, ge=0.1, le=1.0, writable=True) 

532 

533 # Share of a card placement may charge, leaving room for allocator 

534 # fragmentation and driver overhead. Tunable because it decides admission: at 

535 # the default, a 16 GB machine whose chat model needs 12-13 GB can be refused 

536 # chat entirely, and the owner is the one who knows whether that card has the 

537 # room. Raising it trades safety margin for the ability to serve at all. 

538 usable_vram_fraction: float = ConfigField(default=0.9, ge=0.5, le=1.0, writable=True) 

539 

540 # RAM held back for the OS when placing against system memory, in GiB. Capped 

541 # at a quarter of total RAM either way, so a small host keeps its proportional 

542 # reserve however this is set. 

543 system_memory_reserve_gb: float = ConfigField(default=4.0, ge=0.0, le=64.0, writable=True) 

544 

545 # Data-parallel replicas of the embed / vision role across GPUs: N independent 

546 # servers, round-robined, so large-scale ingest fans the embedding / OCR work 

547 # across the whole box. 0 means "auto": one replica per detected GPU, capped by 

548 # the VRAM left after the persistent query fleet (chat, one embed, rerank, one 

549 # vision) is reserved. A positive value pins the count. The extra replicas are 

550 # ingest-only and reclaimed when ingest ends; the persistent query embedder / 

551 # vision (replica 0) always exists if its model fits. 

552 embed_replicas: int = ConfigField(default=0, ge=0, writable=True) 

553 vision_replicas: int = ConfigField(default=0, ge=0, writable=True) 

554 

555 # Seconds a model stays loaded after last use. 0 = unload immediately. 

556 model_keep_alive: int = ConfigField(default=300, ge=0, writable=True) 

557 

558 # Spawn every configured role server at startup instead of on first use. 

559 # Trades a slower TUI mount (the role servers cold-start in parallel) for a 

560 # responsive first interaction. Roles whose model is unset are skipped, so a 

561 # setup with only chat + embed never spawns rerank or vision. Set to false 

562 # for headless / scripted use where the first call doesn't need to be fast. 

563 worker_pool_eager_start: bool = ConfigField(default=True, writable=True) 

564 

565 # Leave the engine fleet running on quit so the next launch adopts it warm. 

566 # On keeps the engine resident: it never idle-unloads and survives app close 

567 # so the next launch binds instantly. Off (default): the engine stops when 

568 # the last lilbee process exits, leaving the machine clean. 

569 keep_engine_warm: bool = ConfigField(default=False, writable=True) 

570 

571 # Hugging Face's high-performance transfer mode: more connections and much 

572 # larger in-flight buffers. Off by default, those ceilings suit a server 

573 # rather than a laptop also holding a model in memory. 

574 fast_model_downloads: bool = ConfigField(default=False, writable=True) 

575 

576 # Idle minutes before the engine unloads its weights (llama-swap ttl), in 

577 # every mode: even a persistent engine naps when unused. 0 keeps weights 

578 # loaded until the engine stops. 

579 engine_idle_ttl_minutes: int = ConfigField(default=5, writable=True) 

580 

581 # Working n_ctx the dynamic picker aims for. Default scales with 

582 # total host RAM (see core.system.chat_ctx_target_for_total_bytes): 

583 # <16 GiB -> 8192, 16-32 -> 12288, 32-64 -> 16384, 64-128 -> 24576, 

584 # >=128 -> 65536 (an agent-capable window on server-class hosts). 

585 # 8192 is the floor; the picker still clamps to training_ctx and 

586 # host headroom. 

587 chat_n_ctx_target: int = ConfigField( 

588 default_factory=scaled_chat_ctx_target_default, 

589 ge=512, 

590 writable=True, 

591 ) 

592 

593 # Condense turns that outgrow chat_n_ctx_target into carried notes instead 

594 # of dropping them. Off: zero model calls; the oldest turns drop and the 

595 # context chip shows it. On: each firing blocks on a summarize call 

596 # (measured: 1.3-2.5s per 60-turn fold on a datacenter GPU, 0.7-2s on an 

597 # 8-core CPU with a 0.6B-4B model). 

598 chat_compaction: bool = ConfigField(default=False, writable=True) 

599 

600 # Persist conversations and expose the Sessions drawer, tab, and commands. 

601 # On by default; turning it off stops chats being written to disk, hides the 

602 # ctrl+o binding from the footer, and gates the Sessions view behind a notice. 

603 # Governs the human surfaces (TUI, HTTP, CLI); agent sessions have their own 

604 # flag below, so the two domains the store already separates stay separate. 

605 sessions_enabled: bool = ConfigField(default=True, writable=True) 

606 

607 # The agent (MCP) half of the same feature, off by default: agent hosts 

608 # generally track their own conversation history, and the seven session 

609 # tools cost schema on every request whether or not anything uses them. 

610 mcp_sessions_enabled: bool = ConfigField(default=False, writable=True) 

611 

612 # Explicit ceiling for the dynamic n_ctx picker. ``None`` (default) 

613 # lets the model's training_ctx from GGUF metadata be the ceiling, 

614 # so a 128K-context model can reach for it on a host with the RAM 

615 # to back it. Set explicitly to cap below the model's training_ctx. 

616 num_ctx_max: int | None = ConfigField(default=None, ge=512, writable=True) 

617 

618 # Flash attention. None (default) = on, True = force on, False = off 

619 # for backends or models where it misbehaves. 

620 # Resolves the 'padding V cache to 1024' warning on models with 

621 # uneven per-layer V dims (e.g. Gemma3) and saves ~25% KV memory. 

622 flash_attention: bool | None = ConfigField(default=None, writable=True) 

623 

624 # KV cache element type. q8_0 (default) halves cache memory vs f16 

625 # with no measurable quality loss for chat; q4_0 quarters it with a 

626 # small quality cost. Both require flash attention to be enabled. 

627 kv_cache_type: KvCacheType = ConfigField(default=KvCacheType.Q8_0, writable=True) 

628 

629 # Number of model layers to offload to GPU. None (default) = all 

630 # layers, 0 = CPU only, positive int = partial offload. Useful when a 

631 # discrete GPU has less VRAM than the model needs. 

632 n_gpu_layers: int | None = ConfigField(default=None, writable=True) 

633 

634 # Keep a MoE model's expert weights in system memory, attention and shared 

635 # layers on the GPU. Lets a sparse model run on a card too small to hold it. 

636 # No effect on dense models, which have no expert tensors. 

637 cpu_moe: bool = ConfigField(default=False, writable=True) 

638 

639 # Offload only the first N layers' experts. Takes precedence over cpu_moe; 

640 # a smaller N keeps more of the model resident. 

641 n_cpu_moe: int | None = ConfigField(default=None, writable=True) 

642 

643 # GPU device picker for dual-GPU machines (typical laptop case: 

644 # discrete NVIDIA + integrated Intel/AMD). The Vulkan backend 

645 # enumerates every adapter the system exposes and may pick the 

646 # integrated one first, producing stalls or OOMs that look like 

647 # llama.cpp bugs. Setting ``gpu_devices`` constrains visibility 

648 # before the servers spawn, pinning inference to the chosen device(s). 

649 # 

650 # Accepts a comma-separated list of device indexes ("0", "1", 

651 # "0,1") and applies it to every backend simultaneously: 

652 # ``GGML_VK_VISIBLE_DEVICES`` for Vulkan, ``CUDA_VISIBLE_DEVICES`` 

653 # for CUDA, ``HIP_VISIBLE_DEVICES`` / ``ROCR_VISIBLE_DEVICES`` for 

654 # ROCm. Setting one variable that the active backend ignores is 

655 # harmless, so we set all four rather than detecting the build. 

656 # 

657 # Must be set before the first llama.cpp call; in practice that 

658 # means via ``LILBEE_GPU_DEVICES`` or ``config.toml`` (TUI edits 

659 # only take effect after a restart). ``None`` (default) hands off 

660 # to the autodetect in ``providers/fleet/gpu_select.py``, 

661 # which parses ``vulkaninfo --summary`` and pins the discrete 

662 # adapter when one is present. The autodetect is silent on failure 

663 # (no vulkaninfo, single device, parse error), leaving the 

664 # Vulkan-loader's default ordering in place. 

665 gpu_devices: str | None = ConfigField(default=None, writable=True) 

666 

667 # Primary GPU index passed to ``Llama(main_gpu=...)``. Only matters 

668 # when multiple devices remain visible after ``gpu_devices``; with 

669 # a single visible device, llama.cpp ignores this. ``None`` 

670 # (default) lets llama.cpp pick (index 0). 

671 main_gpu: int | None = ConfigField(default=None, writable=True) 

672 

673 # Manual GPU placement override stored as a JSON scalar (the config.toml store 

674 # is flat, and core must not depend on the provider PlacementSpec type). When 

675 # set, it fully replaces the automatic placement planner: each active role pins 

676 # to the listed device indices, with an optional tensor_split and replica count. 

677 # Edited via the placement CLI/MCP/HTTP/TUI surfaces rather than the generic 

678 # settings list, so public=False. None hands off to the VRAM-aware auto planner. 

679 placement: str | None = ConfigField(default=None, writable=True, public=False) 

680 

681 # Allow PUT/DELETE /api/placement to apply or clear placement over HTTP. 

682 # Off by default because applying placement restarts the shared fleet's moved roles, which 

683 # is unsafe across concurrent HTTP clients. Turn it on (LILBEE_ALLOW_HTTP_PLACEMENT=1) 

684 # only for a single-client / owned deployment: the plugin's managed local 

685 # server, or a personally-owned pod where one operator runs `lilbee serve`. 

686 allow_http_placement: bool = Field(default=False) 

687 

688 # True = Markdown widget for chat; False = plain Static (faster). 

689 markdown_rendering: bool = True 

690 

691 # TUI theme name; persists the last Ctrl+T pick across sessions. 

692 theme: str = ConfigField(default="rose-pine", writable=True) 

693 

694 # Per-model generation defaults set via apply_model_defaults(). 

695 _model_defaults: Any = None 

696 

697 # Wiki layer. LLM-maintained synthesis pages with citation provenance. 

698 # Off by default; flip to True (or set LILBEE_WIKI=1) to enable. When off, 

699 # the Wiki view tab and the chat ModelBar's scope picker are both hidden. 

700 wiki: bool = ConfigField(default=False, writable=True) 

701 # Whether a sync regenerates touched wiki pages on its own. Off by 

702 # default: enabling the wiki never starts generating by itself, the 

703 # user wikifies explicitly via `lilbee wiki build` / `wiki update`. 

704 wiki_auto_update: bool = ConfigField(default=False, writable=True) 

705 # Read-only: changing the directory at runtime strands prior wiki pages 

706 # under the old path. Users who want a different location set it via 

707 # LILBEE_WIKI_DIR / config.toml before the first wiki_build. 

708 wiki_dir: str = "wiki" 

709 wiki_prune_raw: bool = ConfigField(default=False, writable=True) 

710 

711 # Minimum cosine similarity between a page body and the mean of its 

712 # source chunk vectors before a page is published (below → drafts). 

713 # Replaces the old LLM-based faithfulness score: mean-of-chunks is a 

714 # deterministic, zero-LLM-call signal that routes topic-drifted 

715 # pages to drafts without the 0.0 to 1.0 ambiguity of a model-emitted 

716 # number. Tuning knob: swap to per-chunk max or top-K-mean if the 

717 # default 0.5 produces false drafts. 

718 wiki_embedding_faithfulness_threshold: float = ConfigField( 

719 default=0.5, ge=0.0, le=1.0, writable=True 

720 ) 

721 

722 # Per-call output token cap for wiki generation. Without this a 

723 # reasoning model (Qwen3, DeepSeek-R1) can burn the full context 

724 # window emitting <think> tokens before the actual answer, taking 

725 # minutes per page. Default leaves headroom for a typical reasoning 

726 # budget plus a real response (~1000 output + ~1000 slack). 

727 wiki_summary_max_tokens: int = ConfigField(default=2048, ge=256, writable=True) 

728 

729 # Wiki generation is a structured-output task: the model must emit the 

730 # block separators, the citation footnotes, and verbatim quotes. The 

731 # usual chat default (~0.8) is too creative for that. Lowering the 

732 # sampling temperature makes the model stick to the template and quote 

733 # more faithfully. 0.1 leaves just enough slack to avoid hard loops. 

734 wiki_temperature: float = ConfigField(default=0.1, ge=0.0, le=2.0, writable=True) 

735 

736 # Fraction of citations that must be stale before a wiki page is flagged. 

737 wiki_stale_citation_threshold: float = ConfigField(default=0.5, ge=0.0, le=1.0, writable=True) 

738 

739 # Fraction of content changed that triggers human-review drift guard. 

740 wiki_drift_threshold: float = ConfigField(default=0.3, ge=0.0, le=1.0, writable=True) 

741 

742 # LLM prompt templates for wiki page generation: wiki_synthesis_prompt 

743 # for cross-source synthesis pages, wiki_entity_batch_prompt (below) 

744 # for the per-source batched call. Writable so advanced users can 

745 # override them from /settings, config.toml, or ``LILBEE_WIKI_*_PROMPT`` 

746 # env vars. Templates must keep the expected ``{placeholders}``. If you 

747 # remove one the generator will crash on first use. 

748 wiki_synthesis_prompt: str = ConfigField( 

749 writable=True, 

750 default=( 

751 "You are a knowledge compiler. Given source chunks from MULTIPLE documents " 

752 "about related concepts, write a synthesis wiki page in markdown that connects " 

753 "ideas across sources.\n\n" 

754 "Rules:\n" 

755 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. " 

756 "Never cite by chunk label: [Chunk N] labels only organize the " 

757 "chunks below and must not appear in the page.\n" 

758 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n" 

759 "3. For connections, interpretations, or patterns you identify across sources, " 

760 "mark with [*inference*].\n" 

761 "4. Use blockquotes (>) for directly cited facts.\n" 

762 "5. Reference each source by its filename when drawing connections.\n" 

763 "6. End with a citation block in this format:\n\n" 

764 "---\n" 

765 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n" 

766 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n' 

767 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n' 

768 "Topic: {topic}\n\n" 

769 "Sources:\n{source_list}\n\n" 

770 "Chunks:\n{chunks_text}\n\n" 

771 "Write the synthesis page now. Start with a heading." 

772 ), 

773 ) 

774 

775 # Wiki synthesis clusterer backend. CONCEPTS requires the [graph] extra 

776 # and falls back to EMBEDDING when unavailable. 

777 wiki_clusterer: ClustererBackend = ConfigField( 

778 default=ClustererBackend.EMBEDDING, writable=True 

779 ) 

780 

781 # Neighborhood size for the mutual-kNN graph. 0 = auto-scale from corpus size. 

782 wiki_clusterer_k: int = ConfigField(default=0, ge=0, writable=True) 

783 

784 # LazyGraphRAG-style concept graph. Requires the [graph] extra. 

785 concept_graph: bool = ConfigField(default=True, writable=True) 

786 

787 # Weight of concept overlap boost relative to vector similarity. 

788 concept_boost_weight: float = ConfigField(default=0.3, ge=0.0, le=1.0, writable=True) 

789 

790 # Max noun-phrase concepts extracted per chunk. 

791 concept_max_per_chunk: int = ConfigField(default=5, ge=1, writable=True) 

792 

793 # spaCy NER labels kept by the wiki entity extractor. Anything not 

794 # in this set (QUANTITY, CARDINAL, DATE, TIME, MONEY, PERCENT, 

795 # ORDINAL, ...) is dropped before aggregation. Override via 

796 # LILBEE_CONCEPT_ALLOWED_ENT_TYPES as a comma-separated list. 

797 concept_allowed_ent_types: frozenset[str] = Field(default=DEFAULT_ALLOWED_NER_LABELS) 

798 

799 # Strategy used to extract entities for the concept/entity wiki. 

800 # NER_ENTITIES (default) pulls typed NER entities with spaCy; concept 

801 # pages are proposed by the LLM inside the per-source batched call, 

802 # not by the extractor. NER_CONCEPTS_PLUS_LLM_TYPES layers an 

803 # LLM-proposed domain schema on top. LLM_TAGGED asks the LLM to tag 

804 # every chunk (most expensive). Unimplemented modes fall back to 

805 # NER_ENTITIES. 

806 wiki_entity_mode: WikiEntityMode = ConfigField( 

807 default=WikiEntityMode.NER_ENTITIES, writable=True 

808 ) 

809 

810 # Minimum distinct chunk mentions before an entity or concept earns 

811 # its own wiki page. Filters one-off noise. 

812 wiki_entity_min_mentions: int = ConfigField(default=3, ge=1, writable=True) 

813 wiki_stub_max_chunk_refs: int = ConfigField(default=50, ge=1, writable=True) 

814 

815 # Auto-update cap: if a single sync touches more than this many 

816 # concept or entity pages, skip the per-slug regeneration and tell 

817 # the user to run `lilbee wiki update` explicitly. Keeps a surprise 

818 # bulk import from firing hundreds of LLM calls. 

819 wiki_ingest_update_cap: int = ConfigField(default=20, ge=1, writable=True) 

820 

821 # Whether the per-source batched call asks the LLM to curate 

822 # concept pages alongside the pre-extracted entity list. False → 

823 # entity sections only, no concept curation (incremental ingest 

824 # path uses this to avoid churning concept slugs per source-touch). 

825 wiki_extract_concepts: bool = ConfigField(default=True, writable=True) 

826 

827 # Minimum chunk count a source must contribute before it is eligible 

828 # for concept curation. Sources below the floor still get a batched 

829 # call when they have entities (the prompt writes entity-only 

830 # sections); sources below the floor with zero entities are skipped 

831 # entirely. Prevents boilerplate / TOC / appendix documents from 

832 # burning an LLM call to invent "concepts". 

833 wiki_batch_min_chunks: int = ConfigField(default=3, ge=1, writable=True) 

834 

835 # Prompt template for the per-source batched call. Placeholders: 

836 # {source}, {entity_list}, {chunks_text}, {concept_instruction}. 

837 # {concept_instruction} is filled with a concept-curation paragraph 

838 # when concepts are requested, or the empty string otherwise. 

839 # Single-entity page written on demand from that entity's chunks across 

840 # every source naming it. The batched prompt above cannot serve this: it 

841 # writes every section for one source in one call. 

842 wiki_entity_page_prompt: str = ConfigField( 

843 writable=True, 

844 default=( 

845 "You are a knowledge compiler. Given source chunks that mention " 

846 "ONE subject, write a wiki page about that subject in markdown.\n\n" 

847 "Rules:\n" 

848 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. " 

849 "Never cite by chunk label: [Chunk N] labels only organize the " 

850 "chunks below and must not appear in the page.\n" 

851 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n" 

852 "3. Write only what the chunks support. Mark anything you infer with " 

853 "[*inference*].\n" 

854 "4. Use blockquotes (>) for directly cited facts.\n" 

855 "5. When sources disagree, say so and cite both.\n" 

856 "6. End with a citation block in this format:\n\n" 

857 "---\n" 

858 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n" 

859 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n' 

860 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n' 

861 "Subject: {topic}\n\n" 

862 "Sources:\n{source_list}\n\n" 

863 "Chunks:\n{chunks_text}\n\n" 

864 "Write the page now. Start with a heading naming the subject." 

865 ), 

866 ) 

867 wiki_entity_batch_prompt: str = ConfigField( 

868 writable=True, 

869 default=( 

870 "You are writing wiki sections based on these chunks from {source}.\n\n" 

871 "{concept_instruction}" 

872 "Write a wiki section for each of these NER ENTITIES: {entity_list}\n\n" 

873 "Format each section exactly as:\n" 

874 "## Name\n" 

875 "{{content with [^src1]-style citations}}\n\n" 

876 "Rules:\n" 

877 "1. Every factual claim MUST have an inline citation [^src1], [^src2], etc. " 

878 "Never cite by chunk label: [Chunk N] labels only organize the " 

879 "chunks below and must not appear in the page.\n" 

880 "2. Cite the EXACT text from the source that supports each claim by quoting it.\n" 

881 "3. For interpretations or connections not directly stated, mark with [*inference*].\n" 

882 "4. Use blockquotes (>) for directly cited facts.\n" 

883 "5. End the response with a citation block in this format:\n\n" 

884 "---\n" 

885 "<!-- citations (auto-generated from _citations table -- do not edit) -->\n" 

886 '[^src1]: {{source_name}}, excerpt: "exact quoted text"\n' 

887 '[^src2]: {{source_name}}, excerpt: "exact quoted text"\n\n' 

888 "Source chunks:\n{chunks_text}\n" 

889 ), 

890 ) 

891 

892 # Class variable: not a settings field 

893 _toml_cache: ClassVar[dict[str, Any]] = {} 

894 

895 @field_validator("lilbee_name", mode="after") 

896 @classmethod 

897 def _strip_lilbee_name(cls, value: str) -> str: 

898 """Strip whitespace; an empty string signals 'use the path-derived label'.""" 

899 return value.strip() 

900 

901 @field_validator( 

902 "temperature", 

903 "top_p", 

904 "repeat_penalty", 

905 "top_k_sampling", 

906 "num_ctx", 

907 "seed", 

908 mode="before", 

909 ) 

910 @classmethod 

911 def _empty_string_to_none(cls, v: Any) -> Any: 

912 if isinstance(v, str) and v.strip() == "": 

913 return None 

914 return v 

915 

916 @field_validator("chat_mode", mode="before") 

917 @classmethod 

918 def _normalize_chat_mode(cls, v: Any) -> str: 

919 """Coerce chat_mode to a ChatMode value; default ChatMode.SEARCH.""" 

920 if v is None or v == "": 

921 return ChatMode.SEARCH.value 

922 candidate = str(v).strip().lower() 

923 try: 

924 return ChatMode(candidate).value 

925 except ValueError as exc: 

926 valid = ", ".join(repr(m.value) for m in ChatMode) 

927 raise ValueError(f"chat_mode must be one of {{{valid}}}, got {v!r}") from exc 

928 

929 @field_validator("enable_ocr", mode="before") 

930 @classmethod 

931 def _parse_enable_ocr(cls, v: Any) -> bool | None: 

932 """Parse enable_ocr from env var string or direct value. 

933 

934 Accepts: true/false/1/0/yes/no (case-insensitive), empty string 

935 or None for auto-detect. 

936 """ 

937 if v is None: 

938 return None 

939 if isinstance(v, bool): 

940 return v 

941 if isinstance(v, str): 

942 if v.strip().lower() in ("", "auto", "none"): 

943 return None 

944 try: 

945 return parse_bool(v) 

946 except ValueError: 

947 # bool() on a non-empty string is True, so falling through here 

948 # turned an unparseable value into "on". Warn and auto-detect, 

949 # matching the sibling validators. 

950 log.warning("Invalid LILBEE_ENABLE_OCR=%r, using auto", v) 

951 return None 

952 return bool(v) 

953 

954 @field_validator("ocr_language", mode="before") 

955 @classmethod 

956 def _parse_ocr_language(cls, v: Any) -> list[str]: 

957 """Accept a list or a ``+``/comma/newline-separated string; never empty. 

958 

959 Tesseract joins languages with ``+`` (e.g. ``eng+deu``), so that is the 

960 canonical user-facing form. Commas are also accepted. Newlines are 

961 accepted because ``app.settings`` joins list values with ``\\n`` when it 

962 persists them to config.toml; without splitting on it a multi-language 

963 value would reload as one malformed token. Blank input falls back to 

964 English, since xberg errors on an empty list. 

965 """ 

966 if isinstance(v, str): 

967 v = v.replace("+", ",").replace("\n", ",").split(",") 

968 items = v or [] 

969 langs = [s.strip() for s in items if isinstance(s, str) and s.strip()] 

970 return langs or ["eng"] 

971 

972 @field_validator("flash_attention", mode="before") 

973 @classmethod 

974 def _parse_flash_attention(cls, v: Any) -> bool | None: 

975 """Auto/on/off tri-state: empty/auto/none -> None, else parse bool.""" 

976 if v is None: 

977 return None 

978 if isinstance(v, bool): 

979 return v 

980 if isinstance(v, str): 

981 if v.strip().lower() in ("", "auto", "none"): 

982 return None 

983 try: 

984 return parse_bool(v) 

985 except ValueError: 

986 log.warning("Invalid flash_attention=%r, using auto", v) 

987 return None 

988 return bool(v) 

989 

990 @field_validator("n_gpu_layers", mode="before") 

991 @classmethod 

992 def _parse_n_gpu_layers(cls, v: Any) -> int | None: 

993 """Auto -> None, ``cpu`` alias -> 0, integers parsed verbatim.""" 

994 if v is None: 

995 return None 

996 if isinstance(v, str): 

997 label = v.strip().lower() 

998 if label in ("", "auto", "none"): 

999 return None 

1000 if label == "cpu": 

1001 return 0 

1002 try: 

1003 return int(label) 

1004 except ValueError: 

1005 log.warning("Invalid LILBEE_N_GPU_LAYERS=%r, using auto", v) 

1006 return None 

1007 return int(v) 

1008 

1009 @field_validator("main_gpu", mode="before") 

1010 @classmethod 

1011 def _parse_main_gpu(cls, v: Any) -> int | None: 

1012 """Empty/auto strings -> None, integers parsed verbatim.""" 

1013 if v is None: 

1014 return None 

1015 if isinstance(v, str): 

1016 label = v.strip().lower() 

1017 if label in ("", "auto", "none"): 

1018 return None 

1019 try: 

1020 return int(label) 

1021 except ValueError: 

1022 log.warning("Invalid LILBEE_MAIN_GPU=%r, using auto", v) 

1023 return None 

1024 return int(v) 

1025 

1026 @field_validator("gpu_devices", mode="before") 

1027 @classmethod 

1028 def _parse_gpu_devices(cls, v: Any) -> str | None: 

1029 """Normalize device list: strip whitespace, drop empties, keep order.""" 

1030 if v is None: 

1031 return None 

1032 if isinstance(v, str): 

1033 label = v.strip().lower() 

1034 if label in ("", "auto", "all", "none"): 

1035 return None 

1036 parts = [p.strip() for p in v.split(",") if p.strip()] 

1037 if not parts: 

1038 return None 

1039 for part in parts: 

1040 if not part.lstrip("-").isdigit(): 

1041 log.warning("Invalid LILBEE_GPU_DEVICES=%r, ignoring", v) 

1042 return None 

1043 return ",".join(parts) 

1044 return str(v) 

1045 

1046 @field_validator("placement", mode="before") 

1047 @classmethod 

1048 def _parse_placement(cls, v: Any) -> str | None: 

1049 """Blank/None -> None; validate a JSON string or PlacementSpec; store JSON.""" 

1050 from lilbee.providers.fleet.placement_spec import PlacementError, PlacementSpec 

1051 

1052 if v is None: 

1053 return None 

1054 if isinstance(v, PlacementSpec): 

1055 json_str = v.to_json() 

1056 PlacementSpec.from_json(json_str) # re-validate a directly-built spec 

1057 return json_str 

1058 if isinstance(v, str): 

1059 if v.strip() == "": 

1060 return None 

1061 PlacementSpec.from_json(v) 

1062 return v 

1063 raise PlacementError("placement must be a JSON string or PlacementSpec") 

1064 

1065 @field_validator("semantic_chunking", mode="before") 

1066 @classmethod 

1067 def _parse_semantic_chunking(cls, v: Any) -> bool: 

1068 """Parse from env string; invalid values warn and fall back to False.""" 

1069 if isinstance(v, bool): 

1070 return v 

1071 if isinstance(v, str): 

1072 try: 

1073 return parse_bool(v) 

1074 except ValueError: 

1075 log.warning("Invalid LILBEE_SEMANTIC_CHUNKING=%r, using default False", v) 

1076 return False 

1077 return bool(v) 

1078 

1079 @field_validator( 

1080 "chat_model", "embedding_model", "vision_model", "reranker_model", mode="after" 

1081 ) 

1082 @classmethod 

1083 def _normalize_model_tag(cls, v: str, info: ValidationInfo) -> str: 

1084 """Validate and canonicalize a model ref; blank means the role is unconfigured.""" 

1085 if not v or not v.strip(): 

1086 return "" 

1087 from lilbee.providers.model_ref import parse_model_ref 

1088 

1089 return parse_model_ref(v).for_openai_prefix() 

1090 

1091 @field_validator("ollama_base_url", "lm_studio_base_url", mode="after") 

1092 @classmethod 

1093 def _strip_trailing_slash(cls, v: str) -> str: 

1094 """Canonicalize a local-server URL once at the write boundary.""" 

1095 return v.rstrip("/") 

1096 

1097 @field_validator("cors_origins", mode="before") 

1098 @classmethod 

1099 def _split_cors_origins(cls, v: Any) -> Any: 

1100 if isinstance(v, str): 

1101 return [o.strip() for o in v.split(",") if o.strip()] 

1102 return v 

1103 

1104 @field_validator("crawl_browser_extra_args", mode="before") 

1105 @classmethod 

1106 def _split_crawl_browser_extra_args(cls, v: Any) -> Any: 

1107 """Accept a newline-separated string, matching how the field is persisted. 

1108 

1109 ``app.settings`` joins list values with newlines before writing them to 

1110 ``config.toml`` as a scalar string. Without this inverse, reload cannot 

1111 coerce that string to ``list[str]`` and the whole config.toml is dropped. 

1112 TOML lists and JSON arrays pass through unchanged. 

1113 """ 

1114 if isinstance(v, str): 

1115 return [a.strip() for a in v.splitlines() if a.strip()] 

1116 return v 

1117 

1118 @field_validator("crawl_exclude_patterns", mode="before") 

1119 @classmethod 

1120 def _split_crawl_exclude_patterns(cls, v: Any) -> Any: 

1121 """Accept newline-separated strings from env vars / plain-text config. 

1122 

1123 Regex commonly uses commas (e.g. `{2,4}`) and pipes (alternation), so 

1124 newline is the only separator safe to use for this field. TOML lists 

1125 and JSON arrays pass through unchanged. 

1126 """ 

1127 if isinstance(v, str): 

1128 return [p.strip() for p in v.splitlines() if p.strip()] 

1129 return v 

1130 

1131 @field_validator("crawl_exclude_patterns", mode="after") 

1132 @classmethod 

1133 def _validate_crawl_exclude_patterns(cls, v: list[str]) -> list[str]: 

1134 """Reject any entry that isn't a valid Python regex. 

1135 

1136 These patterns are compiled at crawl time. An invalid pattern there 

1137 surfaces as an opaque mid-crawl error; catching it at PATCH time gives 

1138 the user a 400 with a pointer to the bad entry. 

1139 """ 

1140 import re 

1141 

1142 bad: list[str] = [] 

1143 for i, pattern in enumerate(v): 

1144 try: 

1145 re.compile(pattern) 

1146 except re.error as exc: 

1147 bad.append(f"[{i}] {pattern!r}: {exc}") 

1148 if bad: 

1149 raise ValueError("invalid regex in crawl_exclude_patterns:\n " + "\n ".join(bad)) 

1150 return v 

1151 

1152 @field_validator("ignore_dirs", mode="before") 

1153 @classmethod 

1154 def _merge_ignore_dirs(cls, v: Any) -> frozenset[str]: 

1155 if isinstance(v, str): 

1156 extra = frozenset(name.strip() for name in v.split(",") if name.strip()) 

1157 return DEFAULT_IGNORE_DIRS | extra 

1158 if isinstance(v, (set, frozenset, list)): 

1159 return DEFAULT_IGNORE_DIRS | frozenset(v) 

1160 return DEFAULT_IGNORE_DIRS 

1161 

1162 @field_validator("concept_allowed_ent_types", mode="before") 

1163 @classmethod 

1164 def _parse_ent_types(cls, v: Any) -> frozenset[str]: 

1165 """Replace-semantics override: a narrowed set is used as-is, 

1166 not unioned with defaults. A user asking for ``PERSON,ORG`` 

1167 wants exactly those kinds. Accepts comma-separated strings 

1168 from env and list / set / frozenset from code. Empty input 

1169 falls back to :data:`DEFAULT_ALLOWED_NER_LABELS` so an empty 

1170 env var does not silently disable the gate. 

1171 """ 

1172 if isinstance(v, str): 

1173 parts = frozenset(name.strip().upper() for name in v.split(",") if name.strip()) 

1174 return parts or DEFAULT_ALLOWED_NER_LABELS 

1175 if isinstance(v, (set, frozenset, list)): 

1176 parts = frozenset(str(x).upper() for x in v) 

1177 return parts or DEFAULT_ALLOWED_NER_LABELS 

1178 return DEFAULT_ALLOWED_NER_LABELS 

1179 

1180 @model_validator(mode="before") 

1181 @classmethod 

1182 def _resolve_defaults(cls, data: Any) -> Any: 

1183 from lilbee.core.system import ( 

1184 canonical_data_root, 

1185 canonical_models_dir, 

1186 default_data_dir, 

1187 find_local_root, 

1188 ) 

1189 

1190 if not isinstance(data, dict): 

1191 return data 

1192 

1193 # An empty LILBEE_DATA_ROOT (delivered as "") must fall through to default 

1194 # resolution like an unset one, not become Path(".") = the process cwd. 

1195 if isinstance(data.get("data_root"), str) and not data["data_root"].strip(): 

1196 data["data_root"] = None 

1197 if data.get("data_root") in (None, _UNSET_PATH): 

1198 data_env = os.environ.get("LILBEE_DATA", "").strip() 

1199 if data_env: 

1200 data["data_root"] = Path(data_env) 

1201 else: 

1202 local = find_local_root() 

1203 data["data_root"] = local if local is not None else default_data_dir() 

1204 # Every child path below derives from this, and the server lock keys on 

1205 # those, so canonicalizing here is what makes one directory key one lock. 

1206 # Also coerces a raw string (LILBEE_DATA_ROOT) to Path. 

1207 root = canonical_data_root(data["data_root"]) 

1208 data["data_root"] = root 

1209 if data.get("documents_dir") in (None, _UNSET_PATH): 

1210 data["documents_dir"] = root / "documents" 

1211 if data.get("data_dir") in (None, _UNSET_PATH): 

1212 data["data_dir"] = root / "data" 

1213 if data.get("lancedb_dir") in (None, _UNSET_PATH): 

1214 data["lancedb_dir"] = root / "data" / "lancedb" 

1215 if data.get("models_dir") in (None, _UNSET_PATH): 

1216 data["models_dir"] = canonical_models_dir() 

1217 

1218 return data 

1219 

1220 @classmethod 

1221 def settings_customise_sources( 

1222 cls, 

1223 settings_cls: type[BaseSettings], 

1224 init_settings: Any, 

1225 env_settings: Any, 

1226 dotenv_settings: Any, 

1227 file_secret_settings: Any, 

1228 ) -> tuple[Any, ...]: 

1229 from lilbee.core.system import canonical_data_root, default_data_dir, find_local_root 

1230 

1231 # .strip() to match _resolve_defaults; a padded value would otherwise 

1232 # send the root and its config.toml to different directories. 

1233 data_env = os.environ.get("LILBEE_DATA", "").strip() 

1234 if data_env: 

1235 toml_dir = Path(data_env) 

1236 else: 

1237 local = find_local_root() 

1238 toml_dir = local if local else default_data_dir() 

1239 # Same call as the root itself, so this looks where the root resolves to; 

1240 # a "~/lilbee" value would otherwise search a literal ./~ and find nothing. 

1241 toml_path = canonical_data_root(toml_dir) / "config.toml" 

1242 

1243 plain_env = _PlainEnvSource(settings_cls, env_prefix="LILBEE_", env_ignore_empty=True) 

1244 sources: list[Any] = [init_settings, plain_env] 

1245 if toml_path.exists() and os.environ.get("LILBEE_SKIP_TOML_CONFIG") != "1": 

1246 sources.append(_TomlSource(settings_cls, toml_path)) 

1247 return tuple(sources) 

1248 

1249 @property 

1250 def model_defaults(self) -> Any: 

1251 """Per-model generation defaults (read-only). Set via apply_model_defaults().""" 

1252 return self._model_defaults 

1253 

1254 def apply_model_defaults(self, defaults: Any) -> None: 

1255 """Store per-model generation defaults for 3-layer merge.""" 

1256 object.__setattr__(self, "_model_defaults", defaults) 

1257 

1258 def clear_model_defaults(self) -> None: 

1259 """Reset per-model defaults to None.""" 

1260 object.__setattr__(self, "_model_defaults", None) 

1261 

1262 def generation_options(self, **overrides: Any) -> dict[str, Any]: 

1263 """Merge model defaults, user config, and per-call overrides, dropping None.""" 

1264 result = _model_defaults_dict(self._model_defaults) 

1265 user_fields: dict[str, Any] = { 

1266 "temperature": self.temperature, 

1267 "top_p": self.top_p, 

1268 "top_k": self.top_k_sampling, 

1269 "repeat_penalty": self.repeat_penalty, 

1270 "num_ctx": self.num_ctx, 

1271 "seed": self.seed, 

1272 "max_tokens": self.max_tokens, 

1273 } 

1274 for k, v in user_fields.items(): 

1275 if v is not None: 

1276 result[k] = v 

1277 for k, v in overrides.items(): 

1278 if v is not None: 

1279 result[k] = v 

1280 return result 

1281 

1282 

1283def _model_defaults_dict(defaults: Any) -> dict[str, Any]: 

1284 """Non-None fields of a ModelDefaults instance as a dict.""" 

1285 if defaults is None: 

1286 return {} 

1287 from dataclasses import fields as dc_fields 

1288 

1289 return { 

1290 f.name: getattr(defaults, f.name) 

1291 for f in dc_fields(defaults) 

1292 if getattr(defaults, f.name) is not None 

1293 } 

1294 

1295 

1296class _PlainEnvSource: 

1297 """Reads LILBEE_* env vars as plain strings so field validators handle parsing.""" 

1298 

1299 def __init__( 

1300 self, 

1301 settings_cls: type[BaseSettings], 

1302 env_prefix: str, 

1303 env_ignore_empty: bool = True, 

1304 ) -> None: 

1305 self._prefix = env_prefix 

1306 self._ignore_empty = env_ignore_empty 

1307 self._fields = set(settings_cls.model_fields) 

1308 

1309 def __call__(self) -> dict[str, Any]: 

1310 result: dict[str, Any] = {} 

1311 for field_name in self._fields: 

1312 env_key = f"{self._prefix}{field_name.upper()}" 

1313 raw = os.environ.get(env_key) 

1314 if raw is None: 

1315 continue 

1316 if self._ignore_empty and raw == "": 

1317 continue 

1318 result[field_name] = raw 

1319 return result 

1320 

1321 

1322class _TomlSource: 

1323 """Custom pydantic-settings source that reads config.toml.""" 

1324 

1325 def __init__(self, settings_cls: type[BaseSettings], path: Path) -> None: 

1326 self._path = path 

1327 

1328 def __call__(self) -> dict[str, Any]: 

1329 import tomllib 

1330 

1331 try: 

1332 with self._path.open("rb") as f: 

1333 data = tomllib.load(f) 

1334 except (ValueError, OSError): 

1335 log.warning("Failed to read %s, ignoring", self._path) 

1336 return {} 

1337 # Empty strings represent "no persisted value" for nullable scalar 

1338 # fields (legacy from set_setting writing "" for None). Pydantic 

1339 # can't coerce "" to int|None, so dropping them here lets the field 

1340 # default apply rather than crashing the whole Config load. TOML's 

1341 # native types (lists, ints, bools) pass through untouched: stringifying 

1342 # turned a list field's ["a", "b"] into the literal "['a', 'b']". 

1343 return {k: v for k, v in data.items() if v != ""} 

1344 

1345 

1346def _build_cfg() -> tuple[Config, Exception | None]: 

1347 """Build cfg; on stale-config validation failure, fall back to defaults. 

1348 

1349 A persisted ``config.toml`` from before a breaking schema change can 

1350 contain values the new validators reject. Crashing at module import 

1351 means every command (``lilbee --help`` included) emits a Python 

1352 traceback. Falling back to env+defaults lets the package load; the 

1353 CLI / TUI surfaces the original error before doing real work. 

1354 """ 

1355 try: 

1356 return Config(), None 

1357 except Exception as exc: 

1358 os.environ["LILBEE_SKIP_TOML_CONFIG"] = "1" 

1359 try: 

1360 return Config(), exc 

1361 finally: 

1362 os.environ.pop("LILBEE_SKIP_TOML_CONFIG", None) 

1363 

1364 

1365cfg, config_load_error = _build_cfg() 

1366 

1367# Canonicalize LILBEE_DATA at the cfg.data_root resolution boundary so 

1368# spawn-context worker subprocesses inherit the same data root. 

1369# ``setdefault`` preserves a user-set value. 

1370os.environ.setdefault("LILBEE_DATA", str(cfg.data_root))