diff --git a/docs/production-runbook.md b/docs/production-runbook.md index 891e5d1..a77afc1 100644 --- a/docs/production-runbook.md +++ b/docs/production-runbook.md @@ -21,7 +21,9 @@ This runbook is the operational checklist for releasing and monitoring the trans 1. Deploy artifact/config to target environment. - V6.0 Phase 1 production stack: `docker compose -f docker-compose.production.yml up -d --build` + - `Settings` loads from explicit `_env_file`, then `ENV_FILE`, then the repository-root `.env.production`; it does not resolve relative to the process working directory. - For Runtime Settings writes in production, mount `.env.production` into the app container and set `RUNTIME_SETTINGS_ENV_FILE=/app/.env.production`. + - If deployment uses a non-default env-file location, set both `ENV_FILE` and `RUNTIME_SETTINGS_ENV_FILE` to that absolute path so startup reads and Settings-page writes stay aligned. - For SQLite -> PostgreSQL cutover, run `uv run python tools/export_import_migration.py verify --source-db --target-db ` before switching runtime. 2. Validate service startup: - `/healthz` responds `200` diff --git a/docs/ui/pages/settings.md b/docs/ui/pages/settings.md index db45f69..8db27f5 100644 --- a/docs/ui/pages/settings.md +++ b/docs/ui/pages/settings.md @@ -24,7 +24,8 @@ Settings manages installation-local registries, safe runtime .env settings, and - Runtime Settings exposes an allowlisted set of non-secret fields synchronized with `Settings` model fields except excluded secret/unsafe fields. - Runtime Settings is rendered as a compact two-column editor (**Setting**, **Value**) in a centered, narrower responsive container. - Runtime Settings persists changes to the resolved runtime env file, validates by constructing a `Settings` instance, and reports validation failures through the shared UI error presenter. -- The write target resolution order is: explicit function override (tests/tools), `RUNTIME_SETTINGS_ENV_FILE` environment variable (deployment override), then `Settings.model_config.env_file` (default `.env.production`). +- `Settings` resolves its env file in this order: explicit `_env_file`, `ENV_FILE`, then the repository-root `.env.production`. +- Runtime Settings resolves its write target in this order: explicit function override (tests/tools), `RUNTIME_SETTINGS_ENV_FILE` environment variable (deployment override), `ENV_FILE`, then the repository-root `.env.production`. - Runtime Settings changes require application restart to take effect. - Runtime Settings renders a host-side restart command (`docker compose -f docker-compose.production.yml up -d --force-recreate app worker`) so operators can apply saved values without granting Docker control to the app container. - Runtime Settings includes an explicit "Other settings not shown here" markdown table listing: diff --git a/src/transcription/config.py b/src/transcription/config.py index a5cf204..c3ac0c3 100644 --- a/src/transcription/config.py +++ b/src/transcription/config.py @@ -7,6 +7,7 @@ are resolved by the provider adapters, not here. import copy import logging.config +import os from collections.abc import Sequence from enum import StrEnum from functools import cache @@ -26,6 +27,16 @@ from pydantic_settings import BaseSettings from pydantic_settings import SettingsConfigDict logger = logging.getLogger(__name__) +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_ENV_FILE_NAME = ".env.production" + + +def resolve_settings_env_file_path() -> Path: + """Resolve the runtime env file independent of the process working directory.""" + override = os.getenv("ENV_FILE", "").strip() + if override: + return Path(override) + return PROJECT_ROOT / DEFAULT_ENV_FILE_NAME class Provider(StrEnum): @@ -66,7 +77,7 @@ DatabaseSettings = Annotated[ class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env.production", + env_file=None, env_file_encoding="utf-8", extra="ignore", env_nested_delimiter="__", @@ -75,6 +86,11 @@ class Settings(BaseSettings): frozen=True, ) + def __init__(self, /, **values: Any) -> None: + if "_env_file" not in values: + values["_env_file"] = resolve_settings_env_file_path() + super().__init__(**values) + # --- NiceGUI Server --- host: str = "0.0.0.0" port: int = 8000 diff --git a/src/transcription/errors.py b/src/transcription/errors.py index b6c0eb1..8faaa23 100644 --- a/src/transcription/errors.py +++ b/src/transcription/errors.py @@ -32,6 +32,11 @@ def new_error_id() -> str: return uuid4().hex[:8] +def exception_detail(exc: BaseException) -> str: + """Return internal-only root-cause text for persisted diagnostics.""" + return f"{type(exc).__name__}: {exc}" + + class AppError(RuntimeError): """Base application error carrying user-safe handling metadata.""" @@ -115,7 +120,7 @@ def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError: category=ErrorCategory.INTERNAL_UNEXPECTED, suggestion="Retry once. If it persists, review logs and report the error reference id.", retriable=False, - detail=f"{type(exc).__name__}: {exc}", + detail=exception_detail(exc), ) logger.error( "Unexpected error operation=%s error_id=%s", diff --git a/src/transcription/services/errors.py b/src/transcription/services/errors.py index 22fb166..0335ce9 100644 --- a/src/transcription/services/errors.py +++ b/src/transcription/services/errors.py @@ -16,6 +16,10 @@ class PromptLoadError(AppError): """Raised when prompt artifacts cannot be loaded safely.""" +class PromptStoreError(PromptLoadError): + """Raised when prompt storage validation or persistence fails.""" + + class TranscriptionError(AppError): """Raised when transcription execution fails.""" diff --git a/src/transcription/services/prompts.py b/src/transcription/services/prompts.py index 2025c0d..7a8456a 100644 --- a/src/transcription/services/prompts.py +++ b/src/transcription/services/prompts.py @@ -9,17 +9,14 @@ from uuid import uuid4 from ..config import Settings from ..config import get_settings -from ..errors import AppError from ..errors import ErrorCategory +from ..errors import exception_detail +from .errors import PromptStoreError PROMPT_EXTENSION = ".md" BACKUP_SUFFIX = ".bak" -class PromptStoreError(AppError): - """Raised when prompt storage validation or persistence fails.""" - - @dataclass(frozen=True, slots=True) class PromptSummary: """Read model for one editable prompt artifact.""" @@ -189,5 +186,5 @@ class PromptStore: message, category=ErrorCategory.INFRA_PERSISTENT, suggestion="Check prompt directory permissions and available disk space, then retry.", - detail=f"{type(exc).__name__}: {exc}", + detail=exception_detail(exc), ) diff --git a/src/transcription/ui/pages/tags_page.py b/src/transcription/ui/pages/tags_page.py deleted file mode 100644 index 99ef15d..0000000 --- a/src/transcription/ui/pages/tags_page.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Tags browse and filter page registration.""" - -from __future__ import annotations - -from nicegui import ui - -from transcription.services.documents import DocumentService -from transcription.ui.components.app_shell import render_navigation_header -from transcription.ui.components.cards import archival_card -from transcription.ui.components.error_presenter import run_ui_action -from transcription.ui.components.primitives import render_empty_state -from transcription.ui.components.primitives import section_header_row -from transcription.ui.theme import page_header - -from ...db.session import SessionFactoryDep - - -def register_page() -> None: - """Register the tags browse/filter route.""" - - @ui.page("/tags") - async def tags_page(session_factory: SessionFactoryDep) -> None: - document_service = DocumentService(session_factory=session_factory) - render_navigation_header(current_path="/tags") - - tags_outcome = await run_ui_action( - operation="tags.list", - title="Tags unavailable", - action=document_service.list_tag_summaries, - ) - if not tags_outcome.ok: - return - tag_summaries = tags_outcome.value or () - tag_labels = [item.label for item in tag_summaries] - - documents_outcome = await run_ui_action( - operation="documents.list", - title="Documents unavailable", - action=document_service.list_documents, - ) - if not documents_outcome.ok: - return - documents = documents_outcome.value or () - - with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): - with section_header_row(): - page_header("Tags", subtitle="Browse documents by tag.") - - if not tag_summaries: - with archival_card(extra_classes="p-8 text-center"): - render_empty_state("No tags are configured yet.") - return - - selected_tag = ( - ui.select(tag_labels, label="Filter by tag") - .props("outlined clearable use-input") - .classes("w-full md:w-96 ui-form-surface") - ) - - @ui.refreshable - def render_groups() -> None: - selected = str(selected_tag.value or "").strip() - with ui.column().classes("w-full gap-3"): - rendered_any = False - for summary in tag_summaries: - if selected and summary.label != selected: - continue - tagged_documents = [ - document - for document in documents - if any( - link.tag_ref is not None and link.tag_ref.id == summary.id - for link in document.document_tags - ) - ] - if not tagged_documents: - continue - rendered_any = True - with archival_card(title=f"{summary.label} ({len(tagged_documents)})"): - for document in sorted(tagged_documents, key=lambda item: item.name.casefold()): - ui.button( - document.name, - on_click=lambda _=None, doc_id=document.id: ui.navigate.to(f"/documents/{doc_id}"), - icon="description", - ).props("flat dense no-caps").classes("self-start ui-link-primary text-xs") - - if not rendered_any: - with archival_card(extra_classes="p-6"): - render_empty_state("No documents match this tag filter.", italic=True) - - selected_tag.on_value_change(lambda _event: render_groups.refresh()) - render_groups() diff --git a/src/transcription/ui/runtime_settings_store.py b/src/transcription/ui/runtime_settings_store.py index 2b4a881..de735dd 100644 --- a/src/transcription/ui/runtime_settings_store.py +++ b/src/transcription/ui/runtime_settings_store.py @@ -16,8 +16,10 @@ from pydantic import ValidationError from transcription.config import Provider from transcription.config import Settings +from transcription.config import resolve_settings_env_file_path from transcription.errors import AppError from transcription.errors import ErrorCategory +from transcription.errors import exception_detail FieldControl = Literal["text", "bool", "select"] @@ -385,13 +387,14 @@ def save_runtime_settings( "Runtime settings file is not writable.", category=ErrorCategory.INFRA_PERSISTENT, suggestion="Verify file path and write permissions, then retry.", - detail=f"Failed writing runtime env file {resolved_env_path}: {type(exc).__name__}: {exc}", + detail=f"Failed writing runtime env file {resolved_env_path}: {exception_detail(exc)}", ) from exc refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False) return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path) def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) -> Path: + _ = settings if env_file_path is not None: return env_file_path @@ -399,20 +402,7 @@ def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) -> if override: return Path(override) - configured = settings.model_config.get("env_file") - if configured is None: - return Path(".env.production") - if isinstance(configured, Path): - return Path(configured) - if isinstance(configured, str): - return Path(configured) - if isinstance(configured, (list, tuple)) and configured: - first = configured[0] - if isinstance(first, Path): - return Path(first) - if isinstance(first, str): - return Path(first) - return Path(".env.production") + return resolve_settings_env_file_path() def _display_value(value: object) -> str | bool: @@ -473,7 +463,7 @@ def _read_env_lines(path: Path) -> list[str]: "Runtime settings file is unreadable.", category=ErrorCategory.INFRA_PERSISTENT, suggestion="Verify file path and read permissions, then retry.", - detail=f"Failed reading runtime env file {path}: {type(exc).__name__}: {exc}", + detail=f"Failed reading runtime env file {path}: {exception_detail(exc)}", ) from exc @@ -551,7 +541,7 @@ def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None: "Runtime settings file is not writable.", category=ErrorCategory.INFRA_PERSISTENT, suggestion="Verify file path and write permissions, then retry.", - detail=f"Failed writing runtime env file {path}: {type(exc).__name__}: {exc}", + detail=f"Failed writing runtime env file {path}: {exception_detail(exc)}", ) from exc finally: if temp_path is not None: diff --git a/tests/conftest.py b/tests/conftest.py index af2f5a1..2289fab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ Every test gets a fresh in-memory SQLite database so tests are isolated, fast, and leave no artifacts on disk. """ +import os from pathlib import Path import pytest @@ -28,9 +29,9 @@ from transcription.services.jobs import JobService def isolate_settings_from_local_env_files(tmp_path_factory): """Point `Settings` at a controlled stub env file instead of a developer one. - `Settings.model_config` declares `env_file=".env.production"`, resolved against the - current working directory, so a repository-root pytest run would otherwise read real - deployment values into tests that assert declared defaults. + `Settings()` resolves its env file through the shared config seam, so a repository-root + pytest run would otherwise read a developer's real `.env.production` into tests that + assert declared defaults. The stub mirrors what `.github/workflows/quality-gate.yml` writes in CI: only `OPENROUTER_API_KEY`, which is required and which many tests need `get_settings()` to @@ -42,12 +43,15 @@ def isolate_settings_from_local_env_files(tmp_path_factory): stub = tmp_path_factory.mktemp("settings-env") / ".env.test" stub.write_text("OPENROUTER_API_KEY=test-placeholder-not-a-real-key\n", encoding="utf-8") - original = Settings.model_config.get("env_file") - Settings.model_config["env_file"] = str(stub) + original = os.environ.get("ENV_FILE") + os.environ["ENV_FILE"] = str(stub) try: yield finally: - Settings.model_config["env_file"] = original + if original is None: + os.environ.pop("ENV_FILE", None) + else: + os.environ["ENV_FILE"] = original @pytest.fixture diff --git a/tests/services/test_prompt_store.py b/tests/services/test_prompt_store.py index ab58926..21a1273 100644 --- a/tests/services/test_prompt_store.py +++ b/tests/services/test_prompt_store.py @@ -4,6 +4,7 @@ import pytest from transcription.config import Settings from transcription.errors import ErrorCategory +from transcription.services.errors import PromptLoadError from transcription.services.prompts import PromptStore from transcription.services.prompts import PromptStoreError @@ -79,6 +80,13 @@ def test_prompt_creation_and_empty_content_are_rejected(prompt_store): assert empty.value.category == ErrorCategory.VALIDATION +def test_prompt_store_failures_are_catchable_as_prompt_load_errors(prompt_store): + store, _ = prompt_store + + with pytest.raises(PromptLoadError): + store.read_prompt("missing.md") + + def test_recovery_requires_a_backup(prompt_store): store, _ = prompt_store diff --git a/tests/test_config_isolation.py b/tests/test_config_isolation.py index 6886e81..91aa691 100644 --- a/tests/test_config_isolation.py +++ b/tests/test_config_isolation.py @@ -1,22 +1,12 @@ -"""Suite-wide isolation of `Settings` from developer environment files. - -`Settings.model_config` declares `env_file=".env.production"`, resolved relative to the -current working directory. Running pytest from the repository root therefore loads a real -developer env file into tests that construct `Settings(...)` directly, and the declared -field defaults stop being what the suite actually exercises. - -That breaks verification in both directions: tests asserting default behavior fail locally -for environmental reasons, and tests asserting configured behavior can pass locally against -values that do not exist in CI. The autouse fixture in `conftest.py` neutralizes the class -level `env_file` so defaults are authoritative; tests that need file loading still pass -`_env_file=` explicitly, which takes precedence over the class config. -""" +"""Suite-wide isolation of `Settings` from developer environment files.""" from __future__ import annotations from pathlib import Path +import transcription.config as config_module from transcription.config import Settings +from transcription.config import resolve_settings_env_file_path def test_settings_defaults_are_not_overridden_by_a_local_env_file(): @@ -37,3 +27,23 @@ def test_explicit_env_file_still_loads(tmp_path): settings = Settings(openrouter_api_key="test-key", _env_file=env_path, _cli_parse_args=False) assert settings.port == 9123 + + +def test_settings_env_file_resolves_from_override_environment_variable(tmp_path, monkeypatch): + env_path = tmp_path / "custom.env" + env_path.write_text("PORT=9123\n", encoding="utf-8") + monkeypatch.setenv("ENV_FILE", str(env_path)) + + settings = Settings(openrouter_api_key="test-key", _cli_parse_args=False) + + assert settings.port == 9123 + + +def test_settings_default_env_path_is_anchored_to_project_root(tmp_path, monkeypatch): + monkeypatch.delenv("ENV_FILE", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(config_module, "PROJECT_ROOT", tmp_path / "project-root") + + resolved = resolve_settings_env_file_path() + + assert resolved == tmp_path / "project-root" / ".env.production" diff --git a/tests/test_orphan_sweep.py b/tests/test_orphan_sweep.py index 10c5b9e..a93da0e 100644 --- a/tests/test_orphan_sweep.py +++ b/tests/test_orphan_sweep.py @@ -92,10 +92,6 @@ KNOWN_ORPHANS: dict[str, str] = { "Public read helper currently unused by runtime flows, but retained as part of " "the SourceService API pending endpoint consolidation." ), - "src/transcription/ui/pages/tags_page.py": ( - "Retained temporarily as explicitly dead code until the planned route-retirement " - "cleanup deletes the stranded module." - ), } diff --git a/tests/ui/test_runtime_settings_store.py b/tests/ui/test_runtime_settings_store.py index 77281ab..1f5fcbc 100644 --- a/tests/ui/test_runtime_settings_store.py +++ b/tests/ui/test_runtime_settings_store.py @@ -107,6 +107,22 @@ def test_runtime_settings_uses_override_env_file(tmp_path: Path, monkeypatch): assert "PORT=9001" in target.read_text(encoding="utf-8") +def test_runtime_settings_falls_back_to_shared_settings_env_file_override(tmp_path: Path, monkeypatch): + settings = _settings_for_runtime_editing(tmp_path) + target = tmp_path / "shared.env" + target.write_text("OPENROUTER_API_KEY=test-key\nPORT=8000\n", encoding="utf-8") + monkeypatch.delenv("RUNTIME_SETTINGS_ENV_FILE", raising=False) + monkeypatch.setenv("ENV_FILE", str(target)) + + snapshot = save_runtime_settings( + settings=settings, + updates={"port": "9001"}, + ) + + assert snapshot.env_file_path == target + assert "PORT=9001" in target.read_text(encoding="utf-8") + + def test_save_runtime_settings_surfaces_unreadable_target(tmp_path: Path, monkeypatch): settings = _settings_for_runtime_editing(tmp_path) env_path = tmp_path / ".env"