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.
240 lines
11 KiB
Python
240 lines
11 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
|
||
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()
|