"""Application settings. Every setting can be overridden with an environment variable prefixed ``BOR_`` (or a local gitignored ``.env`` file — see ``.env.example``). """ from __future__ import annotations import os import re from functools import lru_cache from pydantic import ValidationInfo, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict #: The built-in DEFAULT import formats (PLAN anchor A9, revised 2026-08-21; #: revised 2026-08-27, owner permission — the full Podman quadlet family #: ``container, network, volume, image, pod, kube, swap, os, endpoint`` #: plus Jinja templates ``j2`` join the default, chunked as plain text). #: This is the default scope AND the ``.env.example`` example — it is NOT #: a ceiling: ``BOR_IMPORT_EXTENSIONS`` may name **any** well-formed #: extension (lowercase letters/digits, no dot) or narrow to a subset #: (owner permission 2026-08-31, phase 56); see #: :py:attr:`Settings.import_extensions`. _DEFAULT_IMPORT_EXTENSIONS: frozenset[str] = frozenset( { "md", "markdown", "txt", "yaml", "yml", "json", "py", # A9 revised 2026-08-27 (owner permission): quadlet family + jinja. "container", "network", "volume", "image", "pod", "kube", "swap", "os", "endpoint", "j2", } ) class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", env_prefix="BOR_", extra="ignore", ) # --- App --- app_name: str = "Brain of Reese" app_version: str = "0.1.0" environment: str = "development" log_level: str = "INFO" static_dir: str = "frontend" # --- HTTP response compression (phase 167, TODO L5) --- #: Byte floor for the selective gzip layer #: (``app/core/compression.py``, ``BOR_COMPRESS_MIN_BYTES``): responses #: of at least this many bytes are gzipped when the client accepts it #: (Starlette's ``GZipMiddleware`` owns the negotiation, the #: already-encoded skip and the ``Vary`` append). ``0`` = the feature #: is OFF — the middleware is not added at all (the kill switch); #: ``> 0`` = the Starlette minimum-size floor, default 860 — the #: classic nginx default, below which compression can INFLATE a body #: instead of shrinking it. A negative value is a typo — the validator #: fails startup loudly (the ``agent_max_rounds`` pattern). The SSE #: stream (``POST /api/chat``, A15) is never compressed at any value. compress_min_bytes: int = 860 # --- Response byte budget (phase 167, TODO L5) --- #: INFO/DEBUG cut for the per-response ``api method=… bytes=…`` line #: (``app/core/response_budget.py``, ``BOR_LOG_LARGE_RESPONSE_BYTES``). #: ``/api/*`` responses of at least this many bytes (the bytes the ROUTE #: produced, before compression) log at INFO; below it the line is #: DEBUG, i.e. invisible at the default ``BOR_LOG_LEVEL=INFO`` so the #: log stays readable while "which endpoint is heavy, right now" is #: still one ``grep ' :: api '`` away. Default 65536 (64 KB) — the #: "fine" bar of the phase-167 audit; the audit's 256 KB budget is what #: marks an offender (LOCKED A5: a log line and a script, never a 413). #: ``0`` = log EVERY ``/api/*`` response at INFO (the loud mode, for a #: one-off investigation); a negative value is a typo — the validator #: fails startup loudly (the ``agent_max_rounds`` pattern). log_large_response_bytes: int = 65536 # --- UI customization (phase 62, TODO L3) --- # Defaults are the phase-61 neutral copy — UNSET => byte-identical UI. # (Phase 91, task 03: the retired CSS-file theme env var is gone — # the admin Theme tab is the only theming surface; a leftover value # in a deployment's .env is simply ignored.) input_placeholder: str = "Ask me anything…" footer_text: str = "Powered by self-hosted models" # --- Database (PostgreSQL 17 + pgvector) --- database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese" #: Connection pool size for the primary Postgres engine (SEC-14-04). #: Default 5 — matches SQLAlchemy's built-in default. db_pool_size: int = 5 #: Maximum overflow connections beyond pool_size (SEC-14-04). #: Default 10 — matches SQLAlchemy's built-in default. db_pool_max_overflow: int = 10 #: Seconds before a pooled connection is recycled (SEC-14-04). #: Default 3600 (1 hour) — prevents stale connections. db_pool_recycle: int = 3600 # --- LLM (self-hosted, OpenAI-compatible "aipi" endpoint) --- llm_base_url: str = "https://aipi.reeseapps.com/v1" llm_api_key: str = "" llm_chat_model: str = "turbo" llm_embed_model: str = "embed" #: One-shot (non-streaming) completion model (A5 extended, phase 30): #: document summaries at import time and the KB overview (phase 31). #: Served by the same OpenAI-compatible endpoint — no new model #: management. Called via ``LLMClient.chat()``. llm_summary_model: str = "lite" #: The chat turn's thinking level (phase 178, #: ``BOR_LLM_CHAT_THINKING_LEVEL``; the phase-172 knob, renamed) — #: pi-style reasoning effort, free-form text exactly like #: ``llm_chat_model`` (no allowlist — the endpoint is the source of #: truth). Default ``low``. Rides the streaming chat-completions #: requests to aipi as the top-level ``reasoning_effort`` parameter #: (``app/rag/llm.py`` ``chat_stream``); the EMPTY value is the off #: switch — the parameter is not sent at all. llm_chat_thinking_level: str = "low" #: The one-shot summary thinking level (phase 178, #: ``BOR_LLM_SUMMARY_THINKING_LEVEL``) — rides the one-shot #: ``chat()`` calls (document summaries, folder summaries, KB #: overview, the ``check_models`` probe) as ``reasoning_effort``. #: Default EMPTY = the off switch (phase-172 A2): the parameter is #: not sent at all; the TODO's "none" means absence, never a #: literal "none" string. llm_summary_thinking_level: str = "" #: Operator kill-switch for the ``thinking`` SSE events (phase 17, #: ``BOR_STREAM_THINKING``; ``0``/``false`` → off). When off, thinking #: pieces are still counted for the per-turn log line but never #: emitted — the answer stream itself is unchanged. stream_thinking: bool = True #: Retries of a failed LLM request when the endpoint stops responding #: (phase 67, ``BOR_LLM_RETRIES``); ``0`` = no retries (the turn fails #: on the first error, pre-phase-67 behavior). llm_retries: int = 3 #: Flat seconds to wait between attempts (phase 67, #: ``BOR_LLM_RETRY_DELAY``); the TODO-locked 5 s, no backoff. llm_retry_delay: float = 5.0 #: HTTP timeout in seconds for LLM API calls (chat + embeddings). #: Increase when long prompt processing or slow models exceed the #: default 120 s (``BOR_LLM_TIMEOUT``; ``0`` = use the OpenAI SDK #: default, which is platform-dependent). llm_timeout: float = 120.0 # --- Chat history (phase 74, TODO L4: prior turns + prior thinking) --- #: Newest client-provided history turns kept per ``POST /api/chat`` #: (phase 74, ``BOR_HISTORY_MAX_TURNS``): the request's ``history`` #: (the client's prior turns, stateless per A10) is walked #: newest-first and the walk stops once this many turns are kept — #: the oldest turns are the ones dropped. ``0`` = no history (the #: pre-phase-74 two-message requests — the kill switch). history_max_turns: int = 40 #: Total char budget for the kept history (phase 74, #: ``BOR_HISTORY_MAX_CHARS``) — ``len(text) + len(thinking or "")`` #: per turn, so prior thinking blocks count against the same budget #: as the answer text. A turn that would overflow the remaining #: budget is dropped WHOLE (never cut mid-answer) and the walk stops #: there — the kept history is always a contiguous newest window. history_max_chars: int = 24_000 # --- RAG tuning --- embedding_dim: int = 768 # verified against aipi /v1 (embed model) #: Phase 118 retired the full-text seeding role (A6); the suggested #: tier (``select_suggested``) seeds the prompt now — kept for env #: back-compat (no ``app/`` consumer left). top_n_docs: int = 2 # Honesty gate (A8, re-tuned 2026-08-21): the ``embed`` model's cosine # scores compress into 0.41–0.84 on the real corpus, so the old 0.30 # default never discriminated. LOW only fires when best cosine < this # AND no candidate chunk matches the question lexically (see A8). relevance_threshold: float = 0.62 #: Lexical support floor (A8 revised 2026-09-14): an FTS hit promotes # a turn to HIGH only when the best cosine is >= this value — it # requires the vector signal to corroborate the lexical match. A # single weak token hit with vector-unsupported docs (cosine < floor) # stays LOW (deflected). Default 0.35 ≈ half the relevance threshold; # tunable via ``BOR_LEXICAL_SUPPORT_FLOOR``. Must be <= # ``relevance_threshold`` (a floor above the threshold is a typo that # would make every FTS hit require a HIGH cosine anyway). lexical_support_floor: float = 0.35 #: Phase 118 retired the full-text seeding role (A6); the suggested #: tier (``select_suggested``) seeds the prompt now (no floor, A3) — #: kept for env back-compat (no ``app/`` consumer left). #: Usefulness bar for the citation slot (phase 113, LOCKED A2): a #: retrieved document earns ``done.sources`` (the UI's citation chip) #: only when the **cosine** of its best hit chunk clears this floor — #: the vector signal must corroborate the citation, mirroring the A8 #: honesty gate's ``lexical_support_floor``. Documents that scored but #: stay below the bar are demoted to the secondary related-doc tier #: (at most ``related_max_docs``). ``top_n_docs`` is the CEILING for #: the cited tier, never a quota: a single strong document yields one #: citation. Default 0.35 — the same bar as ``lexical_support_floor``; #: tunable via ``BOR_SOURCE_USEFULNESS_FLOOR``. Must satisfy #: ``0 <= source_usefulness_floor <= relevance_threshold`` (a floor #: above the threshold would demote to the related tier documents the #: gate itself calls grounded — the ``lexical_support_floor`` typo #: guard). ``0`` disables the bar (every scored doc is citable — the #: pre-phase behavior, the kill switch). source_usefulness_floor: float = 0.35 #: Cap on the secondary related-doc tier (phase 113, LOCKED A4): #: documents that scored but did not clear ``source_usefulness_floor`` #: ride the ``done`` frame's ``related`` list (the UI's de-emphasized #: "nearby docs" row — never a citation chip). ``0`` = no related #: docs at all (the kill switch); a negative value fails startup #: loudly (the ``agent_max_rounds`` pattern). related_max_docs: int = 2 #: Cap on the "start here" suggestion tier (phase 118, LOCKED A3 — #: the owner directive, TODO L3): a grounded turn seeds the top-N #: related documents into the prompt as SUMMARY blocks (opt-in #: starting points, never citations) and the LLM extends its context #: by reading only what it needs. NO cosine floor applies — unlike #: the cited tier's ``source_usefulness_floor``, a lexical-only hit #: (cosine 0.0) is a valid starting point when it ranks. Default 5; #: tunable via ``BOR_SUGGESTED_DOCS``. A value below 1 is a typo — #: the validator fails startup loudly (the ``agent_max_rounds`` #: pattern). suggested_docs: int = 5 #: Preview cap for a suggestion block whose document summary is missing #: (phase 118, task 03, LOCKED A5): a NULL/blank ``doc.summary`` (a #: fail-soft import miss) falls back to the first ``suggestion_preview_chars`` #: characters of the document content plus the shared #: ``[…truncated…]`` marker — deterministic, no LLM call at chat time. #: Tolerates content at or under the cap whole (no marker — nothing was #: cut). ``0``/negative is a typo (empty preview) — the validator fails #: startup loudly (the ``agent_max_rounds`` pattern). suggestion_preview_chars: int = 400 #: Maximum output tokens a chat answer may use (owner instruction #: 2026-08-22: answers must run to their natural end — the old hard #: 700-token cap cut long answers off mid-sentence). max_output_tokens: int = 32_768 chunk_target_chars: int = 2_000 chunk_overlap_chars: int = 200 embed_batch_size: int = 16 #: Char budget for the chat turn's question embed (phase 114, TODO L6; #: LOCKED A1): the embed step embeds at most this many chars of the #: question — the default 1200 is the chunker's ``HARD_MAX_CHARS`` #: budget (``app/rag/chunker.py``: worst-case ~1.4 chars/token, so it #: stays under the endpoint's ~1024-token per-request input cap). Only #: the embedding is bounded: the FULL question still reaches the LLM #: prompt (prompt build untouched), and a question at or under the #: budget embeds byte-identically to the pre-phase path. A model with a #: larger/smaller cap is accommodated by env, no code change (A1). #: ``0``/negative is a typo — the validator fails loudly at startup #: (the ``agent_max_rounds`` pattern). embed_question_max_chars: int = 1200 #: Total char budget for the ```` section of the system prompt #: (phase 15, steering notes). The newest-fitting notes are kept and the #: overflow is replaced by the ``[…truncated…]`` marker. steering_max_chars: int = 8_000 #: Cap on the document content sent to the ``lite`` summary model in one #: call (phase 30, ``BOR_SUMMARY_MAX_CHARS``). Overflow is cut at the cap #: and the shared ``[…truncated…]`` marker is appended (see #: ``app.rag.summarizer``). summary_max_chars: int = 12_000 #: Char budget for the ```` section of the system prompt #: (phase 31: lite-generated KB overview, ``app.rag.overview``). The #: newest-fitting prefix of the stored outline is kept and the overflow #: is replaced by the shared ``[…truncated…]`` marker (phase 15 #: convention — ``app.rag.prompts``). kb_overview_max_chars: int = 4_000 #: Cap on the document list (source/path/title/first summary line per #: row) sent to the ``lite`` overview model in one call (phase 31, #: ``app.rag.overview``). Overflow is cut at the cap and the shared #: ``[…truncated…]`` marker is appended (summarizer convention). overview_input_max_chars: int = 40_000 #: Cap on the folder document list (path/title/first summary line per #: row) sent to the ``lite`` folder-summary model in ONE call #: (phase 94, ``app.rag.folder_summaries``). Overflow is cut at the #: cap and the shared ``[…truncated…]`` marker is appended #: (summarizer convention). Smaller than the KB-overview cap on #: purpose: a folder sees its own subtree only — there can be #: hundreds of folders, each summarized separately at sync time. folder_summary_input_max_chars: int = 8_000 #: Hard cap on the agent tool rounds per grounded turn (phase 45, #: revising phase 37's per-tool budgets — owner permission #: 2026-08-27, TODO L8: "allow the LLM to make as many tool calls #: as it wants"). Every tool call the model emits consumes a #: round; at the cap the loop forces one final no-tools answer. #: ``0`` disables the tools entirely — the turn is a single #: request with ``tools=None`` (the pre-phase-37 path — the kill #: switch). Negative values are rejected at startup (validator). agent_max_rounds: int = 10 #: Cap in characters on the agent ``read`` tool's result (phase 95, #: ``BOR_READ_MAX_CHARS``): a document LONGER than this is cut at the #: cap and the shared ``[…truncated…]`` marker plus the grep-pointer #: notice (``app.rag.agent``) are appended; a document at or under the #: cap is read whole, byte-identical to the pre-phase-95 result. Spec #: rationale (pinned): 128 000 chars ≈ **32 000 tokens** at the #: ~4-chars/token house estimate (``app.rag.llm``'s embed batching #: notes ~3 chars/token for code-dense text, 4 for prose) — a quarter #: of the 128k-token **minimum** context the owner's LLMs all have, so #: a truncated read still leaves ~96k tokens for the system prompt, the #: top-2 ````, the tool rounds, and the 32 768-token answer #: cap (``max_output_tokens``). Char-based (no tokenizer in the repo — #: the ``BOR_SUMMARY_MAX_CHARS`` precedent) and env-tunable in both #: directions. This is the ONLY truncated read path (owner permission #: 2026-09-10, ``TODO.md`` L5): A7's never-truncated contract is for #: the retrieval ```` path, which stays whole. read_max_chars: int = 128_000 #: Feature toggle for the verbatim text-display capability (phase 182, #: ``BOR_ANSWER_TEXT``; ``0``/``false`` = off). The single knob that #: turns the whole feature on or off. Gates the ANSWER side ONLY: while #: on, the grounded agent is offered the ``show_text`` display tool, #: the HIGH prompt carries its teaching clause, a `` ```bor `` locator #: fence in the answer is resolved server-side, and the resolved blocks #: ride the done frame's ``answer_text`` key (and the saved record's). #: While off, behavior is byte-identical everywhere — no ``show_text`` #: tool offered, no prompt clause, no locator resolution, no #: ``answer_text`` key in any frame or record. THE DEFAULT IS **TRUE** #: (owner decision 2026-10-14, LOCKED A8): unlike phase 124's #: ``answer_images`` (default off, a cosmetic extra), an answer whose #: code block silently differs from the file is a correctness bug, so #: the fix ships ON and this switch is an escape hatch, not an opt-in. answer_text: bool = True #: Cap in characters on ONE displayed verbatim slice (phase 182, #: ``BOR_SHOW_TEXT_MAX_CHARS``): the slice the server resolves for a #: ``show_text`` call or a `` ```bor `` locator fence is cut at the cap #: AT A LINE BOUNDARY, the shared ``[…truncated…]`` marker #: (``app/rag/retriever.py``) is appended and the caption's ``end_line`` #: shrinks to the last fully-shown line — the caption never lies (D4). #: Worst-case ``done``-frame growth is #: ``show_text_max_chars * show_text_max_blocks`` = 60 KB against the #: 256 KB response budget (phase 167's byte line makes it visible). A #: char cap like ``read_max_chars`` (no tokenizer in the repo); ``0`` #: is inert (nothing displays), a negative value is a typo and fails #: startup (validator). show_text_max_chars: int = 12_000 #: Cap on the verbatim blocks displayed per answer (phase 182, #: ``BOR_SHOW_TEXT_MAX_BLOCKS``): the ``show_text`` calls and #: `` ```bor `` locator fences of one turn, in ANSWER order, past this #: count are not displayed (D8). ``0`` is inert (no block displays — #: the feature renders as plain prose), a negative value is a typo and #: fails startup (the ``agent_max_rounds`` validator pattern). show_text_max_blocks: int = 5 # --- Chat concurrency (SEC-14-04, task 03) --- #: Maximum concurrent chat turns allowed (SEC-14-04). #: Default 10 — prevents pool saturation from too many simultaneous #: streams. When exceeded, new requests get a 503 error. chat_max_concurrent: int = 10 # --- Hybrid retrieval (A7, revised 2026-08-21) --- # cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion # (score = Σ 1/(rrf_k + rank) over the lists a chunk appears in). # # The vector window is deliberately wider than the lexical one: a # name-your-tool question's best *lexical* chunk (e.g. the "Install" # section of gitlab.md) can sit far down the vector ranking because the # question embeds close to generic templates. A 100-wide window is what # lets such chunks double-hit (one RRF term per list) and outrank a # template that owns vector rank 1 — measured 2026-08-22 against the # live 2774-chunk KB for "How did I install gitlab?" (gitlab.md:1 at # vrank 100 / lrank 3 → fused 0.0221 vs the template's 0.0164). hybrid_vector_candidates: int = 100 #: HNSW search depth for the cosine tier (phase 188, D3/LOCKED A4): #: ``SET LOCAL hnsw.ef_search`` immediately before #: ``app.rag.retriever._vector_candidates``'s SELECT — per transaction, #: so no request leaks state into another and no global GUC (nor #: connection-pool) setting is touched. A value BELOW the A7 candidate #: count cannot be honoured — it would return FEWER candidates than A7 #: promises (a silent recall regression) — so the retriever CLAMPS it up #: to the candidate count with one WARNING line instead. ``0`` is refused #: here for the same reason: the clamp would silently rewrite it, which #: is exactly what an operator reading ``0`` as "off" would not expect. #: Negative values fail startup loudly (the ``show_text_max_*`` validator #: pattern, phase 182). #: #: Why 400 and not the candidate count (task 04's live battery, the #: 1,000-document live replica, 8,866 real embeddings, the 7-question #: battery with the vectors PINNED so only the index differs): #: #: * ``ef_search = 100`` (``== LIMIT``, the floor the clamp enforces) #: recovers mean **0.9814** / min **0.94** of the exact top-100 — under #: this phase's own 0.99 recall floor (LOCKED A3: the remedy for a #: recall miss is a higher ``ef_search``, never a relaxed assertion). #: Production holds 47× the chunks, where a fixed ``ef_search`` is #: weaker still, not stronger. #: * ``ef_search = 400`` recovers **1.0000** of the exact top-100 with a #: max per-candidate ``top_score`` delta of exactly ``0.0`` — the #: honesty gate (A8) reads that number, and no verdict, top-5 set or #: suggested list moved at ANY depth measured. #: * it is also the depth at which the PLANNER actually uses the index on #: a small relation: at ``ef_search = 100`` Postgres costs the HNSW scan #: above a sequential one and full-scans anyway (measured 51.5 ms vs the #: forced-index 2.6 ms); at 400 the plan names #: ``ix_chunks_embedding_hnsw`` at 5.6 ms — still ~9× under the #: sequential scan this phase exists to remove. #: #: The A7 clamp floor stays at ``hybrid_vector_candidates`` (100): with a #: 400 default a stock install never warns. Postgres caps ``ef_search`` at #: 1000. retrieval_ef_search: int = 400 hybrid_lexical_candidates: int = 30 rrf_k: int = 60 #: Recency boost on the RRF-fused retrieval score (phase 106, D6): the #: MAXIMUM additive score a zero-age document gets — #: ``fused + recency_boost * exp(-age_days / recency_half_life_days)`` #: (``app.rag.retriever.apply_recency_boost``, applied in #: ``retrieve()`` after ``fuse()``). ``0`` = off — the pre-phase #: ranking is byte-identical (the kill switch); negative values fail #: startup loudly (the ``agent_max_rounds`` validator pattern). #: 0.0007 ≈ a 2-3 rank head start on a 60+ RRF scale (rank 1 vs 2 #: in one list differs by ~0.00026, rank 1 vs 10 by ~0.0021) — #: enough to break near-ties toward the newer document, far below #: the gap between a document that answers and one that merely #: resembles (the phase-106 fine-line battery pins the measured #: margin). The design starting point was 0.001; the battery's #: 3×-margin requirement tuned it down here (task 07 step 5) — on #: the k=60 scale a 0.001 boost would flip the pinned owner #: scenario (older-correct vs newer-similar). recency_boost: float = 0.0007 #: Age (days) over which the recency boost decays (phase 106, D6): #: the boost multiplies by ``e**-1`` ≈ 0.37 per ``recency_half_life_days`` #: of document age (full weight at age 0, ``weight/e`` at one #: half-life). ``<= 0`` fails startup loudly (same validator family). recency_half_life_days: int = 365 #: Bounded name-hit bonus on the SELECTION-time document score #: (phase 119, D2, LOCKED A3): the selection walks #: (``select_suggested`` / ``select_related`` / ``weak_hit_titles`` #: in ``app.rag.retriever``) add this to a document's best fused #: chunk score when any of its chunks is a NAME HIT (the document #: PATH matched a question name token under the D1 two-class rule) #: — a product-name question ("How do I deploy gitea?") lifts the #: product's own documents into the seeded suggestion tier. The #: phase-106 recency-boost precedent: additive, bounded, single #: apply site (the selection layer ONLY — chunk scores, ``fuse()``, #: ``retrieve()``, the A8 honesty gate, and ``query_log.top_score`` #: are untouched), ``0`` = off (the pre-phase walk returns #: byte-identical — the kill switch); a negative value fails startup #: loudly. 0.005 ≈ a 2-4 rank head start on the k=60 RRF scale #: (rank 1 vs 5 in one list differs by ~0.0010) — an owner-tunable #: starting point, not a calibrated constant (the phase-119 battery #: records the realized margins). name_hit_bonus: float = 0.005 #: (phase 191, D3, LOCKED A3): the SECOND, independent selection-time #: bonus — a document whose SOURCE is named by the question (the D2 #: rule over the ``source_labels`` row: a question token equals, in #: normalized form, a token of the source's display_name / label / #: tags) gets this added to its best fused chunk score in the SAME #: shared selection walk as the phase-119 ``name_hit_bonus`` (a #: separate knob: the two signals stay independently kill-able). #: Default 0.005 = the phase-119 starting point; ``0`` = off (the #: walk returns byte-identical to pre-phase — the kill switch); a #: negative value fails startup loudly. Bounded per DOCUMENT, applied #: ONCE, at most TWO bonuses on the fused scale (name_hit + source #: name_hit = 0.010 maximum head start — no amplification); the #: bonus lives in the SELECTION layer only — ``score`` / ``cosine`` / #: ``fts_hit`` / ``query_log.top_score`` are untouched (the #: phase-119 LOCKED A3 clause, mirrored). source_name_hit_bonus: float = 0.005 # --- Admin & sign-in (phase 16; A10 revised 2026-08-22) --- # Single-admin auth via a signed session cookie (Starlette # SessionMiddleware — no new services, no DB tables). Both secrets are # REQUIRED at startup: ``create_app()`` refuses to boot when either is # empty (``app.core.auth.ensure_admin_configured``). The password is # plaintext on purpose (homelab scope, owner decision 2026-08-22); # the session secret signs the cookie (``secrets.token_hex(32)``). admin_password: str = "" session_secret: str = "" #: Signed-cookie lifetime in seconds (default 12 h, refreshed on #: session writes — sliding for an active admin). session_max_age: int = 43_200 session_cookie: str = "bor_session" # --- Import scope (A9 default; any extension allowed — phase 56) --- # Comma-separated list of lowercased file extensions (no dot) imported # by ``scripts/import_docs.py``. **Any** well-formed extension is # allowed (lowercase letters/digits, 1-16 chars — the shape guard # doubles as the typo guard); the value below is the built-in default # (the A9 family, incl. the quadlet family + ``j2``) and the documented # example in ``.env.example``. Hidden (dot) path components are always # skipped, plus the importer's exclusion list. A token also matches # extensionless files whose lowercased full filename equals it exactly # (``dockerfile`` → ``Dockerfile``), case-insensitive, no partial # names (phase 102). # Stored as a raw CSV string (env-native — no JSON) and parsed on demand # via :py:meth:`import_extension_set`. The validator rejects an empty # list and malformed tokens so a typo fails loudly at startup (it can # no longer reject a novel extension). import_extensions: str = ( "md,markdown,txt,yaml,yml,json,py," "container,network,volume,image,pod,kube,swap,os,endpoint,j2" ) #: List of git repo URLs to clone/pull into ``sources_dir`` before #: indexing (phase 28); comma-separated, stored raw. Empty means no git #: sources — ``import_docs`` then falls back to ``--source`` / the old #: ``DEFAULT_SOURCES``. git_sources: str = "" #: Where ``import_docs`` clones/pulls the ``git_sources`` repos (phase #: 28). Stored as a raw string — ``Path.expanduser()`` is applied in #: the import script, not here. sources_dir: str = "~/bor-sources" #: Where uploaded source archives are unpacked (phase 49) — one #: subdirectory per source name (filename minus the archive suffix). #: Deliberately kept **separate** from ``sources_dir`` (the git #: checkouts). Raw string — ``Path.expanduser()`` is applied by the #: upload endpoint, not here. upload_dir: str = "~/bor-sources/uploads" #: Cap in MiB for uploaded source archives (phase 49): it bounds BOTH #: the compressed upload size and the total extracted bytes (the #: zip-bomb guard). ``<= 0`` would reject every upload — a typo, so #: the validator fails loudly at startup (the ``agent_max_rounds`` #: pattern). upload_max_mb: int = 512 #: Files between identity-map releases inside #: ``app.rag.importer.import_sources`` (phase 184, #: ``BOR_SYNC_SESSION_RESET_FILES``): every Nth file of the walk — at #: the one point where no object from a finished file is reachable — #: the run's session calls ``expunge_all()``, so whatever the walk #: still holds goes before the next file is read. ``0`` = NEVER reset #: — the exact pre-phase-184 behaviour (the operator's no-redeploy #: rollback, the ``BOR_LOG_LARGE_RESPONSE_BYTES`` precedent). A #: negative value is a typo — the validator fails startup loudly (the #: ``agent_max_rounds`` pattern). Read per run (the importer reads #: ``llm.settings``, so a saved override applies to the NEXT sync). sync_session_reset_files: int = 200 #: Pruned documents per commit inside ``app.rag.importer._prune`` #: (phase 184, ``BOR_SYNC_PRUNE_BATCH``): the delete loop commits #: every Nth document instead of once at the end of the whole import, #: so a mass prune releases its rows as it goes. ``0`` = ONE commit at #: the end — the exact pre-phase-184 behaviour (the same rollback #: knob). A negative value is a typo — the validator fails startup #: loudly (the ``agent_max_rounds`` pattern). Note the deliberate #: atomicity change of a non-zero batch (phase 184 A2): a crash #: mid-prune leaves a PREFIX pruned, and the next sync converges #: because ``seen`` is recomputed from the filesystem every run. sync_prune_batch: int = 500 #: Predict the run's change set BEFORE the walk, so the sync's running #: count is the count of the WORK and not of the walk (phase 194, #: ``BOR_SYNC_PENDING_COUNT``). ``1`` (default): ``import_sources`` #: pre-scans the importable files (one streamed sha256 + one scalar #: ``documents.file_hash`` read each, off the event loop) and the #: per-file ``progress`` hook then reports ``done``/``total`` as the #: position in that PENDING set — the owner's "1216 remaining" fix. #: ``0`` = the exact phase-193 behaviour (the stats-only pre-walk and #: ``done = summary.files``, i.e. "every file left to WALK") — the #: operator's no-redeploy rollback if the extra read cost ever bites. #: A negative value is a typo — the validator fails startup loudly #: (the ``agent_max_rounds`` pattern). Read per run (the importer reads #: ``llm.settings`` once per sync, so a saved override applies to the #: NEXT sync) and only by a caller that passes ``progress`` — the CLI #: and the upload job pay nothing. sync_pending_count: int = 1 # --- Image documents (phase 122: standalone images as documents) --- #: Master switch for image-document indexing (phase 122, #: ``BOR_IMAGES``; ``0``/``false`` = off — the DEFAULT, LOCKED A3). #: Enable only when ``llm_chat_model`` supports vision: image #: descriptions are generated by the chat model, and the description #: is the ONLY part of an image that gets indexed (the embedding #: model never sees pixels). While off, the import walks ignore #: image files and a sync never prunes existing ``is_image`` #: documents (the phase-122 prune guard — the image is invisible to #: an images-off walk, not a deleted file). images: bool = False #: Comma-separated, case-insensitive file extensions (no dot) treated #: as standalone images when ``images`` is on (phase 122, #: ``BOR_IMAGE_EXTENSIONS``). Stored as a raw CSV string (the #: ``import_extensions`` house convention) and parsed on demand via #: :py:meth:`image_extension_set`. A SEPARATE set from #: ``import_extension_set`` — images are never user-added via #: ``BOR_IMPORT_EXTENSIONS`` (the ``images`` toggle is the single #: knob). The validator rejects an empty list and malformed tokens, #: exactly like ``import_extensions`` (a typo would otherwise index #: zero images silently). #: #: ``svg`` (phase 181, LOCKED A1) is the one VECTOR member of the #: family: an ``.svg`` takes the same image branch (bytes copied to #: ``image_dir``, ``is_image``/``image_path``, served as #: ``image/svg+xml``) but NEVER the vision call — its searchable #: text comes from the file itself #: (:func:`app.rag.svg_text.extract_svg_text`, the ```` / #: ``<desc>`` / ``<text>`` words or an honest structural line). #: ``images`` remains the SINGLE knob (LOCKED A7): with it off an #: ``.svg`` stays a plain text document (raw XML in the viewer) and #: the walk never admits it as an image — no special-casing. image_extensions: str = "png,jpg,jpeg,webp,gif,bmp,svg" #: Where ingested image bytes are copied for serving (phase 122, #: ``BOR_IMAGE_DIR``). Raw string — ``Path.expanduser()`` is applied #: by the importer, not here (the ``sources_dir``/``upload_dir`` #: convention). Deliberately separate from ``sources_dir`` (git #: checkouts, re-cloned) and ``upload_dir`` (replaced on every #: upload): the served copy must outlive the source file. image_dir: str = "~/bor-sources/images" #: Where rendered PDF PAGE PNGs are copied for serving (phase 148, #: ``BOR_PDF_PAGE_DIR`` — the ``image_dir`` convention): a #: page-qualifying PDF (the phase-133 fallback class — unusable #: text layer — or the phase-140 pictorial class — the sparse-page #: pass described ≥1 page) gets EVERY page rendered at index time #: to ``<doc-id>-p{N}.png`` here (N 1-based; ``documents.page_count`` #: is the page count — a per-page render failure leaves a gap), #: so the document viewer shows the ACTUAL pages instead of the #: page descriptions (TODO L5 — the IKEA assembly-instruction #: class: the descriptions help the embeddings, "but not when #: shown to the user"). Raw string — ``Path.expanduser()`` is #: applied by the importer, not here (the ``sources_dir``/ #: ``upload_dir``/``image_dir`` convention). Deliberately separate #: from ``sources_dir``/``upload_dir``: git checkouts are #: re-cloned, uploads are replaced, local dirs are user-edited — #: the served copy must outlive the source file (the ``image_dir`` #: rationale). pdf_page_dir: str = "~/bor-sources/pages" # --- Pictorial PDF pages (phase 140: sparse pages rendered + vision-described) --- #: Master switch for the pictorial PDF pass (phase 140, LOCKED A3, #: ``BOR_PDF_PICTORIAL``; ``0``/``false`` = off). While on, a PDF #: whose text layer passes the phase-133 usability gate ALSO gets the #: sparse-page pass: every page whose stripped text is under #: :attr:`pdf_pictorial_min_chars` chars (``app.rag.pdf.pictorial_pages`` #: — the deterministic gate, LOCKED A1) is rendered to a PNG and #: vision-described, and the description appends to that page's #: indexed content block (``[Page N illustration: …]``, LOCKED A4) — #: the embeddings then carry what the thin text layer couldn't (the #: IKEA assembly-instruction class: 20 of 24 pages are 4–73 chars of #: markers around vector pictograms). NO page cap: every candidate #: page is described (PLAN A21 — the LLM calls are free; a slower #: sync is acceptable, a lower-quality KB is not). While off, a #: PDF's indexed content is byte-identical to pre-phase-140 (the #: ``BOR_IMAGES`` naming pattern — the kill switch). pdf_pictorial: bool = True #: Sparseness bar for the pictorial pass (phase 140, LOCKED A1, #: ``BOR_PDF_PICTORIAL_MIN_CHARS``): a page whose STRIPPED text is #: STRICTLY shorter than this many characters is a pictorial #: candidate — a page with exactly this many stripped chars is NOT #: sparse (the boundary is pinned in the unit tests). Default 100 — #: the IKEA fixture's sparse pages run 4–73 chars and its text-heavy #: pages 3,091+, so 100 sits between the classes. ``0`` = every page #: is a candidate (PLAN A21); a negative value is a typo — the #: validator fails startup loudly (the ``agent_max_rounds`` pattern). pdf_pictorial_min_chars: int = 100 # --- PDF embedded images (phase 151: the blank/decorative gate) --- #: Skip blank/decorative PDF-embedded images (phase 151, task 04, #: ``BOR_PDF_IMAGE_SKIP_BLANK``; ``0``/``false`` = off — the kill #: switch, the ``pdf_pictorial``/``images`` naming pattern). While #: on (the DEFAULT) the parser's blank/decorative gate #: (``app.rag.pdf.is_blank_image`` — near-white AND nearly uniform, #: owner-locked A2) drops the near-white underlays (the table- #: shading "blank white" images, TODO L4) from the embedded-image #: pass: no row, no copy, no vision call. While off, EVERY #: extracted image is indexed again — the pre-phase-151 behavior #: (a KB of scanned/decorative pages where a near-white image #: still matters). Dedupe (task 01) and soft-mask compositing #: (task 02) are UNAFFECTED by this knob — they are correctness #: fixes. A plain bool like ``pdf_pictorial``/``images`` — no #: validator. pdf_image_skip_blank: bool = True # --- Chat image questions (phase 123: attach an image to a question) --- #: Where a user's question image bytes are stored (phase 123, #: ``BOR_CHAT_IMAGE_DIR`` — the ``image_dir`` convention: a sibling of #: phase 122's document-image dir, separate because question-images #: are per-conversation, not per-source). Raw string — #: ``Path.expanduser()`` is applied by the upload/serve routes, not #: here. Files land as ``<uuid4().hex>.<ext>`` — the uuid is the #: credential (no enumeration value; the saved/shared chat record #: carries the served path, never base64, LOCKED A5). chat_image_dir: str = "~/bor-sources/chat-images" #: Cap in MiB for one question-image upload (phase 123, LOCKED A5 — #: the ~10 MB cap; ``BOR_CHAT_IMAGE_MAX_MB``). ``<= 0`` would reject #: every upload — a typo, so the validator fails loudly at startup #: (the ``upload_max_mb`` pattern). chat_image_max_mb: int = 10 # --- Answer images (phase 124: the LLM shows a supporting image in the answer) --- #: Feature toggle for the answer-image capability (phase 124, #: ``BOR_ANSWER_IMAGES``; ``0``/``false`` = off — the DEFAULT, #: LOCKED A2). The single knob that turns the whole feature on or #: off. Gates the ANSWER side ONLY: while on, the grounded agent is #: offered the ``show_image`` tool, the HIGH prompt carries its #: teaching clause, and a shown doc rides the done frame's #: ``answer_image`` key (and the saved record's). While off, #: behavior is byte-identical everywhere — no ``show_image`` tool #: offered, no prompt clause, no ``answer_image`` key in any frame #: or record. INDEPENDENT of ``images``/``BOR_IMAGES`` (LOCKED A2): #: that toggle indexes image documents, this one decides whether #: the model may show one in an answer — ``on`` with ``images`` off #: is a coherent (if image-less) state: the tool is offered, finds #: no image docs, and always refuses. answer_images: bool = False # --- Docs push (phase 59: save a chat answer as documentation) --- #: The git repo a saved chat answer is committed to (phase 59, D3): #: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a #: local path (generic git remote — no ``gh``, no GitHub assumption). #: While empty the feature is inert: the "Save as doc" action is #: hidden and the push endpoint 409s (the optional-feature pattern of #: the git-sources env fallback). docs_repo: str = "" #: Docs-push remote username (phase 173, ``BOR_DOCS_USER``) — paired #: with ``docs_token``; travels to git as the ``GIT_CURL_USERNAME`` #: environment variable (``app/core/docs_push.py``, task 03) — never #: in the command line. Empty (default) = no credentials: the push #: is anonymous/URL-credential-free, exactly as before this phase. docs_user: str = "" #: Docs-push remote token (phase 173, ``BOR_DOCS_TOKEN``) — write-only #: (catalogue ``secret=True``): the API reports ``has_value`` and never #: the value (``value=None`` while masked); the admin panel renders #: it masked. Travels to git as #: ``GIT_CURL_PASSWORD`` (``app/core/docs_push.py``, task 03). #: Empty (default) = the credential env is not set at all. docs_token: str = "" #: The branch pushes land on (phase 59): each push cuts it fresh from #: ``docs_base_branch`` and ``git push --ff-only``s it — the owner #: opens the PR themselves (D3: no PR tooling). A git branch token, #: so no whitespace and no ``..`` (the validator below — #: all-or-nothing with ``docs_repo``). docs_branch: str = "bor-docs" #: The branch each push bases off (fetched/reset before the #: ``checkout -B`` of ``docs_branch``). Same token shape rules as #: ``docs_branch``. docs_base_branch: str = "main" #: Where ``docs_repo`` is checked out on the server. Raw string — #: ``Path.expanduser()`` is applied by the push service, not here #: (the ``sources_dir``/``upload_dir`` convention). Deliberately kept #: separate from ``sources_dir`` (the source checkouts). docs_work_dir: str = "~/bor-docs" @field_validator("lexical_support_floor") @classmethod def _lexical_support_floor_bounds(cls, v: float, info: ValidationInfo) -> float: """The lexical support floor must be in [0, relevance_threshold]. A value above the relevance threshold would be a typo — it would make every FTS hit require a HIGH cosine anyway, defeating the purpose of the floor (A8 revised 2026-09-14).""" if v < 0: raise ValueError("lexical_support_floor must be >= 0") threshold = info.data.get("relevance_threshold") if isinstance(threshold, float) and v > threshold: raise ValueError( f"lexical_support_floor ({v}) must be <= relevance_threshold ({threshold})" ) return v @field_validator("source_usefulness_floor") @classmethod def _source_usefulness_floor_bounds(cls, v: float, info: ValidationInfo) -> float: """The usefulness bar must be in [0, relevance_threshold]. A value above the relevance threshold would be a typo — it would demote to the related tier documents the honesty gate itself calls grounded (the ``lexical_support_floor`` typo guard, phase 113).""" if v < 0: raise ValueError("source_usefulness_floor must be >= 0") threshold = info.data.get("relevance_threshold") if isinstance(threshold, float) and v > threshold: raise ValueError( f"source_usefulness_floor ({v}) must be <= relevance_threshold ({threshold})" ) return v @field_validator("related_max_docs") @classmethod def _related_max_docs_non_negative(cls, v: int) -> int: """``0`` is the no-related-docs kill switch — a negative cap is a typo (the ``agent_max_rounds`` pattern, phase 113).""" if v < 0: raise ValueError("related_max_docs must be >= 0 (0 = no related docs)") return v @field_validator("suggested_docs") @classmethod def _suggested_docs_at_least_one(cls, v: int) -> int: """The suggestion tier always seeds at least one summary block — ``0`` (no starting points) and negatives are typos (the ``agent_max_rounds`` pattern, phase 118).""" if v < 1: raise ValueError("suggested_docs must be >= 1") return v @field_validator("suggestion_preview_chars") @classmethod def _suggestion_preview_chars_positive(cls, v: int) -> int: """``0``/negative would preview an empty/absent prefix — fail loud at startup (the ``agent_max_rounds`` pattern, phase 118).""" if v <= 0: raise ValueError("suggestion_preview_chars must be > 0 (chars)") return v @field_validator("import_extensions") @classmethod def _import_extensions_known(cls, v: str) -> str: """Reject an empty list or malformed tokens loudly instead of silently importing nothing (a typo like ``md,jsonn`` would otherwise walk zero files). Any well-formed extension is accepted — the A9 family is the default, not a ceiling (owner permission 2026-08-31, phase 56).""" exts = {part.strip().lstrip(".").lower() for part in v.split(",") if part.strip()} if not exts: raise ValueError("import_extensions must name at least one format") malformed = sorted( ext for ext in exts if re.fullmatch(r"[a-z0-9]{1,16}", ext) is None ) if malformed: raise ValueError( f"import_extensions contains malformed token(s): {', '.join(malformed)} — " "each extension must be lowercase letters/digits only, 1-16 chars, no dot" ) return v @field_validator("image_extensions") @classmethod def _image_extensions_known(cls, v: str) -> str: """Reject an empty list or malformed tokens loudly (the ``import_extensions`` precedent, phase 122): a typo like ``png,jpeb`` would otherwise index zero images silently.""" exts = {part.strip().lstrip(".").lower() for part in v.split(",") if part.strip()} if not exts: raise ValueError("image_extensions must name at least one format") malformed = sorted( ext for ext in exts if re.fullmatch(r"[a-z0-9]{1,16}", ext) is None ) if malformed: raise ValueError( f"image_extensions contains malformed token(s): {', '.join(malformed)} — " "each extension must be lowercase letters/digits only, 1-16 chars, no dot" ) return v @field_validator("agent_max_rounds") @classmethod def _agent_max_rounds_non_negative(cls, v: int) -> int: """``0`` is the no-tools kill switch — a negative value is a typo.""" if v < 0: raise ValueError("agent_max_rounds must be >= 0 (0 = no tools)") return v @field_validator("read_max_chars") @classmethod def _read_max_chars_non_negative(cls, v: int) -> int: """A negative cap is a typo — it would slice from the END of the content (negative indexing) instead of failing. Fail loud at startup (the ``agent_max_rounds`` pattern). ``0`` is legal (every non-empty read truncates to the marker + notice).""" if v < 0: raise ValueError("read_max_chars must be >= 0 (chars)") return v @field_validator("show_text_max_chars") @classmethod def _show_text_max_chars_non_negative(cls, v: int) -> int: """A negative per-block cap is a typo — it would slice from the END of the content (negative indexing) instead of failing. Fail loud at startup, naming the field (the ``read_max_chars`` pattern, phase 182). ``0`` is legal and inert (no block displays).""" if v < 0: raise ValueError("show_text_max_chars must be >= 0 (chars)") return v @field_validator("show_text_max_blocks") @classmethod def _show_text_max_blocks_non_negative(cls, v: int) -> int: """A negative per-answer block cap is a typo — it would count down from the END of the block list instead of failing. Fail loud at startup, naming the field (the ``agent_max_rounds`` pattern, phase 182). ``0`` is legal and inert (no block displays).""" if v < 0: raise ValueError("show_text_max_blocks must be >= 0 (blocks)") return v @field_validator("pdf_pictorial_min_chars") @classmethod def _pdf_pictorial_min_chars_non_negative(cls, v: int) -> int: """``0`` is the every-page-is-a-candidate mode (PLAN A21 — the LLM calls are free) — a negative threshold is a typo (the ``agent_max_rounds`` pattern, phase 140).""" if v < 0: raise ValueError( "pdf_pictorial_min_chars must be >= 0 (0 = every page is a candidate)" ) return v @field_validator("retrieval_ef_search") @classmethod def _retrieval_ef_search_positive(cls, v: int) -> int: """``0``/negative is a typo, not a switch (phase 188, D3). There is no ``ef_search=0`` to turn the index off — the retriever clamps any value below the A7 candidate count UP to it, so a ``0`` that read as "off" would silently become 100 instead of failing. Fail loud at startup, naming the field (the ``show_text_max_chars`` pattern).""" if v <= 0: raise ValueError( "retrieval_ef_search must be > 0 (HNSW ef_search; the " "retriever clamps values below the candidate count up to it)" ) return v @field_validator("embed_question_max_chars") @classmethod def _embed_question_max_chars_positive(cls, v: int) -> int: """``0``/negative would embed an empty/absent prefix — fail loud at startup (the ``agent_max_rounds`` pattern, phase 114).""" if v <= 0: raise ValueError("embed_question_max_chars must be > 0 (chars)") return v @field_validator("llm_retries") @classmethod def _llm_retries_non_negative(cls, v: int) -> int: """``0`` is the no-retry kill switch (pre-phase-67 behavior) — a negative value is a typo (the ``agent_max_rounds`` pattern).""" if v < 0: raise ValueError("llm_retries must be >= 0 (0 = no retries)") return v @field_validator("llm_retry_delay") @classmethod def _llm_retry_delay_non_negative(cls, v: float) -> float: """A negative delay is a typo — fail loud at startup (the ``agent_max_rounds`` pattern).""" if v < 0: raise ValueError("llm_retry_delay must be >= 0 (seconds)") return v @field_validator("upload_max_mb") @classmethod def _upload_max_mb_positive(cls, v: int) -> int: """``0``/negative would reject every upload — fail loud at startup.""" if v <= 0: raise ValueError("upload_max_mb must be > 0 (MiB)") return v @field_validator("sync_session_reset_files") @classmethod def _sync_session_reset_files_non_negative(cls, v: int) -> int: """``0`` is the pre-phase-184 rollback (never reset) — a negative value is a typo that would make the cadence comparison constant, so it fails startup naming the field (the ``agent_max_rounds`` pattern, phase 184).""" if v < 0: raise ValueError( "sync_session_reset_files must be >= 0 (0 = never reset the" " import session)" ) return v @field_validator("sync_prune_batch") @classmethod def _sync_prune_batch_non_negative(cls, v: int) -> int: """``0`` is the pre-phase-184 rollback (one commit at the end of the prune) — a negative value is a typo that would make the batch comparison constant, so it fails startup naming the field (the ``agent_max_rounds`` pattern, phase 184).""" if v < 0: raise ValueError( "sync_prune_batch must be >= 0 (0 = one commit at the end of" " the prune)" ) return v @field_validator("sync_pending_count") @classmethod def _sync_pending_count_non_negative(cls, v: int) -> int: """``0`` is the phase-193 rollback (count the walk, not the work) — a negative value is a typo that no branch of the importer can mean, so it fails startup naming the field (the ``agent_max_rounds`` pattern, phase 194).""" if v < 0: raise ValueError( "sync_pending_count must be >= 0 (0 = count the whole walk, " "the pre-phase-194 behaviour)" ) return v @field_validator("chat_image_max_mb") @classmethod def _chat_image_max_mb_positive(cls, v: int) -> int: """``0``/negative would reject every question-image upload — fail loud at startup (the ``upload_max_mb`` precedent, phase 123).""" if v <= 0: raise ValueError("chat_image_max_mb must be > 0 (MiB)") return v @field_validator("history_max_turns") @classmethod def _history_max_turns_non_negative(cls, v: int) -> int: """``0`` is the no-history kill switch (pre-phase-74 two-message requests) — a negative value is a typo (the ``agent_max_rounds`` pattern).""" if v < 0: raise ValueError("history_max_turns must be >= 0 (0 = no history)") return v @field_validator("history_max_chars") @classmethod def _history_max_chars_non_negative(cls, v: int) -> int: """``0`` is the no-history kill switch (pre-phase-74 two-message requests) — a negative value is a typo (the ``agent_max_rounds`` pattern).""" if v < 0: raise ValueError("history_max_chars must be >= 0 (chars)") return v @field_validator("recency_boost") @classmethod def _recency_boost_non_negative(cls, v: float) -> float: """``0`` is the kill switch (pre-phase ranking byte-identical) — a negative boost would demote fresh documents, the exact opposite of D6 (the ``agent_max_rounds`` pattern, phase 106).""" if v < 0: raise ValueError("recency_boost must be >= 0 (0 = off)") return v @field_validator("recency_half_life_days") @classmethod def _recency_half_life_days_positive(cls, v: int) -> int: """``0``/negative would divide the decay exponent by zero — fail loud at startup (the ``agent_max_rounds`` pattern, phase 106).""" if v <= 0: raise ValueError("recency_half_life_days must be > 0 (days)") return v @field_validator("name_hit_bonus") @classmethod def _name_hit_bonus_non_negative(cls, v: float) -> float: """``0`` is the byte-identical kill switch (the pre-phase selection order) — a NEGATIVE bonus would demote name-hit documents, the exact opposite of D2 (the ``agent_max_rounds`` pattern, phase 119).""" if v < 0: raise ValueError("name_hit_bonus must be >= 0 (0 = off)") return v @field_validator("source_name_hit_bonus") @classmethod def _source_name_hit_bonus_non_negative(cls, v: float) -> float: """``0`` is the byte-identical kill switch (the pre-phase-191 selection order) — a NEGATIVE bonus would demote source-named documents, the exact opposite of D2/D3 (the ``name_hit_bonus`` pattern, phase 191).""" if v < 0: raise ValueError("source_name_hit_bonus must be >= 0 (0 = off)") return v @field_validator("db_pool_size") @classmethod def _db_pool_size_positive(cls, v: int) -> int: """``0``/negative would create an unusable pool — fail loud at startup (the ``agent_max_rounds`` pattern, SEC-14-04).""" if v < 1: raise ValueError("db_pool_size must be >= 1") return v @field_validator("db_pool_max_overflow") @classmethod def _db_pool_max_overflow_non_negative(cls, v: int) -> int: """A negative overflow is a typo — fail loud at startup (the ``agent_max_rounds`` pattern, SEC-14-04).""" if v < 0: raise ValueError("db_pool_max_overflow must be >= 0") return v @field_validator("chat_max_concurrent") @classmethod def _chat_max_concurrent_positive(cls, v: int) -> int: """``0``/negative would block every chat turn — fail loud at startup (the ``agent_max_rounds`` pattern, SEC-14-04).""" if v < 1: raise ValueError("chat_max_concurrent must be >= 1") return v @field_validator("compress_min_bytes") @classmethod def _compress_min_bytes_non_negative(cls, v: int) -> int: """``0`` is the compression kill switch (the middleware is not added at all) — a negative value is a typo (the ``agent_max_rounds`` pattern, phase 167).""" if v < 0: raise ValueError("compress_min_bytes must be >= 0 (0 = compression disabled)") return v @field_validator("log_large_response_bytes") @classmethod def _log_large_response_bytes_non_negative(cls, v: int) -> int: """``0`` is the loud mode (every ``/api/*`` response logs its byte line at INFO) — a negative value is a typo that would make the comparison constant, so it fails startup naming the field (the ``agent_max_rounds`` pattern, phase 167).""" if v < 0: raise ValueError( "log_large_response_bytes must be >= 0 (0 = log every /api/* response)" ) return v @field_validator("docs_branch", "docs_base_branch") @classmethod def _docs_branch_tokens(cls, v: str, info: ValidationInfo) -> str: """Git branch-token shape guard (phase 59, D3) — all-or-nothing: while ``docs_repo`` is empty the feature is inert, so the (ignored) branch values must not block startup; once a repo IS set, a blank / whitespace-bearing / ``..``-bearing branch is a typo that would corrupt a ``git checkout`` argument, so it fails loudly at startup (the ``agent_max_rounds`` pattern), naming the field.""" repo = info.data.get("docs_repo") if not isinstance(repo, str) or not repo.strip(): return v name = info.field_name or "docs branch" if not v.strip(): raise ValueError(f"{name} must not be empty while docs_repo is set") if re.search(r"\s", v): raise ValueError(f"{name} must not contain whitespace (a git branch token)") if ".." in v: raise ValueError(f"{name} must not contain '..' (a git branch token)") return v # Onboarding-chip SEED (phase 80, TODO.md L6): shown ONLY while no # saved chat has ever asked a question — after that, # ``GET /api/suggestions`` serves the opening questions of the 3 # most recent saved chats (the session openers — a chat's first # user question; follow-ups never chip — phase 103; deployment- # wide, newest first). ``BOR_SUGGESTIONS`` overrides this seed # for a new deployment. suggestions: list[str] = [ "What documents are in the knowledge base?", "Which source does each answer come from?", "How do I add a new source?", "Summarize the most recent document.", ] @property def import_extension_set(self) -> frozenset[str]: """Lowercased, dotted extension set (``.md``) for path filtering.""" return frozenset( f".{part.strip().lstrip('.').lower()}" for part in self.import_extensions.split(",") if part.strip() ) @property def image_extension_set(self) -> frozenset[str]: """Lowercased, dotted image-extension set (``.png``) for the phase-122 walk filter — SEPARATE from :py:attr:`import_extension_set` (images are never user-added via ``BOR_IMPORT_EXTENSIONS``; the ``images`` toggle is the single knob).""" return frozenset( f".{part.strip().lstrip('.').lower()}" for part in self.image_extensions.split(",") if part.strip() ) @property def git_source_list(self) -> list[str]: """Non-empty, stripped git URLs from :py:attr:`git_sources` (phase 28). Whitespace around each entry is trimmed and empty entries dropped; an unset/empty value yields ``[]`` (the import script then uses its legacy local-directory defaults). """ return [part.strip() for part in self.git_sources.split(",") if part.strip()] @property def docs_configured(self) -> bool: """True while a docs repo is configured (phase 59): the "Save as doc" surface is live. Empty (or whitespace-only) ``docs_repo`` → the feature is inert — no button for anyone, the push endpoint 409s (the optional-feature pattern of the git-sources env fallback).""" return bool(self.docs_repo.strip()) @property def effective_api_key(self) -> str: """API key for aipi: explicit setting, then $AIPI_KEY, then a placeholder.""" return self.llm_api_key or os.environ.get("AIPI_KEY", "") or "not-needed" @lru_cache def get_settings() -> Settings: """Effective settings: env/.env with the admin's DB overrides layered on top (phase 168, TODO L3: "The database value should override the env var"). The DB half lives in :mod:`app.core.settings_overlay` — imported LAZILY, on purpose: ``app.core.*`` imports ``app.config`` at module scope (and ``app.db`` → ``app.config`` runs at import time), so a module-scope import here would deadlock the import graph. The ``@lru_cache`` is unchanged from pre-phase — every existing ``get_settings.cache_clear()`` call site keeps working (they only retune the environment, so skipping the reset hooks there is correct), and :func:`invalidate_settings` is the save-side wrapper that also runs the runtime reset hooks (phase 168, task 03). """ from app.core.settings_overlay import resolve_settings return resolve_settings() def invalidate_settings() -> None: """Save-side settings refresh (phase 168, task 02/03). Clears the ``get_settings`` cache (the next resolution re-reads the DB overrides — no restart) and runs the registered runtime reset hooks (task 03: the in-process singletons — the chat LLM client and semaphore — rebuild from the fresh settings). The six existing ``get_settings.cache_clear()`` test call sites stay the env-only retune path: they re-resolve without running the hooks, which is exactly what a test that only re-tunes the environment wants. """ get_settings.cache_clear() from app.core.settings_overlay import run_invalidators run_invalidators()