Files
brain-of-reese/app/config.py
T
ducoterra baefcde668 fix(web): retire the stale homelab-era copy — neutral, accurate defaults on every page
Fixed: index.html meta description, empty-state sub and composer
placeholder (A1); app/config.py default suggestion chips → the four
neutral A2 defaults (BOR_SUGGESTIONS override unchanged); sources.html
KB page-sub → the current source model (git repos + local dirs +
uploaded archives, Sync pulls/imports); git-sources.html example URL
→ your-repo.git (A3); all 9 footers → neutral default in
span.footer-text (the phase-62 hook); E2E/unit conftests force the
code defaults so a local .env cannot leak corpus copy into tests;
new unit text pins + dedicated E2E suite.

Task 02 verification read-through — no change needed:
- sources.html sync result/error copy (matches the real sync behavior)
- tuning.html page-sub (accurate as written)
- history.html page-sub (accurate as written)
- doc-edit.html page-sub (accurate as written)
- git-sources.html page-sub (accurate as written)
- #sources-gate anonymous copy (accurate as written)
2026-09-01 10:54:50 -04:00

309 lines
15 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"
# --- Database (PostgreSQL 17 + pgvector) ---
database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese"
# --- 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
# --- RAG tuning ---
embedding_dim: int = 768 # verified against aipi /v1 (embed model)
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
#: 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
#: 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
#: 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
# --- 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
# --- 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.
# 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("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("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("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
# Suggested questions (onboarding + empty state).
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()