Implement rec - Phase 4 complete
Quality Gate / gate (push) Successful in 2m38s

This commit is contained in:
Jim Lancaster
2026-09-02 16:23:09 -05:00
parent 27ec81ca5b
commit 6f4accf275
13 changed files with 98 additions and 141 deletions
+2
View File
@@ -21,7 +21,9 @@ This runbook is the operational checklist for releasing and monitoring the trans
1. Deploy artifact/config to target environment. 1. Deploy artifact/config to target environment.
- V6.0 Phase 1 production stack: `docker compose -f docker-compose.production.yml up -d --build` - 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`. - 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 <sqlite-path-or-url> --target-db <postgres-url>` before switching runtime. - For SQLite -> PostgreSQL cutover, run `uv run python tools/export_import_migration.py verify --source-db <sqlite-path-or-url> --target-db <postgres-url>` before switching runtime.
2. Validate service startup: 2. Validate service startup:
- `/healthz` responds `200` - `/healthz` responds `200`
+2 -1
View File
@@ -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 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 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. - 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 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 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: - Runtime Settings includes an explicit "Other settings not shown here" markdown table listing:
+17 -1
View File
@@ -7,6 +7,7 @@ are resolved by the provider adapters, not here.
import copy import copy
import logging.config import logging.config
import os
from collections.abc import Sequence from collections.abc import Sequence
from enum import StrEnum from enum import StrEnum
from functools import cache from functools import cache
@@ -26,6 +27,16 @@ from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict from pydantic_settings import SettingsConfigDict
logger = logging.getLogger(__name__) 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): class Provider(StrEnum):
@@ -66,7 +77,7 @@ DatabaseSettings = Annotated[
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env.production", env_file=None,
env_file_encoding="utf-8", env_file_encoding="utf-8",
extra="ignore", extra="ignore",
env_nested_delimiter="__", env_nested_delimiter="__",
@@ -75,6 +86,11 @@ class Settings(BaseSettings):
frozen=True, 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 --- # --- NiceGUI Server ---
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 8000 port: int = 8000
+6 -1
View File
@@ -32,6 +32,11 @@ def new_error_id() -> str:
return uuid4().hex[:8] 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): class AppError(RuntimeError):
"""Base application error carrying user-safe handling metadata.""" """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, category=ErrorCategory.INTERNAL_UNEXPECTED,
suggestion="Retry once. If it persists, review logs and report the error reference id.", suggestion="Retry once. If it persists, review logs and report the error reference id.",
retriable=False, retriable=False,
detail=f"{type(exc).__name__}: {exc}", detail=exception_detail(exc),
) )
logger.error( logger.error(
"Unexpected error operation=%s error_id=%s", "Unexpected error operation=%s error_id=%s",
+4
View File
@@ -16,6 +16,10 @@ class PromptLoadError(AppError):
"""Raised when prompt artifacts cannot be loaded safely.""" """Raised when prompt artifacts cannot be loaded safely."""
class PromptStoreError(PromptLoadError):
"""Raised when prompt storage validation or persistence fails."""
class TranscriptionError(AppError): class TranscriptionError(AppError):
"""Raised when transcription execution fails.""" """Raised when transcription execution fails."""
+3 -6
View File
@@ -9,17 +9,14 @@ from uuid import uuid4
from ..config import Settings from ..config import Settings
from ..config import get_settings from ..config import get_settings
from ..errors import AppError
from ..errors import ErrorCategory from ..errors import ErrorCategory
from ..errors import exception_detail
from .errors import PromptStoreError
PROMPT_EXTENSION = ".md" PROMPT_EXTENSION = ".md"
BACKUP_SUFFIX = ".bak" BACKUP_SUFFIX = ".bak"
class PromptStoreError(AppError):
"""Raised when prompt storage validation or persistence fails."""
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PromptSummary: class PromptSummary:
"""Read model for one editable prompt artifact.""" """Read model for one editable prompt artifact."""
@@ -189,5 +186,5 @@ class PromptStore:
message, message,
category=ErrorCategory.INFRA_PERSISTENT, category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check prompt directory permissions and available disk space, then retry.", suggestion="Check prompt directory permissions and available disk space, then retry.",
detail=f"{type(exc).__name__}: {exc}", detail=exception_detail(exc),
) )
-92
View File
@@ -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()
+7 -17
View File
@@ -16,8 +16,10 @@ from pydantic import ValidationError
from transcription.config import Provider from transcription.config import Provider
from transcription.config import Settings from transcription.config import Settings
from transcription.config import resolve_settings_env_file_path
from transcription.errors import AppError from transcription.errors import AppError
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.errors import exception_detail
FieldControl = Literal["text", "bool", "select"] FieldControl = Literal["text", "bool", "select"]
@@ -385,13 +387,14 @@ def save_runtime_settings(
"Runtime settings file is not writable.", "Runtime settings file is not writable.",
category=ErrorCategory.INFRA_PERSISTENT, category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Verify file path and write permissions, then retry.", 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 ) from exc
refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False) refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False)
return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path) 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: def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) -> Path:
_ = settings
if env_file_path is not None: if env_file_path is not None:
return env_file_path return env_file_path
@@ -399,20 +402,7 @@ def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) ->
if override: if override:
return Path(override) return Path(override)
configured = settings.model_config.get("env_file") return resolve_settings_env_file_path()
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")
def _display_value(value: object) -> str | bool: def _display_value(value: object) -> str | bool:
@@ -473,7 +463,7 @@ def _read_env_lines(path: Path) -> list[str]:
"Runtime settings file is unreadable.", "Runtime settings file is unreadable.",
category=ErrorCategory.INFRA_PERSISTENT, category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Verify file path and read permissions, then retry.", 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 ) from exc
@@ -551,7 +541,7 @@ def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None:
"Runtime settings file is not writable.", "Runtime settings file is not writable.",
category=ErrorCategory.INFRA_PERSISTENT, category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Verify file path and write permissions, then retry.", 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 ) from exc
finally: finally:
if temp_path is not None: if temp_path is not None:
+10 -6
View File
@@ -4,6 +4,7 @@ Every test gets a fresh in-memory SQLite database so tests are
isolated, fast, and leave no artifacts on disk. isolated, fast, and leave no artifacts on disk.
""" """
import os
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -28,9 +29,9 @@ from transcription.services.jobs import JobService
def isolate_settings_from_local_env_files(tmp_path_factory): def isolate_settings_from_local_env_files(tmp_path_factory):
"""Point `Settings` at a controlled stub env file instead of a developer one. """Point `Settings` at a controlled stub env file instead of a developer one.
`Settings.model_config` declares `env_file=".env.production"`, resolved against the `Settings()` resolves its env file through the shared config seam, so a repository-root
current working directory, so a repository-root pytest run would otherwise read real pytest run would otherwise read a developer's real `.env.production` into tests that
deployment values into tests that assert declared defaults. assert declared defaults.
The stub mirrors what `.github/workflows/quality-gate.yml` writes in CI: only 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 `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 = tmp_path_factory.mktemp("settings-env") / ".env.test"
stub.write_text("OPENROUTER_API_KEY=test-placeholder-not-a-real-key\n", encoding="utf-8") stub.write_text("OPENROUTER_API_KEY=test-placeholder-not-a-real-key\n", encoding="utf-8")
original = Settings.model_config.get("env_file") original = os.environ.get("ENV_FILE")
Settings.model_config["env_file"] = str(stub) os.environ["ENV_FILE"] = str(stub)
try: try:
yield yield
finally: 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 @pytest.fixture
+8
View File
@@ -4,6 +4,7 @@ import pytest
from transcription.config import Settings from transcription.config import Settings
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.services.errors import PromptLoadError
from transcription.services.prompts import PromptStore from transcription.services.prompts import PromptStore
from transcription.services.prompts import PromptStoreError 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 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): def test_recovery_requires_a_backup(prompt_store):
store, _ = prompt_store store, _ = prompt_store
+23 -13
View File
@@ -1,22 +1,12 @@
"""Suite-wide isolation of `Settings` from developer environment files. """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.
"""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import transcription.config as config_module
from transcription.config import Settings 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(): 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) settings = Settings(openrouter_api_key="test-key", _env_file=env_path, _cli_parse_args=False)
assert settings.port == 9123 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"
-4
View File
@@ -92,10 +92,6 @@ KNOWN_ORPHANS: dict[str, str] = {
"Public read helper currently unused by runtime flows, but retained as part of " "Public read helper currently unused by runtime flows, but retained as part of "
"the SourceService API pending endpoint consolidation." "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."
),
} }
+16
View File
@@ -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") 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): def test_save_runtime_settings_surfaces_unreadable_target(tmp_path: Path, monkeypatch):
settings = _settings_for_runtime_editing(tmp_path) settings = _settings_for_runtime_editing(tmp_path)
env_path = tmp_path / ".env" env_path = tmp_path / ".env"