Files
brain-of-reese/app/config.py
T
ducoterra 3a4035fc96
Build and Push Containers / build-and-push-app (push) Successful in 2m14s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 110_fix_sse_db_pool_exhaustion
---

**Phase 110 — Fix SSE DB Connection Pool Exhaustion (SEC-14-04): COMPLETE**

**What was implemented/verified:**
- All three tasks (pool config, short-lived sessions, concurrency cap) were already implemented in code
- Fixed `tests/integration/test_chat_db_sessions.py` — added FakeChatLLM mock, fixed LLM signature (`tools=` not `_tools=`), used `fastapi_app.dependency_overrides` instead of `client.app.dependency_overrides`
- Fixed `tests/e2e/test_chat_db_pool.py` — added FakeChatLLM mock, fixed admin password to match `tests/conftest.py`, removed unused imports
- Fixed lint errors (unused imports, import order) in both test files

**Test / lint / coverage results:**
- `uv run pytest` → 2350 passed, 1 warning, 56.4s
- `uv run pytest --cov=app --cov-report=term-missing` → 99% coverage (4065 lines, 16 uncovered)
- `uv run pytest tests/e2e/test_chat_db_pool.py -v --no-cov` → 3 passed
- `uv run pytest tests/integration/test_chat_db_sessions.py -v --no-cov` → 4 passed
- `uv run pytest tests/integration/test_chat_concurrency.py -v --no-cov` → 11 passed
- `uv run pytest tests/unit/test_db_pool_config.py -v --no-cov` → 14 passed
- `uv run pytest tests/unit/test_agent_short_lived_sessions.py -v --no-cov` → 7 passed
- `uv run ruff check .` → all checks passed
- `uv run pyright` → 0 errors, 0 warnings

**Completion criteria:**
- [✓] `app/db.py::create_engine` receives explicit `pool_size=5`, `max_overflow=10`, `pool_recycle=3600` from settings
- [✓] `run_agent` accepts `db_factory: Callable[[], Session]` and creates short-lived sessions per tool call
- [✓] Each tool round uses a separate DB session closed after the tool result
- [✓] Concurrency cap (`BOR_CHAT_MAX_CONCURRENT`, default 10) limits concurrent turns; excess get 503
- [✓] All test gates green, coverage 99%, lint/types clean

**Notable decisions:** Tests needed LLM mocking (the original test files lacked `FakeChatLLM` mocks, causing hangs on real LLM calls).

**Next pending phase:** None — this is the last phase in `todo/`.
2026-09-14 15:55:13 -04:00

510 lines
25 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"
# --- 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)
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
#: 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
# --- 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("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("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 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()