Phase 103 final verification pass — all green.
**Verified (all 3 tasks already in `complete/`; no code changes needed):**
- `opening_questions` in `app/api/suggestions.py` — forward walk, one opener per chat (first non-blank user msg, A3), reads raw `messages` not `title` (A4), phase-80 order/dedup/cap/seed contracts; `last_questions` name gone from `app/`+`tests/`
- Docs updated: `app/config.py` seed docstring, `.env.example` `BOR_SUGGESTIONS`, `README.md` — "session openers" wording
- Diff scope correct: only the 6 expected files + phase-file moves; `app/rag/suggestions.py` and `frontend/` untouched
**Test / lint / coverage results:**
- `uv run pytest tests/integration/test_suggestions_api.py -v` → 12 passed
- `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` → 8 passed in isolation (opener-only core pin included)
- `test_responsive_polish.py` → 7 passed; `test_chat_persistence.py` → 4 passed (both isolated, no edits)
- `uv run pytest --cov=app --cov-report=term-missing` → 2086 passed, TOTAL 99% (>90%); `app/api/suggestions.py` 100%
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
**Completion criteria:** all 7 ✅ (follow-ups-never-surface pin; cap-across-chats pin; seed/dedup/case/partial/A3/401 pins; E2E suites isolated; deflection chips unchanged; full suite + lint; commit + dir move left to harness per executor rules).
**Deviations:** none — no defects found; nothing changed in this pass.
**Next pending phase:** `98_sync_summary_visibility` (numeric order in `todo/`).
427 lines
21 KiB
Python
427 lines
21 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
|
||
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 ``<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
|
||
|
||
# --- 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 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()
|