**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**
**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs
**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)
**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).
**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.
**Next pending phase:** `123_chat_image_questions`.
740 lines
37 KiB
Python
740 lines
37 KiB
Python
"""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
|
||
#: 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
|
||
|
||
# --- 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
|
||
|
||
# --- 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).
|
||
image_extensions: str = "png,jpg,jpeg,webp,gif,bmp"
|
||
#: 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"
|
||
|
||
# --- 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("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("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("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("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 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:
|
||
return Settings()
|