"""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 #: Lexical support floor (A8 revised 2026-09-14): an FTS hit promotes # a turn to HIGH only when the best cosine is >= this value — it # requires the vector signal to corroborate the lexical match. A # single weak token hit with vector-unsupported docs (cosine < floor) # stays LOW (deflected). Default 0.35 ≈ half the relevance threshold; # tunable via ``BOR_LEXICAL_SUPPORT_FLOOR``. Must be <= # ``relevance_threshold`` (a floor above the threshold is a typo that # would make every FTS hit require a HIGH cosine anyway). lexical_support_floor: float = 0.35 #: Usefulness bar for the citation slot (phase 113, LOCKED A2): a #: retrieved document earns ``done.sources`` (the UI's citation chip) #: only when the **cosine** of its best hit chunk clears this floor — #: the vector signal must corroborate the citation, mirroring the A8 #: honesty gate's ``lexical_support_floor``. Documents that scored but #: stay below the bar are demoted to the secondary related-doc tier #: (at most ``related_max_docs``). ``top_n_docs`` is the CEILING for #: the cited tier, never a quota: a single strong document yields one #: citation. Default 0.35 — the same bar as ``lexical_support_floor``; #: tunable via ``BOR_SOURCE_USEFULNESS_FLOOR``. Must satisfy #: ``0 <= source_usefulness_floor <= relevance_threshold`` (a floor #: above the threshold would demote to the related tier documents the #: gate itself calls grounded — the ``lexical_support_floor`` typo #: guard). ``0`` disables the bar (every scored doc is citable — the #: pre-phase behavior, the kill switch). source_usefulness_floor: float = 0.35 #: Cap on the secondary related-doc tier (phase 113, LOCKED A4): #: documents that scored but did not clear ``source_usefulness_floor`` #: ride the ``done`` frame's ``related`` list (the UI's de-emphasized #: "nearby docs" row — never a citation chip). ``0`` = no related #: docs at all (the kill switch); a negative value fails startup #: loudly (the ``agent_max_rounds`` pattern). related_max_docs: int = 2 #: 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 # --- 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("lexical_support_floor") @classmethod def _lexical_support_floor_bounds(cls, v: float, info: ValidationInfo) -> float: """The lexical support floor must be in [0, relevance_threshold]. A value above the relevance threshold would be a typo — it would make every FTS hit require a HIGH cosine anyway, defeating the purpose of the floor (A8 revised 2026-09-14).""" if v < 0: raise ValueError("lexical_support_floor must be >= 0") threshold = info.data.get("relevance_threshold") if isinstance(threshold, float) and v > threshold: raise ValueError( f"lexical_support_floor ({v}) must be <= relevance_threshold ({threshold})" ) return v @field_validator("source_usefulness_floor") @classmethod def _source_usefulness_floor_bounds(cls, v: float, info: ValidationInfo) -> float: """The usefulness bar must be in [0, relevance_threshold]. A value above the relevance threshold would be a typo — it would demote to the related tier documents the honesty gate itself calls grounded (the ``lexical_support_floor`` typo guard, phase 113).""" if v < 0: raise ValueError("source_usefulness_floor must be >= 0") threshold = info.data.get("relevance_threshold") if isinstance(threshold, float) and v > threshold: raise ValueError( f"source_usefulness_floor ({v}) must be <= relevance_threshold ({threshold})" ) return v @field_validator("related_max_docs") @classmethod def _related_max_docs_non_negative(cls, v: int) -> int: """``0`` is the no-related-docs kill switch — a negative cap is a typo (the ``agent_max_rounds`` pattern, phase 113).""" if v < 0: raise ValueError("related_max_docs must be >= 0 (0 = no related docs)") return v @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()