gpt-5.3 codex review: Phase 7 and the addition of the new test-effectiveness-auditor skill.
Quality Gate / gate (push) Failing after 12s

This commit is contained in:
Jim Lancaster
2026-08-20 11:50:10 -05:00
parent 443a1e29c8
commit 7c4300f9c2
21 changed files with 831 additions and 119 deletions
+36 -31
View File
@@ -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
@@ -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
+48 -13
View File
@@ -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.
@@ -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.
+84
View File
@@ -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.
@@ -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.
+19 -5
View File
@@ -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)
+4 -1
View File
@@ -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
+25 -6
View File
@@ -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")
+69 -2
View File
@@ -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()
+25 -2
View File
@@ -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",
},
}
@@ -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~
@@ -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
@@ -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.]
+127 -26
View File
@@ -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]
+5 -1
View File
@@ -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
+2 -2
View File
@@ -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)
+17
View File
@@ -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:
+16 -1
View File
@@ -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
+93
View File
@@ -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 == {}
+10 -2
View File
@@ -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