diff --git a/.env.example b/.env.example index 6f30590..7d89829 100644 --- a/.env.example +++ b/.env.example @@ -1,35 +1,42 @@ -# --- NiceGUI Server --- -# HOST=`0.0.0.0` (default) -# PORT=8000 (default) -# LOG_LEVEL: [`critical`, `error`, `warning`, `info` (default), `debug`, `trace`] -# RELOAD=false (default) +# Canonical settings mirror for src/transcription/config.py (Settings). +# Any value here overrides the in-code default. + +# --- NiceGUI Server --- +HOST=0.0.0.0 +PORT=8000 +# LOG_LEVEL: critical | error | warning | info | debug | trace +LOG_LEVEL=info +RELOAD=false +LOG_DIR=./data/logs +LOG_FILE_NAME=transcription.log +LOG_FILE_MAX_BYTES=10485760 +LOG_FILE_BACKUP_COUNT=5 # --- AI provider --- -# PROVIDER=[`openrouter`(default), `google_genai`] +# PROVIDER: openrouter PROVIDER=openrouter -# OPENROUTER_API_KEY - Required when `PROVIDER=openrouter` +# Required. OPENROUTER_API_KEY=your-api-key-goes-here -# GEMINI_API_KEY - Required when `PROVIDER=google_genai` -# PROVIDER_MODEL= specify model. If left blank OpenRouter will supply default. PROVIDER_MODEL=google/gemini-2.5-flash -# Optional JSON allowlist for model selection. The default above is always first. -# PROVIDER_MODELS=["google/gemini-2.5-flash","google/gemini-2.5-pro","anthropic/claude-sonnet-4"] -# OPENROUTER_HTTP_REFERER=https://example.com -# OPENROUTER_APP_TITLE="Google: Gemini 2.5 Flash (openrouter)" +# PROVIDER_MODELS default: derived from PROVIDER_MODEL when omitted. +# If provided, use a non-empty JSON array. +# PROVIDER_MODELS=["google/gemini-2.5-flash","anthropic/claude-sonnet-4"] +# OPENROUTER_HTTP_REFERER= +# OPENROUTER_APP_TITLE= +DEFAULT_PROMPT_NAME=transcribe_document.md +# TRANSCRIPTION_TEMPERATURE default: unset (optional range 0.0..2.0) +# TRANSCRIPTION_TEMPERATURE= +# TRANSCRIPTION_TOP_P default: unset (optional range 0.0..1.0) +# TRANSCRIPTION_TOP_P= # --- runtime environment --- -# ENVIRONMENT: [`development`(default), `test`, `production`] +# ENVIRONMENT: development | test | production +ENVIRONMENT=development # --- persistence --- -# Use nested settings with double underscore because env_nested_delimiter="__". -# SQLite example: -# DATABASE__DRIVER=sqlite -# DATABASE__PATH=app.db -# -# SQLite with custom relative path: -# DATABASE__DRIVER=sqlite +# Use nested keys (env_nested_delimiter="__"). +DATABASE__DRIVER=sqlite DATABASE__PATH=./data/transcription.db -# # Postgres example: # DATABASE__DRIVER=postgres # DATABASE__HOST=localhost @@ -37,20 +44,18 @@ DATABASE__PATH=./data/transcription.db # DATABASE__DATABASE=transcription # DATABASE__USER=postgres # DATABASE__PASSWORD=change-me -# -# Optional persistence flags: -# BOOTSTRAP_SCHEMA_ON_STARTUP=false -# SQLITE_CHECK_SAME_THREAD=false +BOOTSTRAP_SCHEMA_ON_STARTUP=false +SQLITE_CHECK_SAME_THREAD=false # --- filesystem paths --- -UPLOAD_DIR="./data" -PROMPT_DIR="./prompts" +UPLOAD_DIR=./data +PROMPT_DIR=./prompts +HOMEPAGE_DIR=./data/homepage +DATABASE_BACKUP_DIR=./data/backups # --- worker reliability --- WORKER_MAX_RETRIES=0 -WORKER_RETRY_BACKOFF_SECONDS=0 -# WORKER_PROVIDER_TIMEOUT_SECONDS=180 -WORKER_PROVIDER_TIMEOUT_SECONDS=180 +WORKER_PROVIDER_TIMEOUT_SECONDS=30.0 WORKER_MIN_TRANSCRIPTION_CHARS=0 WORKER_MIN_TRANSCRIPTION_LINES=0 WORKER_FAIL_ON_FINISH_REASON_LENGTH=false diff --git a/.github/instructions/services.instructions.md b/.github/instructions/services.instructions.md index daabbe5..f2bd304 100644 --- a/.github/instructions/services.instructions.md +++ b/.github/instructions/services.instructions.md @@ -132,6 +132,7 @@ Atomicity rules: - `Source.raw_transcription` is a projection, not authoritative history. - Service/UI read paths that touch relationships must be eager-loaded for `lazy="raise"` compatibility. - If model fields, enums, constraints, indexes, or relationship-loading semantics change, update `docs/ver4/schema_v4.md` in the same change. +- If `Settings` fields or defaults change in `src/transcription/config.py`, update `.env.example` in the same change so keys/defaults remain synchronized and no stale settings remain documented. # Service Composition diff --git a/.github/skills/python-code-reviewer/skill.md b/.github/skills/python-code-reviewer/skill.md index 99fad4c..00eb062 100644 --- a/.github/skills/python-code-reviewer/skill.md +++ b/.github/skills/python-code-reviewer/skill.md @@ -26,11 +26,15 @@ Perform thorough, evidence-based code reviews for Python projects. Every finding ## Review Workflow 1. **Map the Repository First:** Inspect entry points, package layout, configurations, dependency manifests, and any project-specific rule files (`AGENTS.md`, `CLAUDE.md`, `.github/instructions/`). Project-specific conventions override generic advice. -2. **Read Representative Modules:** Sample across all layers (routes/pages, UI components, services, workers, persistence, provider adapters, settings, tests) before drawing conclusions. -3. **Verify Claims:** Run or reference project tooling (`ruff check`, `ty`, `pytest`) rather than guessing. -4. **Prioritize Hot Paths:** Focus deeply on request handling, database sessions, background workers, and external API calls. -5. **Enforce Read-Only Safety:** Do not modify code unless explicitly instructed. -6. **Escalate Provenance Audits:** For evidence/provenance-heavy changes, apply invariant checks from `.github/skills/evidence-provenance-auditor/skill.md` and include pass/fail outcomes in the report. +2. **Establish Canonical Authority First:** Read architecture/contracts (`docs/ver4/*`, `docs/invariant/*`, UI docs) and active instructions/skills before evaluating source behavior. +3. **Read Representative Modules:** Sample across all layers (routes/pages, UI components, services, workers, persistence, provider adapters, settings, tests) before drawing conclusions. +4. **Run Drift Analysis:** Compare documented intended behavior versus repository ground truth; identify both implementation drift and undocumented-but-repeatable conventions that should be formalized. +5. **Assess Boundary and Coupling Health:** Evaluate UI/service/persistence/provider dependency flow, identify circular dependencies, leaky abstractions, and transaction ownership ambiguity. +6. **Assess Invariant Placement:** For each hard rule, decide whether it belongs in docs (rationale), instructions (active steering), skills (periodic audit procedure), or deterministic tests (enforcement). +7. **Verify Claims:** Run or reference project tooling (`ruff check`, `ty`, `pytest`) rather than guessing. +8. **Prioritize Hot Paths:** Focus deeply on request handling, database sessions, background workers, and external API calls. +9. **Enforce Read-Only Safety:** Do not modify code unless explicitly instructed. +10. **Escalate Provenance Audits:** For evidence/provenance-heavy changes, apply invariant checks from `.github/skills/evidence-provenance-auditor/skill.md` and include pass/fail outcomes in the report. ## Repo-Specific Deterministic Checks (Transcription) @@ -89,6 +93,12 @@ When reviewing this repository, always include explicit pass/fail checks for: ### 9. Duplication & Consolidation - Identify repeated code blocks, candidate helper abstractions, divergent patterns for identical operations, and duplicated domain constants. +### 10. Architecture & Governance +- **Architectural Drift:** Compare intended architecture rules against implementation behavior and cite concrete drift points. +- **Systemic Health:** Evaluate domain cohesion, dependency direction, lifecycle consistency, and operational reliability seams. +- **Invariant Routing:** Recommend the correct enforcement layer per rule (docs vs instructions vs skills vs tests). +- **Meta-Tooling Alignment:** Recommend updates for instruction files and skills when repository patterns or contracts evolve. + ## Output Report Structure & Template Generate Markdown reports in `./docs` following this exact template structure: @@ -106,7 +116,13 @@ Generate Markdown reports in `./docs` following this exact template structure: --- -## 2. Findings by Severity +## 2. Executive Architecture Assessment +- High-level verdict on domain cohesion, boundary clarity, and architecture fitness. +- Top 3-5 systemic risks or bottlenecks. + +--- + +## 3. Findings by Severity ### Critical Severity #### [CRIT-01] Title @@ -129,7 +145,19 @@ Generate Markdown reports in `./docs` following this exact template structure: --- -## 3. Stack-Specific Analysis +## 4. Architectural Drift & Gap Analysis +| Area / Component | Documented / Intended Rule | Actual Implementation State | Severity | Recommended Resolution | +| :--- | :--- | :--- | :--- | :--- | + +--- + +## 5. Invariant Inventory & Routing Recommendations +| Invariant / Constraint | Current Location | Recommended Target Layer | Rationale | +| :--- | :--- | :--- | :--- | + +--- + +## 6. Stack-Specific Analysis - Python 3.12+ Best Practices - FastAPI - NiceGUI @@ -141,7 +169,7 @@ Generate Markdown reports in `./docs` following this exact template structure: --- -## 4. Duplication & Consolidation Report +## 7. Duplication & Consolidation Report | Pattern / Duplication | Locations | Proposed Canonical Home | Estimated Lines Removed | | :--- | :--- | :--- | :--- | @@ -150,12 +178,19 @@ Generate Markdown reports in `./docs` following this exact template structure: --- -## 5. Prioritized Action Plan -1. **Phase 1: Quick Wins (PR 1-2)** -2. **Phase 2: Reliability & Concurrency (PR 3-4)** -3. **Phase 3: Consolidation & Refactoring (PR 5-6)** +## 8. Meta-Tooling & Instruction Update Recommendations +- Required updates to docs/instructions/skills/tests to keep enforcement current. --- -## 6. Preserved Strengths +## 9. Prioritized Dependency-Ordered Action Plan +1. **Phase 1: Blocking fixes** +2. **Phase 2: Enforcement hardening** +3. **Phase 3: Reliability & concurrency** +4. **Phase 4: Consolidation & refactoring** +5. **Phase 5: Non-blocking governance/documentation depth** + +--- + +## 10. Preserved Strengths - Existing patterns worth maintaining. \ No newline at end of file diff --git a/.github/skills/test-effectiveness-auditor/skill.md b/.github/skills/test-effectiveness-auditor/skill.md new file mode 100644 index 0000000..0103278 --- /dev/null +++ b/.github/skills/test-effectiveness-auditor/skill.md @@ -0,0 +1,96 @@ +--- +name: test-effectiveness-auditor +description: Periodic reviewer for test-suite signal quality. Detects low-value or redundant tests, validates contract coverage, and recommends pruning or strengthening actions. +--- + +# Test Effectiveness Auditor + +Run a deterministic audit of test usefulness. Focus on whether tests catch real regressions, not whether they merely execute code. + +## When to Use + +- Monthly/quarterly test-health review. +- Pre-release hardening when test count grows quickly. +- After major AI-assisted test generation. +- When suite runtime is increasing without clear quality gains. + +## Primary Objectives + +1. Identify tests that are weak, redundant, or non-diagnostic. +2. Confirm critical contracts are guarded by meaningful assertions. +3. Produce a prune/strengthen backlog with explicit risk and effort. + +## Normative References (Transcription Repo) + +1. `docs/ver4/*` +2. `docs/invariant/*` +3. `.github/instructions/*.instructions.md` +4. `tests/test_meta_contract_guards.py` +5. Contract-specific guards (`tests/test_service_boundaries.py`, `tests/test_ui_boundaries.py`, worker/evidence/media/error suites) + +## Deterministic Audit Checks + +### A. Contract Traceability +- Each high-risk contract maps to at least one focused regression test file. +- Missing mapping is a gap. + +### B. Assertion Strength +- Flag tests that only assert status code, non-null, or “no exception” without validating state transitions or persisted outcomes. +- Prefer assertions on domain effects: DB rows, status changes, error categories, evidence writes, or emitted payload shape. + +### C. Failure-Path Coverage +- Critical paths must include negative-path tests (timeouts, provider errors, validation failures, cancellation paths, retries). +- Happy-path-only coverage on critical modules is a gap. + +### D. Redundancy and Noise +- Detect near-duplicate tests asserting the same behavior at multiple layers with no extra signal. +- Recommend canonical location (unit/integration) and prune overlaps. + +### E. Mutation/Change Sensitivity +- Prefer mutation testing for high-risk modules when practical. +- If not run, identify tests likely to survive meaningful code mutations (low sensitivity). + +### F. Drift Guards +- Verify config/doc/instruction contracts have deterministic guards and are current. +- Ensure settings/docs synchronization checks remain active. + +## Evidence Standards + +- Every finding must include concrete file paths and line ranges. +- No speculative claims. +- Distinguish clearly between: + - **Confirmed ineffective tests** + - **Likely weak tests (needs mutation/probe confirmation)** + +## Output Format + +Produce a Markdown report in `docs/`: + +```markdown +# Test Effectiveness Audit Report + +## 1. Executive Verdict +- Effective / Effective with Conditions / Needs Remediation +- Top risks to confidence + +## 2. Contract Coverage Matrix +| Contract | Guarding Tests | Signal Quality | Gap | Action | +| :--- | :--- | :--- | :--- | :--- | + +## 3. Weak/Redundant Test Findings +| Finding ID | Location | Why Low-Signal | Risk | Recommendation | +| :--- | :--- | :--- | :--- | :--- | + +## 4. Prune/Strengthen Backlog +| Task ID | Goal | Files | Acceptance Criteria | Validation | +| :--- | :--- | :--- | :--- | :--- | + +## 5. Confidence Recommendation +- Go / Go with Conditions / No-Go for release confidence +``` + +## Decision Rules + +- Do not recommend deleting a test unless equivalent or stronger coverage is identified. +- Prefer strengthening assertions before adding more tests. +- Prioritize deterministic contract guards over broad snapshot-style tests. diff --git a/docs/production-runbook.md b/docs/production-runbook.md new file mode 100644 index 0000000..47d89eb --- /dev/null +++ b/docs/production-runbook.md @@ -0,0 +1,84 @@ +# Production Runbook + +This runbook is the operational checklist for releasing and monitoring the transcription system. + +## 1. Pre-release gate checklist + +1. Run the full suite: `uv run pytest` +2. Confirm contract guardrails are green: + - `uv run pytest tests/test_meta_contract_guards.py` +3. Confirm health endpoint includes worker liveness payload (`/healthz` returns `worker.state`). +4. Confirm required runtime settings are present in deployment environment: + - `OPENROUTER_API_KEY` + - `DATABASE__*` + - filesystem paths for data/logs/backups. +5. Confirm schema contract alignment is current: + - `src/transcription/db/models.py` + - `docs/ver4/schema_v4.md` + +## 2. Release execution steps + +1. Deploy artifact/config to target environment. +2. Validate service startup: + - `/healthz` responds `200` + - `worker.state` is `running` +3. Execute one smoke workflow: + - create a document/job with at least one source + - verify terminal job outcome updates + - verify execution evidence row appended +4. Verify log flow: + - stdout aggregation receives events + - file logs are written under `./data/logs` + +## 3. Rollback triggers and actions + +### Trigger conditions + +1. `/healthz` reports `worker.state=failed` +2. Repeated provider timeout/error spikes beyond normal baseline +3. Evidence write failures or DB persistence failures + +### Actions + +1. Roll back app artifact and config to previous release. +2. Restart service and re-check `/healthz`. +3. Re-run smoke workflow and confirm worker returns to `running`. +4. Preserve incident evidence: + - `./data/logs` + - relevant DB rows (`job`, `job_source`, `execution_attempt`) + +## 4. Post-release monitoring checklist + +## First 24 hours + +1. Monitor `/healthz` periodically for `worker.state`. +2. Track job terminal distribution (`transcribed`, `partial_success`, `failed`). +3. Sample timeout/error categories for abnormal increase. +4. Spot-check new `execution_attempt` records for append-only growth and timing metadata. + +## First 72 hours + +1. Re-check error/timeout trend versus 24h baseline. +2. Verify no recurring worker-failed states. +3. Verify storage growth and rotation behavior under `./data/logs`. +4. Confirm incident response notes are captured for any production anomalies. + +## 5. Operator playbook for common incidents + +### Worker failed + +1. Check `/healthz` payload (`error_id`, `error_category`). +2. Locate matching error in logs. +3. If non-transient defect persists, roll back. + +### Provider timeout spike + +1. Confirm provider reachability and rate limits. +2. Review timeout frequency and impacted job volume. +3. If sustained, execute rollback criteria and notify stakeholders. + +### Partial-success increase + +1. Inspect affected `job_source` and `execution_attempt` records. +2. Confirm failures are category-aligned (`external`/`timeout`/`internal`). +3. Triage whether issue is source quality, provider, or runtime regression. diff --git a/docs/test-effectiveness-audit-2026-08-20.md b/docs/test-effectiveness-audit-2026-08-20.md new file mode 100644 index 0000000..177247a --- /dev/null +++ b/docs/test-effectiveness-audit-2026-08-20.md @@ -0,0 +1,45 @@ +# Test Effectiveness Audit Report + +## 1. Executive Verdict + +- **Effective with Conditions** +- The suite has strong contract coverage for architecture governance, worker reliability, error taxonomy, evidence append-only semantics, and media safety. +- The largest confidence risk is a placeholder module with eight empty tests that always pass and contribute no regression signal. +- A smaller risk is several exception-path tests that assert only exception type and do not validate envelope/category/detail semantics. + +## 2. Contract Coverage Matrix + +| Contract | Guarding Tests | Signal Quality | Gap | Action | +| :--- | :--- | :--- | :--- | :--- | +| Service boundary isolation | `tests/test_service_boundaries.py` | Strong | None | Keep as-is | +| UI boundary isolation | `tests/test_ui_boundaries.py` | Strong | None | Keep as-is | +| Canonical docs/instruction authority + settings parity | `tests/test_meta_contract_guards.py` | Strong | None | Keep as-is | +| Error envelope taxonomy mapping | `tests/api/test_error_responses.py` | Strong | None | Keep as-is | +| Worker non-retriable stop + resilience behavior | `tests/test_worker.py`, `tests/services/test_workflows_reliability.py` | Strong | None | Keep as-is | +| Evidence append-only + candidate promotion invariants | `tests/services/test_v45_candidates.py` | Strong | None | Keep as-is | +| UI/API media path safety | `tests/test_media_path_safety.py`, `tests/ui/test_media_urls.py` | Strong | None | Keep as-is | +| Service base behavior contract | `tests/services/test_service_base.py` | **None (current tests empty)** | **High** | Replace placeholders with real assertions or remove file | + +## 3. Weak/Redundant Test Findings + +| Finding ID | Location | Why Low-Signal | Risk | Recommendation | +| :--- | :--- | :--- | :--- | :--- | +| TE-01 | `tests/services/test_service_base.py:6-35` | Contains eight `test_*` functions with docstrings only and no executable assertions. | High: false confidence and inflated pass count. | Replace with real behavior checks against `ServiceBase` session-scope semantics, or delete file until concrete tests exist. | +| TE-02 | `tests/test_engine_registry.py:36-37` | `test_disposing_an_unregistered_url_is_a_noop` asserts only “no exception.” It does not verify registry state invariants before/after call. | Medium: regression may survive if behavior changes silently without raising. | Assert that previously created engine/session-factory instances for other URLs remain unchanged after noop disposal path. | +| TE-03 | `tests/services/test_v45_candidates.py:90-94` | `pytest.raises(CandidatePromotionError)` validates type only; no assertions on message/category/suggestion for user-safe failure semantics. | Low-Med: weaker diagnostics contract protection. | Capture exception and assert critical error metadata fields to strengthen failure-path guarantees. | + +## 4. Prune/Strengthen Backlog + +| Task ID | Goal | Files | Acceptance Criteria | Validation | +| :--- | :--- | :--- | :--- | :--- | +| TE-T1 | Eliminate zero-signal placeholder tests | `tests/services/test_service_base.py`, `src/transcription/services/base.py` | No empty `test_*` functions remain; each test has behavior assertions that fail on meaningful `ServiceBase` regressions. | `uv run pytest tests/services/test_service_base.py` | +| TE-T2 | Strengthen noop disposal invariant test | `tests/test_engine_registry.py` | Noop disposal test verifies unaffected URL registries remain intact and disposed URL behavior is unchanged. | `uv run pytest tests/test_engine_registry.py` | +| TE-T3 | Strengthen exception-path semantics checks | `tests/services/test_v45_candidates.py` (and similar raise-only tests where high-value) | Exception tests assert key semantic fields (message/category/suggestion or equivalent domain signal), not only type. | `uv run pytest tests/services/test_v45_candidates.py` | + +## 5. Confidence Recommendation + +- **Go with Conditions** for test-confidence governance. +- Exit criteria: + 1. Complete TE-T1 (highest priority). + 2. Complete TE-T2. + 3. Apply TE-T3 at least on high-risk service error paths. diff --git a/src/transcription/api/health.py b/src/transcription/api/health.py index 2ee9fa5..bff83a6 100644 --- a/src/transcription/api/health.py +++ b/src/transcription/api/health.py @@ -1,16 +1,30 @@ """Health endpoint routes.""" from fastapi import APIRouter +from fastapi import Request + +from transcription.worker import resolve_worker_health router = APIRouter() -def healthz() -> dict[str, str]: - """Return a simple health status payload.""" - return {"status": "ok"} +def healthz(request: Request) -> dict[str, object]: + """Return health status with worker-liveness signal.""" + worker = resolve_worker_health(request.app.state) + payload: dict[str, object] = { + "status": "ok", + "worker": { + "state": worker.state, + }, + } + if worker.error_id is not None: + payload["worker"]["error_id"] = worker.error_id + if worker.error_category is not None: + payload["worker"]["error_category"] = worker.error_category + return payload @router.get("/healthz") -def healthz_route() -> dict[str, str]: +def healthz_route(request: Request) -> dict[str, object]: """Route wrapper for health status payload.""" - return healthz() + return healthz(request) diff --git a/src/transcription/app.py b/src/transcription/app.py index e770893..a0b5135 100644 --- a/src/transcription/app.py +++ b/src/transcription/app.py @@ -45,12 +45,14 @@ async def _lifespan(app: FastAPI): settings.upload_dir.mkdir(parents=True, exist_ok=True) settings.prompt_dir.mkdir(parents=True, exist_ok=True) + settings.log_dir.mkdir(parents=True, exist_ok=True) + settings.database_backup_dir.mkdir(parents=True, exist_ok=True) await _recover_stale_processing_jobs(app) async with AsyncExitStack() as stack: stack.push_async_callback(dispose_database_runtime) - stop_event, worker_notifier = await stack.enter_async_context( + stop_event, worker_notifier, worker_health = await stack.enter_async_context( worker_consumer_lifespan( session_factory=app.state.runtime.session_factory, poll_interval_seconds=1.0, @@ -58,6 +60,7 @@ async def _lifespan(app: FastAPI): ) app.state.worker_stop_event = stop_event app.state.worker_notifier = worker_notifier + app.state.worker_health = worker_health yield diff --git a/src/transcription/config.py b/src/transcription/config.py index 880e2de..5e03e30 100644 --- a/src/transcription/config.py +++ b/src/transcription/config.py @@ -5,6 +5,7 @@ once at startup. Provider-specific defaults (model names, base URLs) are resolved by the provider adapters, not here. """ +import copy import logging.config from collections.abc import Sequence from enum import StrEnum @@ -42,7 +43,7 @@ class SqliteSettings(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) driver: Literal["sqlite"] = "sqlite" - path: NonEmptyStr = "app.db" + path: NonEmptyStr = "./data/transcription.db" class PostgresSettings(BaseModel): @@ -78,6 +79,10 @@ class Settings(BaseSettings): port: int = 8000 log_level: Literal["critical", "error", "warning", "info", "debug", "trace"] = "info" reload: bool = False + log_dir: Path = Path("./data/logs") + log_file_name: NonEmptyStr = "transcription.log" + log_file_max_bytes: int = Field(default=10 * 1024 * 1024, gt=0) + log_file_backup_count: int = Field(default=5, ge=1) # --- AI provider --- provider: Provider = Provider.OPENROUTER @@ -99,15 +104,16 @@ class Settings(BaseSettings): sqlite_check_same_thread: bool = False # --- filesystem paths --- - upload_dir: Path = Path("./uploads") + upload_dir: Path = Path("./data") prompt_dir: Path = Path("./prompts") homepage_dir: Path = Path("./data/homepage") + database_backup_dir: Path = Path("./data/backups") # --- worker reliability --- worker_max_retries: int = Field(default=0, ge=0) # Bounded only from below. Vision transcription of a dense page routinely runs # well past twenty seconds, so an upper cap here would silently fail real work. - worker_provider_timeout_seconds: float = Field(default=180.0, gt=0.0) + worker_provider_timeout_seconds: float = Field(default=30.0, gt=0.0) worker_min_transcription_chars: int = Field(default=0, ge=0) worker_min_transcription_lines: int = Field(default=0, ge=0) worker_fail_on_finish_reason_length: bool = False @@ -193,16 +199,24 @@ LOGGING_CONFIG: dict[str, Any] = { "class": "logging.StreamHandler", "formatter": "standard", "stream": "ext://sys.stdout", + }, + "file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "standard", + "filename": str((Path("./data/logs") / "transcription.log")), + "maxBytes": 10 * 1024 * 1024, + "backupCount": 5, + "encoding": "utf-8", } }, "root": { "level": "INFO", - "handlers": ["console"], + "handlers": ["console", "file"], }, "loggers": { "transcription": { "level": "DEBUG", - "handlers": ["console"], + "handlers": ["console", "file"], "propagate": False, } }, @@ -211,8 +225,13 @@ LOGGING_CONFIG: dict[str, Any] = { def configure_logging(settings: Settings | None = None) -> None: """Configure root logging once at startup.""" - cfg = LOGGING_CONFIG.copy() + cfg = copy.deepcopy(LOGGING_CONFIG) active_settings = settings or get_settings() + active_settings.log_dir.mkdir(parents=True, exist_ok=True) + file_handler = cfg["handlers"]["file"] + file_handler["filename"] = str(active_settings.log_dir / active_settings.log_file_name) + file_handler["maxBytes"] = active_settings.log_file_max_bytes + file_handler["backupCount"] = active_settings.log_file_backup_count cfg["loggers"]["transcription"]["level"] = active_settings.log_level.upper() logging.config.dictConfig(cfg) logger.debug("Logging configured") diff --git a/src/transcription/worker.py b/src/transcription/worker.py index 74c1fa9..e963b0c 100644 --- a/src/transcription/worker.py +++ b/src/transcription/worker.py @@ -8,6 +8,8 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from contextlib import contextmanager from contextlib import suppress +from dataclasses import dataclass +from typing import Literal from typing import Protocol from typing import runtime_checkable @@ -24,6 +26,48 @@ from .services.workflows import process_next_queued_job as process_next_queued_j logger = logging.getLogger(__name__) +WorkerHealthState = Literal["starting", "running", "stopped", "failed", "unknown"] + + +@dataclass(frozen=True, slots=True) +class WorkerHealthSnapshot: + """Structured worker-loop health status for API visibility.""" + + state: WorkerHealthState + error_id: str | None = None + error_category: str | None = None + + +class WorkerHealth: + """Mutable worker-loop health state owned by app lifespan.""" + + def __init__(self) -> None: + self._state: WorkerHealthState = "starting" + self._error_id: str | None = None + self._error_category: str | None = None + + def mark_running(self) -> None: + self._state = "running" + self._error_id = None + self._error_category = None + + def mark_stopped(self) -> None: + if self._state != "failed": + self._state = "stopped" + + def mark_failed(self, error: AppError) -> None: + self._state = "failed" + self._error_id = error.error_id + self._error_category = error.category.value + + def snapshot(self) -> WorkerHealthSnapshot: + return WorkerHealthSnapshot( + state=self._state, + error_id=self._error_id, + error_category=self._error_category, + ) + + @runtime_checkable class WorkerNotifier(Protocol): """Abstraction for signaling the worker loop about new work.""" @@ -59,28 +103,42 @@ def resolve_worker_notifier(state: object) -> WorkerNotifier: return NoopWorkerNotifier() +def resolve_worker_health(state: object) -> WorkerHealthSnapshot: + """Resolve worker health from app-like state objects.""" + health = getattr(state, "worker_health", None) + if isinstance(health, WorkerHealth): + return health.snapshot() + if isinstance(health, WorkerHealthSnapshot): + return health + if health is not None: + logger.warning("Ignoring worker_health of unsupported type %r; reporting unknown.", type(health)) + return WorkerHealthSnapshot(state="unknown") + + @asynccontextmanager async def worker_consumer_lifespan( *, session_factory: async_sessionmaker[AsyncSession] | None = None, poll_interval_seconds: float = 1.0, -) -> AsyncGenerator[tuple[asyncio.Event, WorkerNotifier]]: +) -> AsyncGenerator[tuple[asyncio.Event, WorkerNotifier, WorkerHealth]]: """Start and stop the worker consumer loop for app lifespan.""" stop_event = asyncio.Event() wake_event = asyncio.Event() worker_notifier: WorkerNotifier = EventWorkerNotifier(wake_event) + worker_health = WorkerHealth() worker_task = asyncio.create_task( run_worker_loop( session_factory=session_factory, stop_event=stop_event, wake_event=wake_event, poll_interval_seconds=poll_interval_seconds, + worker_health=worker_health, ) ) worker_notifier.notify() try: - yield stop_event, worker_notifier + yield stop_event, worker_notifier, worker_health finally: stop_event.set() worker_notifier.notify() @@ -126,6 +184,7 @@ async def run_worker_loop( stop_event: asyncio.Event | None = None, wake_event: asyncio.Event | None = None, poll_interval_seconds: float = 1.0, + worker_health: WorkerHealth | None = None, ) -> None: """Run worker loop until stop_event is set. @@ -142,9 +201,13 @@ async def run_worker_loop( """ services = ServiceBundle.from_session_factory(session_factory) try: + if worker_health is not None: + worker_health.mark_running() while True: if stop_event is not None and stop_event.is_set(): logger.info("Worker stop event received") + if worker_health is not None: + worker_health.mark_stopped() return if wake_event is not None: @@ -177,7 +240,11 @@ async def run_worker_loop( error.error_id, error.category.value, ) + if worker_health is not None: + worker_health.mark_failed(error) finally: + if worker_health is not None: + worker_health.mark_stopped() await services.aclose() diff --git a/tests/api/test_health.py b/tests/api/test_health.py index 28c6a23..52fbc00 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -4,12 +4,13 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from transcription.api.health import router +from transcription.worker import WorkerHealthSnapshot class TestHealthEndpoint: """Verify /healthz endpoint behavior.""" - def test_healthz_returns_ok_status(self): + def test_healthz_returns_ok_status_with_unknown_worker_when_uninitialized(self): """GET /healthz returns a healthy status payload.""" app = FastAPI() app.include_router(router) @@ -18,4 +19,26 @@ class TestHealthEndpoint: response = client.get("/healthz") assert response.status_code == 200 - assert response.json() == {"status": "ok"} + assert response.json() == {"status": "ok", "worker": {"state": "unknown"}} + + def test_healthz_returns_worker_failure_metadata_when_available(self): + app = FastAPI() + app.state.worker_health = WorkerHealthSnapshot( + state="failed", + error_id="abc12345", + error_category="internal_unexpected_error", + ) + app.include_router(router) + client = TestClient(app) + + response = client.get("/healthz") + + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "worker": { + "state": "failed", + "error_id": "abc12345", + "error_category": "internal_unexpected_error", + }, + } diff --git a/tests/artifacts/transcriptions/Book_Two_-_page_02.jpg.txt b/tests/artifacts/transcriptions/Book_Two_-_page_02.jpg.txt index 768bcc3..e6fa454 100644 --- a/tests/artifacts/transcriptions/Book_Two_-_page_02.jpg.txt +++ b/tests/artifacts/transcriptions/Book_Two_-_page_02.jpg.txt @@ -1,11 +1,11 @@ source: Book Two - page 02.jpg provider: openrouter -model: google/gemini-2.5-flash +model: openai/gpt-5.3-codex --- [document body typewritten] BY WAY OF INTRODUCTION:- -These few paragraphs of introduction may help you read BOOK 2 which covers a wider range than did BOOK 1 (Pioneer Days). +These few paragraphs of introduotion [sic] may help you read BOOK 2 which covers a wider range than did BOOK 1 (Pioneer Days). BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 consisted largely of first generation family history. BOOK 2 throws more light on the second generation. Sidney promises a BOOK 3 and that may begin to do justice to the third generation. We suggest that Sidney get the help of Louis Shinn who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq Plains. Louis has the gift of seeing, recalling and telling. One sentence in his chapter gives a great tribute to the Doumecqers--so far as he knows no one on the Doumecq Plains went on relief during the depression. That in a nutshell shows the sturdy character of the residents of the Doumecq Plains. @@ -13,5 +13,6 @@ We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of John Some who get this book will consider the group picture the best thing in the book. It took a lot of preliminary photographing to reduce some pictures, enlarge others and bring out the tin types. We wish that instead of 44 faces we could have given 88. Do not blame Ethel Cochran-Shinn for the selection. She furnished enough pictures but we had to take only part of them. We think there are great possibilities in reproducing old pictures. We wish we had a Pickard group. Some Pickard descendant may wish to make a collection. -We are much impressed with the future possibilities of getting a complete geneol-ogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones. --2- +We are much impressed with the future possibilities of getting a complete genealogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones. + +~2~ diff --git a/tests/artifacts/transcriptions/Omie_Writes_Home.pdf.txt b/tests/artifacts/transcriptions/Omie_Writes_Home.pdf.txt index d3bb572..faac9d0 100644 --- a/tests/artifacts/transcriptions/Omie_Writes_Home.pdf.txt +++ b/tests/artifacts/transcriptions/Omie_Writes_Home.pdf.txt @@ -1,13 +1,13 @@ source: Omie Writes Home.pdf provider: openrouter -model: google/gemini-2.5-flash +model: openai/gpt-5.3-codex --- -[document body mixed] -JOHN E. COCHRAN -FAMILY ASSOCIATION +[document body typeset] Family Only -[handwritten: Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF] +Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF + OMIE WRITES HOME + Ed. Note: The following letter was written by Omie Cochran in Nome, Alaska and sent to her sister, Ethel Shinn, in Canfield, Idaho in 1923. It has been stored away these 63 years in the original envelope with its 2 cent stamp. The letter has a number of references to the Shinn @@ -16,7 +16,9 @@ Miss Saville was the nurse at the Nome Hospital that was mentioned in the articl the family newsletter two years ago. Nome Alaska August 26, 1923 + My Dear Ethel et al. + I don't know when I did write or when you did but I am going to write now however and never the less. But I wish I could talk (I can yet but I @@ -26,8 +28,6 @@ rascal of yours would fairly sparkle with listening. Can't I see him listening now to all the yarns we told last summer? -[photo: Five people, appearing to be native Alaskan, in winter clothing, standing on a beach with a dog sled in front of them and a boat in the background] - You see, we-Miss Saville and I, took a trip north on the Buford and it was very interesting. We went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain @@ -35,4 +35,84 @@ of our craft and some other mighty hunters went out first in kayaks and later in shot seven walrus. When they also took a movie man and camera, so you will likely see all this in the movies before I get to tell you. They came back on board and the ship went up along the icebergs and the walrus were 'heisted' on board by the use of the crane which loads -tons of freight and the beasts were +tons of freight and the beasts were so huge that they made the pulleys just creak. They were +over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or +flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles + +[photograph of people standing outdoors in snow] + +all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are +the most repulsive looking animals imaginable and tho I have always read about them I never +expect such disagreeable looking creatures. They had a rough brown hairy skin and some of +them looked warty. They must have weighed two ton at least. Ere we got them back to Nome +to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the +ship with any degree of comfort unless you had per chance lost your sense of smell. + +Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile +we would go to Wrangell Island where some men from Steffonsons ship were supposed to be +stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in +Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23 +years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or +3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room +was partitioned off at the sides with skins for sleeping quarters. In the main part they had the +fire on the ground and the fish drying on lines and the skins hanging around and the dogs and +babies and children. They wore skin clothes entirely. The women's were made like bloomers +and were heavily padded for warmth. They wore high mukluks and really looked very +comfortable. The babies were in fur skins with the fur inside and they looked like little Teddy +bears with faces. I guess they had never seen white women, not so many at one time anyway. +We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band +playing and the rear of the last life boat was the movie man. 'Twas very thrilling. + +The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists' +went wild. They rushed helter-skelter, hither and thither, here and there, trying to find +something to buy. Prices raised right before your eyes. One would but something for $1.00 +and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to +have it. One man I was sort of taking care of, tho he had his son along for the purpose, +bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one +fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the +little natives throw them at the flocks of geese and ducks which fly close over the village and +the slings entangle their wings and legs, sometimes more than one, and they can't fly. They +come down and the natives capture them. There was more junk brot aboard than baggage, I +do believe. And they say that at the first stop it was worse than here. The red flag was flying +over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the +number. + +We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out +until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let +us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is +all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he +didn't and Polly would in my lap and I don't know much about the youngest one of yours so +likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf. + +I expect there were 150 passengers on board and almost or more of the crew and helpers. We +had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times. + +I have had jobs nearly all summer but not very much in them. Next week, September 4, +school opens. I wish they would wait for a week but you know these school men. Wouldn't +make any special difference I suppose for I would just fritter away the time but still one likes +to postpone the inevitable. + +I just now stopped and re-read your letter and it sure was a bird. I don't especially blame Ray +for reading over your shoulder. It would seem, then that you have bright children. Maybe +they do know something about Geography. But it is ridiculous to speak of Louis finishing the +eighth grade. Why you and I were grown children when we finished and he is only a baby. I +am rather afraid he doesn't know much. I quite remember your little timid Maurice and how +he shifted his affection from his Aunt Om and yelled and howled and screamed steadily for a +week while his mother went to S.S. Ask him is he recalls this little interview. Do you suppose +he does? + +Ever hear from Zen? or Inez? They don't seem to be very writing inclined tho Zen has done +well this year. Even sent me a telegram a few weeks ago. Well, if anything else ever happens, +I'll write again. Don't suppose it ever will, tho. + +Lots of love to all, + +Ome + +Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986 + +© JECFA 1986 + +Up + +jecochranclan.org ~ Contact webmaster diff --git a/tests/artifacts/transcriptions/Rod_Moser_Letter_-_p1.jpg.txt b/tests/artifacts/transcriptions/Rod_Moser_Letter_-_p1.jpg.txt index a9520bd..16bb391 100644 --- a/tests/artifacts/transcriptions/Rod_Moser_Letter_-_p1.jpg.txt +++ b/tests/artifacts/transcriptions/Rod_Moser_Letter_-_p1.jpg.txt @@ -1,31 +1,32 @@ source: Rod Moser Letter - p1.jpg provider: openrouter -model: google/gemini-2.5-flash +model: openai/gpt-5.3-codex --- [document body mixed] -JOHN ISBILL -R. T. MOSER +JOHN ISBILL R. T. MOSER + ISBILL & MOSER DEALERS IN GENERAL MERCHANDISE -[handwritten: Vonore, Tenn. Jan'y 27 - 1913] -[handwritten: Dear Much Aunt Louing +Vonore, Tenn., [handwritten: July 27-] 191[handwritten: 2] + +[handwritten: Dear Uncle Aunt & Cousins I was at home a few nights ago & saw a -letter from you folks, so +letter from your folks, so I decided to write you -a few lines myself & -I am contemplating a +a few lines in regards of +I am [contemplating?] a trip out west next summer -& would like of [illegible] to go +& want [lots?] of [olders?] to go where I am from. Am getting -up in years & unmarried +up in years & wondering, so you see the object of my trip, is to get a wife -of there is any old maids -or widows out there, I -want you to kiss them -at my [illegible] for me at home -as soon as I get there] +If there is any old maids +or widows out there I +want you to [hire?] them +at [one?] [find?] me at there +as soon as I get there.] diff --git a/tests/services/test_service_base.py b/tests/services/test_service_base.py index 71c8b82..c45ed02 100644 --- a/tests/services/test_service_base.py +++ b/tests/services/test_service_base.py @@ -1,36 +1,137 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + import pytest +from transcription.config import Settings +from transcription.services.base import ServiceBase -class TestServiceBase: - class TestInitialization: - def test_initializes_with_defaults(self): - """Test initialization with default session factory and queue.""" - def test_initializes_with_custom_session_factory(self): - """Test initialization with a provided session factory.""" +class _TrackingSession: + def __init__(self) -> None: + self.commits = 0 + self.flushes = 0 + self.refreshed: list[object] = [] - def test_initializes_with_custom_queue(self): - """Test initialization with a provided queue.""" + async def commit(self) -> None: + self.commits += 1 - class TestSessionScope: - @pytest.mark.asyncio - async def test_uses_provided_session(self): - """Test that session scope reuses a provided session.""" + async def flush(self) -> None: + self.flushes += 1 - @pytest.mark.asyncio - async def test_creates_new_session_when_none_provided(self): - """Test that session scope creates a new session when none is provided.""" + async def refresh(self, obj: object) -> None: + self.refreshed.append(obj) - class TestContextManagerBehavior: - @pytest.mark.asyncio - async def test_yields_session(self): - """Test that session scope yields a usable session object.""" - @pytest.mark.asyncio - async def test_multiple_operations(self): - """Test multiple operations within a single session scope.""" +def _settings() -> Settings: + return Settings(_env_file=None, openrouter_api_key="test-key") - class TestEdgeCases: - @pytest.mark.asyncio - async def test_handles_exception_propagation(self): - """Test exception propagation behavior inside session scope.""" + +def test_initializes_with_defaults(monkeypatch): + settings = _settings() + session_factory = object() + + monkeypatch.setattr("transcription.services.base.get_settings", lambda: settings) + monkeypatch.setattr( + "transcription.services.base.resolve_session_factory", + lambda **kwargs: session_factory, + ) + + service = ServiceBase() + + assert service.settings is settings + assert service.session_factory is session_factory + + +def test_initializes_with_custom_session_factory(): + settings = _settings() + session_factory = object() + + service = ServiceBase(settings=settings, session_factory=session_factory) + + assert service.settings is settings + assert service.session_factory is session_factory + + +@pytest.mark.asyncio +async def test_session_scope_reuses_provided_session(monkeypatch): + captured: dict[str, object | None] = {} + provided_session = object() + yielded = object() + service = ServiceBase(settings=_settings(), session_factory=object()) + + @asynccontextmanager + async def _fake_session_scope(*, session_factory=None, session=None): + captured["session_factory"] = session_factory + captured["session"] = session + yield yielded if session is None else session + + monkeypatch.setattr("transcription.services.base.session_scope", _fake_session_scope) + + async with service._session_scope(session=provided_session) as active: + assert active is provided_session + + assert captured == {"session_factory": service.session_factory, "session": provided_session} + + +@pytest.mark.asyncio +async def test_session_scope_creates_owned_session_when_none_provided(monkeypatch): + captured: dict[str, object | None] = {} + owned_session = object() + service = ServiceBase(settings=_settings(), session_factory=object()) + + @asynccontextmanager + async def _fake_session_scope(*, session_factory=None, session=None): + captured["session_factory"] = session_factory + captured["session"] = session + yield owned_session + + monkeypatch.setattr("transcription.services.base.session_scope", _fake_session_scope) + + async with service._session_scope() as active: + assert active is owned_session + + assert captured == {"session_factory": service.session_factory, "session": None} + + +@pytest.mark.asyncio +async def test_session_scope_propagates_exceptions(monkeypatch): + service = ServiceBase(settings=_settings(), session_factory=object()) + + @asynccontextmanager + async def _fake_session_scope(*, session_factory=None, session=None): + _ = (session_factory, session) + yield object() + + monkeypatch.setattr("transcription.services.base.session_scope", _fake_session_scope) + + with pytest.raises(RuntimeError, match="boom"): + async with service._session_scope(): + raise RuntimeError("boom") + + +@pytest.mark.asyncio +async def test_finalize_commits_for_service_owned_session(): + service = ServiceBase(settings=_settings(), session_factory=object()) + session = _TrackingSession() + refreshed = object() + + await service._finalize(session=session, caller_session=None, refresh=(refreshed,)) + + assert session.commits == 1 + assert session.flushes == 0 + assert session.refreshed == [refreshed] + + +@pytest.mark.asyncio +async def test_finalize_flushes_for_caller_owned_session(): + service = ServiceBase(settings=_settings(), session_factory=object()) + session = _TrackingSession() + refreshed = object() + + await service._finalize(session=session, caller_session=object(), refresh=(refreshed,)) + + assert session.commits == 0 + assert session.flushes == 1 + assert session.refreshed == [refreshed] diff --git a/tests/services/test_v45_candidates.py b/tests/services/test_v45_candidates.py index ef4e7bb..372dba0 100644 --- a/tests/services/test_v45_candidates.py +++ b/tests/services/test_v45_candidates.py @@ -87,11 +87,15 @@ async def test_promotion_rejects_unrelated_attempt(default_session_factory): services = _services(default_session_factory, settings) source = await _seed_source(services) - with pytest.raises(CandidatePromotionError): + with pytest.raises(CandidatePromotionError) as exc_info: await services.evidence.promote_machine_attempt( source_id=source.id, execution_attempt_id=uuid4(), ) + error = exc_info.value + assert error.category.value == "validation_error" + assert "successful transcription attempt" in error.message + assert "Select an available successful candidate" in error.suggestion @pytest.mark.integration diff --git a/tests/test_app.py b/tests/test_app.py index 5308d65..2f2f3ae 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -54,7 +54,7 @@ class TestAppLifespan: @asynccontextmanager async def _worker_lifespan(**_kwargs): calls.append("worker_start") - yield object(), object() + yield object(), object(), object() calls.append("worker_stop") monkeypatch.setattr("transcription.app.worker_consumer_lifespan", _worker_lifespan) @@ -110,7 +110,7 @@ class TestAppLifespan: @asynccontextmanager async def _worker_lifespan(**_kwargs): calls.append("worker_start") - yield object(), object() + yield object(), object(), object() calls.append("worker_stop") monkeypatch.setattr("transcription.app.worker_consumer_lifespan", _worker_lifespan) diff --git a/tests/test_config.py b/tests/test_config.py index cc6ed7e..a41d52b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,7 @@ """Tests for transcription.config — settings loading, provider defaults, and paths.""" +import logging +from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Any @@ -8,6 +10,7 @@ from pydantic import ValidationError from transcription.config import Provider from transcription.config import Settings +from transcription.config import configure_logging from transcription.config import parse_cli_settings @@ -141,6 +144,20 @@ class TestPathSettings: settings = _make_settings() assert isinstance(settings.upload_dir, Path) assert isinstance(settings.prompt_dir, Path) + assert isinstance(settings.log_dir, Path) + assert isinstance(settings.database_backup_dir, Path) + + +def test_configure_logging_writes_rotating_file_logs_to_configured_directory(tmp_path): + log_dir = tmp_path / "logs" + settings = _make_settings(log_dir=log_dir, log_file_name="app.log") + + configure_logging(settings) + + root_handlers = logging.getLogger().handlers + file_handlers = [handler for handler in root_handlers if isinstance(handler, RotatingFileHandler)] + assert file_handlers + assert Path(file_handlers[0].baseFilename) == log_dir / "app.log" class TestWorkerReliabilitySettings: diff --git a/tests/test_engine_registry.py b/tests/test_engine_registry.py index 2aed60c..c5c20fc 100644 --- a/tests/test_engine_registry.py +++ b/tests/test_engine_registry.py @@ -34,7 +34,22 @@ async def test_distinct_urls_produce_distinct_engines_and_eviction_is_targeted() @pytest.mark.asyncio async def test_disposing_an_unregistered_url_is_a_noop(): - await dispose_engine("sqlite+aiosqlite:///./.registry-test-never-created.db") + never_created = "sqlite+aiosqlite:///./.registry-test-never-created.db" + engine_a = get_engine(URL_A) + engine_b = get_engine(URL_B) + factory_a = get_session_factory(URL_A) + factory_b = get_session_factory(URL_B) + + await dispose_engine(never_created) + await dispose_session_factory(never_created) + + assert get_engine(URL_A) is engine_a + assert get_engine(URL_B) is engine_b + assert get_session_factory(URL_A) is factory_a + assert get_session_factory(URL_B) is factory_b + + await dispose_session_factory(URL_A) + await dispose_session_factory(URL_B) @pytest.mark.asyncio diff --git a/tests/test_meta_contract_guards.py b/tests/test_meta_contract_guards.py index 85edfc7..e66ac70 100644 --- a/tests/test_meta_contract_guards.py +++ b/tests/test_meta_contract_guards.py @@ -2,8 +2,11 @@ from __future__ import annotations +import re from pathlib import Path +from transcription.config import Settings + PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -97,3 +100,93 @@ def test_canonical_authority_references_are_present(): if absent: missing[relative_path] = absent assert missing == {} + + +def _declared_env_example_keys(*, include_commented: bool) -> set[str]: + text = _read(".env.example") + pattern = r"^\s*#?\s*([A-Z0-9_]+)\s*=" if include_commented else r"^\s*([A-Z0-9_]+)\s*=" + return {match.group(1) for match in re.finditer(pattern, text, flags=re.MULTILINE)} + + +def _active_env_example_values() -> dict[str, str]: + text = _read(".env.example") + return { + key: value.strip() + for key, value in re.findall(r"^\s*([A-Z0-9_]+)\s*=\s*(.*)$", text, flags=re.MULTILINE) + } + + +def _normalize_env_path_value(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + return normalized + + +def test_env_example_keys_match_runtime_settings_contract(): + """Guard against .env.example drift from Settings keys.""" + declared = _declared_env_example_keys(include_commented=True) + settings_keys = {field.upper() for field in Settings.model_fields if field != "database"} + database_keys = { + "DATABASE__DRIVER", + "DATABASE__PATH", + "DATABASE__HOST", + "DATABASE__PORT", + "DATABASE__DATABASE", + "DATABASE__USER", + "DATABASE__PASSWORD", + } + allowed = settings_keys | database_keys + + missing = sorted(allowed - declared) + unknown = sorted(declared - allowed) + assert missing == [] + assert unknown == [] + + +def test_env_example_default_values_match_settings_defaults(): + """Uncommented .env.example entries should mirror in-code defaults.""" + defaults = Settings(_env_file=None, openrouter_api_key="placeholder-key") + expected = { + "HOST": defaults.host, + "PORT": str(defaults.port), + "LOG_LEVEL": defaults.log_level, + "RELOAD": str(defaults.reload).lower(), + "LOG_DIR": str(defaults.log_dir), + "LOG_FILE_NAME": defaults.log_file_name, + "LOG_FILE_MAX_BYTES": str(defaults.log_file_max_bytes), + "LOG_FILE_BACKUP_COUNT": str(defaults.log_file_backup_count), + "PROVIDER": defaults.provider.value, + "PROVIDER_MODEL": defaults.provider_model or "", + "DEFAULT_PROMPT_NAME": defaults.default_prompt_name, + "ENVIRONMENT": defaults.environment, + "DATABASE__DRIVER": defaults.database.driver, + "DATABASE__PATH": getattr(defaults.database, "path", ""), + "BOOTSTRAP_SCHEMA_ON_STARTUP": str(defaults.bootstrap_schema_on_startup).lower(), + "SQLITE_CHECK_SAME_THREAD": str(defaults.sqlite_check_same_thread).lower(), + "UPLOAD_DIR": str(defaults.upload_dir), + "PROMPT_DIR": str(defaults.prompt_dir), + "HOMEPAGE_DIR": str(defaults.homepage_dir), + "DATABASE_BACKUP_DIR": str(defaults.database_backup_dir), + "WORKER_MAX_RETRIES": str(defaults.worker_max_retries), + "WORKER_PROVIDER_TIMEOUT_SECONDS": str(defaults.worker_provider_timeout_seconds), + "WORKER_MIN_TRANSCRIPTION_CHARS": str(defaults.worker_min_transcription_chars), + "WORKER_MIN_TRANSCRIPTION_LINES": str(defaults.worker_min_transcription_lines), + "WORKER_FAIL_ON_FINISH_REASON_LENGTH": str(defaults.worker_fail_on_finish_reason_length).lower(), + } + active = _active_env_example_values() + path_like_keys = {"LOG_DIR", "UPLOAD_DIR", "PROMPT_DIR", "HOMEPAGE_DIR", "DATABASE_BACKUP_DIR"} + mismatches = { + key: { + "expected": _normalize_env_path_value(expected_value) if key in path_like_keys else expected_value, + "actual": _normalize_env_path_value(active.get(key)) if key in path_like_keys else active.get(key), + } + for key, expected_value in expected.items() + if ( + (_normalize_env_path_value(active.get(key)) if key in path_like_keys else active.get(key)) + != (_normalize_env_path_value(expected_value) if key in path_like_keys else expected_value) + ) + } + assert mismatches == {} diff --git a/tests/test_worker.py b/tests/test_worker.py index 9d6f7ed..48325d8 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -8,6 +8,7 @@ from transcription.errors import AppError from transcription.errors import ErrorCategory from transcription.services import ServiceBundle from transcription.services.sources import SourceService +from transcription.worker import WorkerHealth from transcription.worker import process_next_queued_job from transcription.worker import run_worker_loop @@ -23,6 +24,7 @@ async def test_run_worker_loop_stops_on_non_retriable_exception(monkeypatch, cap """ calls = 0 stop_event = asyncio.Event() + worker_health = WorkerHealth() async def _fake_process_next_queued_job(*, session=None, session_factory=None, services=None): nonlocal calls @@ -34,12 +36,16 @@ async def test_run_worker_loop_stops_on_non_retriable_exception(monkeypatch, cap with caplog.at_level(logging.CRITICAL): await asyncio.wait_for( - run_worker_loop(stop_event=stop_event, poll_interval_seconds=0), + run_worker_loop(stop_event=stop_event, poll_interval_seconds=0, worker_health=worker_health), timeout=5, ) assert calls == 1 assert "Worker loop stopped after a non-retriable error" in caplog.text + snapshot = worker_health.snapshot() + assert snapshot.state == "failed" + assert snapshot.error_id is not None + assert snapshot.error_category == ErrorCategory.INTERNAL_UNEXPECTED.value @pytest.mark.asyncio @@ -47,6 +53,7 @@ async def test_run_worker_loop_survives_retriable_exception(monkeypatch, caplog) """A retriable fault is still suppressed so transient conditions do not stop work.""" calls = 0 stop_event = asyncio.Event() + worker_health = WorkerHealth() async def _fake_process_next_queued_job(*, session=None, session_factory=None, services=None): nonlocal calls @@ -65,10 +72,11 @@ async def test_run_worker_loop_survives_retriable_exception(monkeypatch, caplog) monkeypatch.setattr("transcription.worker.process_next_queued_job", _fake_process_next_queued_job) with caplog.at_level(logging.ERROR): - await run_worker_loop(stop_event=stop_event, poll_interval_seconds=0) + await run_worker_loop(stop_event=stop_event, poll_interval_seconds=0, worker_health=worker_health) assert calls == 2 assert "Worker loop exception" in caplog.text + assert worker_health.snapshot().state == "stopped" @pytest.mark.asyncio