Files
brain-of-reese/app/config.py
T
ducoterra 03d26255c6 feat(sources): upload tarball/zipfile archives as sources — unpack, scan, and replace in place
Phase 49 (owner request, chat 2026-08-28: "The git sources page should
remove local directory and should instead accept a tarball or zipfile
upload which it will unpack and scan … reuploading the same tarball
should not create a new folder, but should unpack and overwrite the
previously unpacked content" — design confirmed in the same
conversation):

* POST /api/git-sources/upload (admin-only, require_admin): accepts
  .tar/.tar.gz/.tgz/.zip, streams it with the BOR_UPLOAD_MAX_MB cap
  (bounds BOTH the compressed upload and the total extracted bytes —
  zip-bomb guard), safely unpacks (absolute/traversal/symlink/hardlink
  escape and device/FIFO members rejected), and atomically swaps the
  content in over BOR_UPLOAD_DIR/<name>/ (name = filename minus the
  archive suffix — no missing window, a failed upload never touches the
  existing folder/row/KB). The git_sources row is upserted by path
  (kind='local', no duplicates, added_at preserved), the models are
  checked fail-fast (503 sanitized when down — the folder/row stay
  committed and the next sync/re-upload retries idempotently), and the
  source is scanned synchronously in the request (single-source
  import_sources prune=True + change-gated KB overview), answering 200
  with the sync-style counts. One upload at a time (409); the request
  session is released before the scan so a concurrent TRUNCATE cannot
  deadlock against it.
* app/rag/archive_upload.py: ArchiveUploadError, ARCHIVE_SUFFIXES,
  archive_source_name (safe-name derivation), unpack_archive (guarded
  zip/tar extraction with the extracted-byte cap, no partial state),
  swap_in (atomic replace with restore-on-failure) — fully unit-tested.
* app/config.py + .env.example: BOR_UPLOAD_DIR (default
  ~/bor-sources/uploads, deliberately separate from the git checkouts)
  and BOR_UPLOAD_MAX_MB (default 512; a validator fails loud at
  startup on <= 0).
* python-multipart added to the dependencies — FastAPI's required
  multipart parser (an A2 implementation detail, phase locked decision).
* The Sources page: the phase-38 "Add a local directory" form is
  removed; #archive-upload-form takes its place (labeled file input,
  "Upload & scan" button, the §7.4 never-stale lifecycle, inline
  role=alert error, role=status count line); hint + table caption
  updated. The POST /api/git-sources kind=local API contract is
  UNCHANGED — a plain directory is still registrable via the API, and
  existing Local rows list/remove/sync exactly as before.
* The phase-38 story E2E (test_local_directory_sources.py) is rewritten
  API-driven — the form it drove is gone; its acceptance stands.
* The story E2E (test_archive_upload_sources.py): the swap,
  upload→scan→list (the deterministic "Uploading…" in-flight state, the
  Local row, /api/docs + the RAG catalog), same-filename re-upload
  (in-place replace, prune, no duplicate row, v2-only folder), the
  422 inline error + recovery (the form is not wedged), and the
  anonymous gate + 403.
* README: the archive-upload section (formats, naming rule, in-place
  replace, both new settings), the local-directory form removal noted,
  config reference rows for BOR_UPLOAD_DIR / BOR_UPLOAD_MAX_MB.

Gates: unit+integration green, app/ coverage 99%, the story E2E green
in isolation, the regression suites (git sources admin, local
directory sources, sync button, import documents, nav rename, smoke,
shared header) green in isolation, ruff + pyright clean.

Note: per this phase's file-level staging, frontend/assets/styles.css
also carries the small same-day in-flight owner rework already in the
working tree (the .sign-in-mobile companion rule for the phase-48
mobile sign-in copy); the phase-49 change is the upload form's block.
2026-08-28 15:57:59 -04:00

240 lines
11 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
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
#: The A9 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 allowed set, chunked as plain
#: text). ``BOR_IMPORT_EXTENSIONS`` may narrow — but never widen — this
#: set.
_ALLOWED_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, revised 2026-08-21 and 2026-08-27) ---
# Comma-separated list of lowercased file extensions (no dot) imported
# by ``scripts/import_docs.py``. 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`. ``mode="after"`` validation runs
# against the raw string so a typo fails loudly at startup.
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
@field_validator("import_extensions")
@classmethod
def _import_extensions_known(cls, v: str) -> str:
"""Reject unknown/empty formats loudly instead of silently importing
nothing (a typo like ``md,jsonn`` would otherwise walk zero files)."""
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")
unknown = exts - _ALLOWED_IMPORT_EXTENSIONS
if unknown:
raise ValueError(
f"unknown import extension(s): {', '.join(sorted(unknown))} — "
f"allowed: {', '.join(sorted(_ALLOWED_IMPORT_EXTENSIONS))}"
)
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
# Suggested questions (onboarding + empty state).
suggestions: list[str] = [
"How is my Kubernetes cluster set up?",
"What's my backup strategy?",
"How do I deploy a new service?",
"What's currently running in the homelab?",
]
@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 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()