Files
brain-of-reese/app/config.py
T
ducoterra 9820c361b0
Build and Push Containers / build-and-push-app (push) Successful in 2m2s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 118_summary_seed_context
**Phase 118 final verification pass — complete.** All criteria verified; 4 pre-existing defects found and fixed.

- **Verified:** summary-seed wiring (`select_suggested` top-5 no-floor → summary blocks, no full text in HIGH prompt), all-doc markdown summaries + NULL backfill (`summary_backfilled`, no `sources_meta` bump), `read` adds full text with `read_docs`-only dedupe, `done.sources` = suggested+read / durable record = suggested+related+read + `suggested=N` log line (seen live in E2E), byte-locked PERSONA/LOW/TOOLS_SECTION, battery gate PASS recorded in `TOOL_CALLING_TESTING.md` §10 (turbo 2026-09-16: 1/2/4 GREEN, cond-3 reported 9/10 per A7, contract 21/21, caps 0).
- **Defects fixed (all pre-existing, none phase-118):** ① `ChatMessage` schema missing the phase-113 `related` key → `extra="forbid"` 422'd every done-time auto-save of grounded turns with a related tier, leaving `message_count=1` (root cause of `test_share_chat` 3F; browser-level instrumentation proved the PUT 422) — added the field + unit/integration pins; ② `test_theme_semantic_completion` pins stale vs phase-117 debox (border/chip removed) — re-targeted to assert border/chip *absence*; ③ `test_header_consistency` `<26`px pin red on 26.125px native date-input line — bound relaxed to `<34` (wrap-detection intent kept); ④ `test_navbar_refresh` bor.chat.v1 key set updated for `related`.
- **Test/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **2506 passed, app/ 99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- **E2E:** new story suite in isolation → **2 passed**; full 103-suite matrix sweep (each isolated) → **all 103 green** after the fixes; `test_share_chat` 4 passed, `test_theme_semantic_completion` 8 passed, `test_header_consistency` 3 passed, `test_navbar_refresh` 7 passed.
- **Deviations:** none from LOCKED decisions. Note: orphaned diagnostic uvicorn processes briefly made E2E sessions exercise stale code — killed and re-verified; a sweep-regenerated tracked screenshot was restored. No commits made (harness commits).
- **Completion criteria:** all 7 ✅ (commit/phase-move is the harness's step).
- **Next pending phase:** none — `todo/` holds only this phase's overview pending the harness move.
2026-09-16 06:57:49 -04:00

649 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"
# --- 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"
#: 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 ``<tuning>`` 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 ``<knowledge_base>`` 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 ``<documents>``, 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 ``<documents>`` path, which stays whole.
read_max_chars: int = 128_000
# --- 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
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
# --- 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
# --- 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 = ""
#: 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("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("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("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("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("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 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:
return Settings()