All gates re-verified against a stable tree. Report: **Verified (all 6 tasks in `complete/`; this was the final pass)** - `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`. - **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness. - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned). - **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code. - **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria. - **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
385 lines
14 KiB
Python
385 lines
14 KiB
Python
"""The admin Settings panel's catalogue (phase 168, task 02).
|
|
|
|
``TODO.md`` L3: "I want an admin panel that can edit anything in .env
|
|
and save it to the database."
|
|
|
|
This module is the single source of truth for what the panel shows and
|
|
what the DB-over-env overlay (``app.core.settings_overlay``) is allowed
|
|
to do with each of the ``Settings`` fields:
|
|
|
|
* :data:`CATALOG` maps every ``Settings`` field name to its
|
|
presentation metadata (``group`` + ``label``) and its capability
|
|
flags:
|
|
|
|
- ``secret`` — write-only (LOCKED A4): the API reports ``has_value``
|
|
(and ``masked=True`` / ``value=None``), never the plaintext; a
|
|
saved value is masked on read.
|
|
- ``restart_required`` — boot-wired (LOCKED A4): the middleware /
|
|
engine / logging / static mount read it once at boot, so the
|
|
override applies on the NEXT boot (the panel shows the badge).
|
|
- ``overridable`` — ``False`` for LOCKED A3's ``database_url``
|
|
(it builds the very engine that would read the override — a
|
|
chicken-and-egg brick) and ``embedding_dim`` (A6 fixes
|
|
``chunks.embedding`` at table-creation; a dim override would
|
|
corrupt retrieval).
|
|
|
|
The panel (task 05) is GENERATED from this catalogue — never
|
|
hand-written — so a new ``Settings`` field appears there only once
|
|
it has a :class:`SettingMeta`; the completeness pin
|
|
(``tests/unit/test_settings_catalog.py``: ``set(CATALOG) ==
|
|
set(Settings.model_fields)``) fails the build the moment a field is
|
|
added without metadata, so it can never silently disappear from the
|
|
panel.
|
|
* :data:`GROUPS` is the display order of the groups — it mirrors
|
|
``app/config.py``'s own section comments (App, HTTP response
|
|
compression / byte budget, UI customization, Database, LLM, Chat
|
|
history, RAG tuning, …).
|
|
* :data:`SECRETS`, :data:`RESTART_REQUIRED` and
|
|
:data:`NON_OVERRIDABLE` are DERIVED from :data:`CATALOG` — never
|
|
re-listed by hand, so they cannot drift.
|
|
|
|
:func:`env_var` keeps the ``BOR_`` prefix a pure presentation concern
|
|
(D1): the stored key is the bare ``Settings`` field name
|
|
(``relevance_threshold``), ``"BOR_" + key.upper()`` is what the panel
|
|
and the API show, and a stored prefix could double up — so it is
|
|
derived, never stored.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SettingMeta:
|
|
"""One settings field's panel metadata + capability flags."""
|
|
|
|
key: str
|
|
group: str
|
|
label: str
|
|
#: Write-only (LOCKED A4) — masked in responses, ``is_set`` only.
|
|
secret: bool = False
|
|
#: Boot-wired (LOCKED A4) — applies on the next boot.
|
|
restart_required: bool = False
|
|
#: ``False`` = read-only row (LOCKED A3).
|
|
overridable: bool = True
|
|
|
|
|
|
#: The ``BOR_`` prefix is presentation only (D1) — see :func:`env_var`.
|
|
ENV_PREFIX = "BOR_"
|
|
|
|
#: Display order of the groups (mirrors ``app/config.py``'s section
|
|
#: comments); :data:`CATALOG` is keyed in the same order.
|
|
GROUPS: tuple[str, ...] = (
|
|
"App & branding",
|
|
"HTTP & logging",
|
|
"Database",
|
|
"LLM (aipi)",
|
|
"Chat history",
|
|
"Retrieval & honesty",
|
|
"Chunking & embedding",
|
|
"Prompt budgets",
|
|
"Agent tools",
|
|
"Import & sources",
|
|
"Images",
|
|
"PDF",
|
|
"Docs push",
|
|
"Admin & security",
|
|
)
|
|
|
|
#: Every ``Settings`` field, in :data:`GROUPS` display order. Completeness
|
|
#: is pinned by ``tests/unit/test_settings_catalog.py`` (the diff is in
|
|
#: the failure message — a new setting without metadata fails the build).
|
|
CATALOG: dict[str, SettingMeta] = {
|
|
# --- App & branding ---
|
|
"app_name": SettingMeta("app_name", "App & branding", "App name"),
|
|
"app_version": SettingMeta("app_version", "App & branding", "App version"),
|
|
"environment": SettingMeta("environment", "App & branding", "Environment"),
|
|
"input_placeholder": SettingMeta(
|
|
"input_placeholder", "App & branding", "Input placeholder"
|
|
),
|
|
"footer_text": SettingMeta("footer_text", "App & branding", "Footer text"),
|
|
"suggestions": SettingMeta(
|
|
"suggestions", "App & branding", "Suggestions (JSON array)"
|
|
),
|
|
# --- HTTP & logging ---
|
|
"log_level": SettingMeta(
|
|
"log_level", "HTTP & logging", "Log level", restart_required=True
|
|
),
|
|
"log_large_response_bytes": SettingMeta(
|
|
"log_large_response_bytes",
|
|
"HTTP & logging",
|
|
"Log large response bytes",
|
|
),
|
|
"compress_min_bytes": SettingMeta(
|
|
"compress_min_bytes", "HTTP & logging", "Compress min bytes",
|
|
restart_required=True,
|
|
),
|
|
"static_dir": SettingMeta(
|
|
"static_dir", "HTTP & logging", "Static dir", restart_required=True
|
|
),
|
|
# --- Database ---
|
|
"database_url": SettingMeta(
|
|
"database_url", "Database", "Database URL",
|
|
secret=True, overridable=False,
|
|
),
|
|
"db_pool_size": SettingMeta(
|
|
"db_pool_size", "Database", "DB pool size", restart_required=True
|
|
),
|
|
"db_pool_max_overflow": SettingMeta(
|
|
"db_pool_max_overflow", "Database", "DB pool max overflow",
|
|
restart_required=True,
|
|
),
|
|
"db_pool_recycle": SettingMeta(
|
|
"db_pool_recycle", "Database", "DB pool recycle", restart_required=True
|
|
),
|
|
"chat_max_concurrent": SettingMeta(
|
|
"chat_max_concurrent", "Database", "Chat max concurrent"
|
|
),
|
|
# --- LLM (aipi) ---
|
|
"llm_base_url": SettingMeta(
|
|
"llm_base_url", "LLM (aipi)", "LLM base URL"
|
|
),
|
|
"llm_api_key": SettingMeta(
|
|
"llm_api_key", "LLM (aipi)", "LLM API key", secret=True
|
|
),
|
|
"llm_chat_model": SettingMeta(
|
|
"llm_chat_model", "LLM (aipi)", "LLM chat model"
|
|
),
|
|
"llm_embed_model": SettingMeta(
|
|
"llm_embed_model", "LLM (aipi)", "LLM embed model"
|
|
),
|
|
"llm_summary_model": SettingMeta(
|
|
"llm_summary_model", "LLM (aipi)", "LLM summary model"
|
|
),
|
|
"llm_chat_thinking_level": SettingMeta(
|
|
"llm_chat_thinking_level", "LLM (aipi)", "Chat thinking level"
|
|
),
|
|
"llm_summary_thinking_level": SettingMeta(
|
|
"llm_summary_thinking_level", "LLM (aipi)", "Summary thinking level"
|
|
),
|
|
"stream_thinking": SettingMeta(
|
|
"stream_thinking", "LLM (aipi)", "Stream thinking"
|
|
),
|
|
"llm_retries": SettingMeta("llm_retries", "LLM (aipi)", "LLM retries"),
|
|
"llm_retry_delay": SettingMeta(
|
|
"llm_retry_delay", "LLM (aipi)", "LLM retry delay"
|
|
),
|
|
"llm_timeout": SettingMeta("llm_timeout", "LLM (aipi)", "LLM timeout (s)"),
|
|
# --- Chat history ---
|
|
"history_max_turns": SettingMeta(
|
|
"history_max_turns", "Chat history", "History max turns"
|
|
),
|
|
"history_max_chars": SettingMeta(
|
|
"history_max_chars", "Chat history", "History max chars"
|
|
),
|
|
# --- Retrieval & honesty ---
|
|
"embedding_dim": SettingMeta(
|
|
"embedding_dim", "Retrieval & honesty", "Embedding dim",
|
|
overridable=False,
|
|
),
|
|
"top_n_docs": SettingMeta("top_n_docs", "Retrieval & honesty", "Top N docs"),
|
|
"relevance_threshold": SettingMeta(
|
|
"relevance_threshold", "Retrieval & honesty", "Relevance threshold"
|
|
),
|
|
"lexical_support_floor": SettingMeta(
|
|
"lexical_support_floor", "Retrieval & honesty", "Lexical support floor"
|
|
),
|
|
"source_usefulness_floor": SettingMeta(
|
|
"source_usefulness_floor",
|
|
"Retrieval & honesty",
|
|
"Source usefulness floor",
|
|
),
|
|
"related_max_docs": SettingMeta(
|
|
"related_max_docs", "Retrieval & honesty", "Related max docs"
|
|
),
|
|
"suggested_docs": SettingMeta(
|
|
"suggested_docs", "Retrieval & honesty", "Suggested docs"
|
|
),
|
|
"suggestion_preview_chars": SettingMeta(
|
|
"suggestion_preview_chars",
|
|
"Retrieval & honesty",
|
|
"Suggestion preview chars",
|
|
),
|
|
"hybrid_vector_candidates": SettingMeta(
|
|
"hybrid_vector_candidates",
|
|
"Retrieval & honesty",
|
|
"Hybrid vector candidates",
|
|
),
|
|
"retrieval_ef_search": SettingMeta(
|
|
"retrieval_ef_search", "Retrieval & honesty", "Vector ef_search"
|
|
),
|
|
"hybrid_lexical_candidates": SettingMeta(
|
|
"hybrid_lexical_candidates",
|
|
"Retrieval & honesty",
|
|
"Hybrid lexical candidates",
|
|
),
|
|
"rrf_k": SettingMeta("rrf_k", "Retrieval & honesty", "RRF k"),
|
|
"recency_boost": SettingMeta(
|
|
"recency_boost", "Retrieval & honesty", "Recency boost"
|
|
),
|
|
"recency_half_life_days": SettingMeta(
|
|
"recency_half_life_days",
|
|
"Retrieval & honesty",
|
|
"Recency half-life days",
|
|
),
|
|
"name_hit_bonus": SettingMeta(
|
|
"name_hit_bonus", "Retrieval & honesty", "Name hit bonus"
|
|
),
|
|
# Phase 191 (D3): the second, independent selection bonus — the SAME
|
|
# live shape as name_hit_bonus (the selection walk reads
|
|
# get_settings() per turn, so a saved override applies on the next
|
|
# question).
|
|
"source_name_hit_bonus": SettingMeta(
|
|
"source_name_hit_bonus", "Retrieval & honesty", "Source name hit bonus"
|
|
),
|
|
# --- Chunking & embedding ---
|
|
"chunk_target_chars": SettingMeta(
|
|
"chunk_target_chars", "Chunking & embedding", "Chunk target chars"
|
|
),
|
|
"chunk_overlap_chars": SettingMeta(
|
|
"chunk_overlap_chars", "Chunking & embedding", "Chunk overlap chars"
|
|
),
|
|
"embed_batch_size": SettingMeta(
|
|
"embed_batch_size", "Chunking & embedding", "Embed batch size"
|
|
),
|
|
"embed_question_max_chars": SettingMeta(
|
|
"embed_question_max_chars",
|
|
"Chunking & embedding",
|
|
"Embed question max chars",
|
|
),
|
|
# --- Prompt budgets ---
|
|
"max_output_tokens": SettingMeta(
|
|
"max_output_tokens", "Prompt budgets", "Max output tokens"
|
|
),
|
|
"steering_max_chars": SettingMeta(
|
|
"steering_max_chars", "Prompt budgets", "Steering max chars"
|
|
),
|
|
"summary_max_chars": SettingMeta(
|
|
"summary_max_chars", "Prompt budgets", "Summary max chars"
|
|
),
|
|
"kb_overview_max_chars": SettingMeta(
|
|
"kb_overview_max_chars", "Prompt budgets", "KB overview max chars"
|
|
),
|
|
"overview_input_max_chars": SettingMeta(
|
|
"overview_input_max_chars",
|
|
"Prompt budgets",
|
|
"Overview input max chars",
|
|
),
|
|
"folder_summary_input_max_chars": SettingMeta(
|
|
"folder_summary_input_max_chars",
|
|
"Prompt budgets",
|
|
"Folder summary input max chars",
|
|
),
|
|
# --- Agent tools ---
|
|
"agent_max_rounds": SettingMeta(
|
|
"agent_max_rounds", "Agent tools", "Agent max rounds"
|
|
),
|
|
"read_max_chars": SettingMeta(
|
|
"read_max_chars", "Agent tools", "Read max chars"
|
|
),
|
|
"answer_text": SettingMeta("answer_text", "Agent tools", "Answer text"),
|
|
"show_text_max_chars": SettingMeta(
|
|
"show_text_max_chars", "Agent tools", "Show text max chars"
|
|
),
|
|
"show_text_max_blocks": SettingMeta(
|
|
"show_text_max_blocks", "Agent tools", "Show text max blocks"
|
|
),
|
|
# --- Import & sources ---
|
|
"import_extensions": SettingMeta(
|
|
"import_extensions", "Import & sources", "Import extensions (CSV)"
|
|
),
|
|
"git_sources": SettingMeta(
|
|
"git_sources", "Import & sources", "Git sources (CSV)"
|
|
),
|
|
"sources_dir": SettingMeta(
|
|
"sources_dir", "Import & sources", "Sources dir"
|
|
),
|
|
"upload_dir": SettingMeta("upload_dir", "Import & sources", "Upload dir"),
|
|
"upload_max_mb": SettingMeta(
|
|
"upload_max_mb", "Import & sources", "Upload max MB"
|
|
),
|
|
"sync_session_reset_files": SettingMeta(
|
|
"sync_session_reset_files", "Import & sources", "Sync session reset files"
|
|
),
|
|
"sync_prune_batch": SettingMeta(
|
|
"sync_prune_batch", "Import & sources", "Sync prune batch"
|
|
),
|
|
# --- Images ---
|
|
"images": SettingMeta("images", "Images", "Images"),
|
|
"image_extensions": SettingMeta(
|
|
"image_extensions", "Images", "Image extensions (CSV)"
|
|
),
|
|
"image_dir": SettingMeta("image_dir", "Images", "Image dir"),
|
|
"chat_image_dir": SettingMeta(
|
|
"chat_image_dir", "Images", "Chat image dir"
|
|
),
|
|
"chat_image_max_mb": SettingMeta(
|
|
"chat_image_max_mb", "Images", "Chat image max MB"
|
|
),
|
|
"answer_images": SettingMeta("answer_images", "Images", "Answer images"),
|
|
# --- PDF ---
|
|
"pdf_pictorial": SettingMeta("pdf_pictorial", "PDF", "PDF pictorial"),
|
|
"pdf_pictorial_min_chars": SettingMeta(
|
|
"pdf_pictorial_min_chars", "PDF", "PDF pictorial min chars"
|
|
),
|
|
"pdf_image_skip_blank": SettingMeta(
|
|
"pdf_image_skip_blank", "PDF", "PDF image skip blank"
|
|
),
|
|
"pdf_page_dir": SettingMeta("pdf_page_dir", "PDF", "PDF page dir"),
|
|
# --- Docs push ---
|
|
"docs_repo": SettingMeta("docs_repo", "Docs push", "Docs repo"),
|
|
"docs_user": SettingMeta("docs_user", "Docs push", "Docs user"),
|
|
"docs_token": SettingMeta(
|
|
"docs_token", "Docs push", "Docs token", secret=True
|
|
),
|
|
"docs_branch": SettingMeta("docs_branch", "Docs push", "Docs branch"),
|
|
"docs_base_branch": SettingMeta(
|
|
"docs_base_branch", "Docs push", "Docs base branch"
|
|
),
|
|
"docs_work_dir": SettingMeta("docs_work_dir", "Docs push", "Docs work dir"),
|
|
# --- Admin & security ---
|
|
"admin_password": SettingMeta(
|
|
"admin_password", "Admin & security", "Admin password", secret=True
|
|
),
|
|
"session_secret": SettingMeta(
|
|
"session_secret", "Admin & security", "Session secret",
|
|
secret=True, restart_required=True,
|
|
),
|
|
"session_max_age": SettingMeta(
|
|
"session_max_age", "Admin & security", "Session max age",
|
|
restart_required=True,
|
|
),
|
|
"session_cookie": SettingMeta(
|
|
"session_cookie", "Admin & security", "Session cookie",
|
|
restart_required=True,
|
|
),
|
|
}
|
|
|
|
#: The masked set (LOCKED A4: write-only; ``database_url`` is read-only
|
|
#: but its URL carries credentials, so it is masked too) — derived,
|
|
#: never re-listed by hand.
|
|
SECRETS: frozenset[str] = frozenset(
|
|
key for key, meta in CATALOG.items() if meta.secret
|
|
)
|
|
|
|
#: The boot-wired set (LOCKED A4: logging, the gzip middleware, the
|
|
#: static mount, ``create_engine``, ``SessionMiddleware``) — derived.
|
|
RESTART_REQUIRED: frozenset[str] = frozenset(
|
|
key for key, meta in CATALOG.items() if meta.restart_required
|
|
)
|
|
|
|
#: The read-only set (LOCKED A3) — derived.
|
|
NON_OVERRIDABLE: frozenset[str] = frozenset(
|
|
key for key, meta in CATALOG.items() if not meta.overridable
|
|
)
|
|
|
|
|
|
def env_var(key: str) -> str:
|
|
"""The env var for one bare setting key — presentation only (D1).
|
|
|
|
The stored key is the bare ``Settings`` field name; the ``BOR_``
|
|
form is what the panel and the API show, never what is stored
|
|
(a stored prefix could double up).
|
|
"""
|
|
return ENV_PREFIX + key.upper()
|