"""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" # --- 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) 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 ```` 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 # --- 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. 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("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("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("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 last 3 questions asked # (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()