This commit is contained in:
Jim Lancaster
2026-06-26 18:19:14 -05:00
41 changed files with 265 additions and 192 deletions
+5 -2
View File
@@ -4,10 +4,13 @@ from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse
from transcription.errors import AppError, ErrorCategory, build_error_envelope
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import build_error_envelope
from transcription.security import AccessDeniedError
logger = logging.getLogger(__name__)
+9 -10
View File
@@ -5,17 +5,16 @@ from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter
from pydantic import BaseModel, Field
from pydantic import BaseModel
from pydantic import Field
from transcription.services.library import (
accept_revision,
add_revision,
export_transcripts,
get_job_detail,
list_jobs,
list_revisions,
search_accepted_transcripts,
)
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import get_job_detail
from transcription.services.library import list_jobs
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
router = APIRouter(prefix="/api", tags=["transcription"])
+13 -12
View File
@@ -3,25 +3,27 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from threading import Event, Thread
from threading import Event
from threading import Thread
from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse
from transcription.api.errors import register_error_handlers
from transcription.api.health import router as health_router
from transcription.api.routes import router as transcription_router
from transcription.config import get_settings, setup_logging
from transcription.db import (
create_all,
dispose_database_runtime,
initialize_database_runtime,
should_bootstrap_schema,
validate_schema_compatibility,
)
from transcription.config import get_settings
from transcription.config import setup_logging
from transcription.db import create_all
from transcription.db import dispose_database_runtime
from transcription.db import initialize_database_runtime
from transcription.db import should_bootstrap_schema
from transcription.db import validate_schema_compatibility
from transcription.errors import build_error_envelope
from transcription.migrations import apply_pending_migrations
from transcription.security import AccessDeniedError, enforce_request_access
from transcription.security import AccessDeniedError
from transcription.security import enforce_request_access
from transcription.ui import register_pages
from transcription.worker import run_worker_loop
@@ -104,4 +106,3 @@ def create_app() -> FastAPI:
app.include_router(health_router)
app.include_router(transcription_router)
return app
+2 -2
View File
@@ -5,7 +5,6 @@ once at startup. Provider-specific defaults (model names, base URLs)
are resolved by the provider adapters, not here.
"""
import logging
import logging.config
from enum import StrEnum
from functools import lru_cache
@@ -13,7 +12,8 @@ from pathlib import Path
from typing import Literal
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
class Provider(StrEnum):
+5 -3
View File
@@ -10,10 +10,12 @@ from dataclasses import dataclass
from sqlalchemy import inspect
from sqlalchemy.engine import Engine
from sqlmodel import Session, SQLModel, create_engine
from transcription.config import Settings, get_settings
from sqlmodel import Session
from sqlmodel import SQLModel
from sqlmodel import create_engine
from transcription.config import Settings
from transcription.config import get_settings
@dataclass(frozen=True)
+4 -3
View File
@@ -3,7 +3,8 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import UTC
from datetime import datetime
from enum import StrEnum
from uuid import uuid4
@@ -64,7 +65,7 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope:
category=error.category.value,
message=error.message,
suggestion=error.suggestion,
timestamp=datetime.now(timezone.utc).isoformat(),
timestamp=datetime.now(UTC).isoformat(),
)
@@ -84,4 +85,4 @@ def format_error_detail(error: AppError) -> str:
return (
f"[{error.category.value}] {error.message} | "
f"suggestion={error.suggestion} | error_id={error.error_id}"
)
)
+5 -4
View File
@@ -3,11 +3,12 @@
from __future__ import annotations
import argparse
import sys
from transcription.config import get_settings
from transcription.db import initialize_database_runtime, validate_schema_compatibility
from transcription.migrations import apply_pending_migrations, list_pending_migrations
from transcription.db import initialize_database_runtime
from transcription.db import validate_schema_compatibility
from transcription.migrations import apply_pending_migrations
from transcription.migrations import list_pending_migrations
def _build_parser() -> argparse.ArgumentParser:
@@ -71,4 +72,4 @@ def main() -> int:
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
+12 -8
View File
@@ -6,13 +6,16 @@ personal-scale deployments without introducing heavyweight migration tooling.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import logging
from typing import Callable
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection, Engine
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.engine import Connection
from sqlalchemy.engine import Engine
logger = logging.getLogger(__name__)
@@ -60,7 +63,7 @@ def _record_revision(connection: Connection, revision: MigrationRevision) -> Non
{
"revision_id": revision.revision_id,
"description": revision.description,
"applied_at": datetime.now(timezone.utc).isoformat(),
"applied_at": datetime.now(UTC).isoformat(),
},
)
@@ -83,9 +86,10 @@ def _apply_0001_add_retry_count(connection: Connection) -> None:
def _apply_0002_create_transcriptrevision(connection: Connection) -> None:
"""Ensure transcriptrevision table exists."""
# Import models lazily so metadata is fully populated.
from transcription.models import TranscriptRevision # noqa: F401
from sqlmodel import SQLModel
from transcription.models import TranscriptRevision # noqa: F401
table = SQLModel.metadata.tables["transcriptrevision"]
table.create(bind=connection, checkfirst=True)
@@ -125,4 +129,4 @@ def apply_pending_migrations(*, engine: Engine) -> list[str]:
applied_ids.append(revision.revision_id)
logger.info("Applied migration revision=%s", revision.revision_id)
return applied_ids
return applied_ids
+12 -8
View File
@@ -1,11 +1,15 @@
"""SQLModel domain models for the transcription system."""
from datetime import datetime, timezone
from datetime import UTC
from datetime import datetime
from enum import StrEnum
from typing import Optional
from uuid import UUID, uuid4
from uuid import UUID
from uuid import uuid4
from sqlmodel import Field, Relationship, SQLModel
from sqlmodel import Field
from sqlmodel import Relationship
from sqlmodel import SQLModel
class JobStatus(StrEnum):
@@ -22,7 +26,7 @@ class Document(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
filename: str
file_path: str
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
jobs: list["Job"] = Relationship(back_populates="document")
@@ -35,8 +39,8 @@ class Job(SQLModel, table=True):
document_id: UUID = Field(foreign_key="document.id")
status: JobStatus = Field(default=JobStatus.QUEUED)
retry_count: int = Field(default=0, ge=0)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
document: Document = Relationship(back_populates="jobs")
@@ -51,7 +55,7 @@ class Transcript(SQLModel, table=True):
job_id: UUID = Field(foreign_key="job.id", unique=True)
text: str | None = None
error_detail: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
job: Job = Relationship(back_populates="transcript")
@@ -66,7 +70,7 @@ class TranscriptRevision(SQLModel, table=True):
text: str
source: str = Field(default="worker")
accepted: bool = Field(default=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
job: Job = Relationship(back_populates="revisions")
+9 -9
View File
@@ -1,13 +1,13 @@
"""Provider exports and factory for transcription adapters."""
from transcription.config import Provider, Settings, get_settings
from transcription.providers.base import (
ProviderAuthError,
ProviderError,
ProviderResponseError,
TranscriptionProvider,
TranscriptionResult,
)
from transcription.config import Provider
from transcription.config import Settings
from transcription.config import get_settings
from transcription.providers.base import ProviderAuthError
from transcription.providers.base import ProviderError
from transcription.providers.base import ProviderResponseError
from transcription.providers.base import TranscriptionProvider
from transcription.providers.base import TranscriptionResult
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
@@ -21,11 +21,11 @@ def get_transcription_provider(*, settings: Settings | None = None) -> Transcrip
__all__ = [
"OpenRouterTranscriptionProvider",
"ProviderAuthError",
"ProviderError",
"ProviderResponseError",
"TranscriptionProvider",
"TranscriptionResult",
"OpenRouterTranscriptionProvider",
"get_transcription_provider",
]
+8 -12
View File
@@ -9,13 +9,12 @@ from typing import Any
from openrouter import OpenRouter
from transcription.config import Settings, get_settings
from transcription.providers.base import (
ProviderAuthError,
ProviderError,
ProviderResponseError,
TranscriptionResult,
)
from transcription.config import Settings
from transcription.config import get_settings
from transcription.providers.base import ProviderAuthError
from transcription.providers.base import ProviderError
from transcription.providers.base import ProviderResponseError
from transcription.providers.base import TranscriptionResult
logger = logging.getLogger(__name__)
@@ -55,7 +54,7 @@ class OpenRouterTranscriptionProvider:
http_referer=request.http_referer,
x_open_router_title=request.x_open_router_title,
)
except Exception as exc: # noqa: BLE001
except Exception as exc:
message = str(exc).lower()
if "401" in message or "auth" in message or "api key" in message:
raise ProviderAuthError("OpenRouter authentication failed") from exc
@@ -111,10 +110,7 @@ class OpenRouterTranscriptionProvider:
parts: list[str] = []
for item in content:
text_part = None
if isinstance(item, dict):
text_part = item.get("text")
else:
text_part = self._get_optional_attr(item, "text")
text_part = item.get("text") if isinstance(item, dict) else self._get_optional_attr(item, "text")
if isinstance(text_part, str) and text_part.strip():
parts.append(text_part.strip())
+4 -3
View File
@@ -9,7 +9,8 @@ import secrets
from fastapi import Request
from transcription.config import Settings
from transcription.errors import AppError, ErrorCategory
from transcription.errors import AppError
from transcription.errors import ErrorCategory
class AccessDeniedError(AppError):
@@ -22,7 +23,7 @@ class AccessDeniedError(AppError):
def is_protected_path(path: str) -> bool:
"""Return True when a request path requires operator authentication."""
return path == "/ui" or path.startswith("/ui/") or path.startswith("/api")
return path == "/ui" or path.startswith(("/ui/", "/api"))
def enforce_request_access(*, request: Request, settings: Settings) -> None:
@@ -78,4 +79,4 @@ def _parse_basic_authorization_header(value: str | None) -> tuple[str, str]:
suggestion="Provide HTTP Basic operator credentials and retry.",
)
return username, password
return username, password
+14 -19
View File
@@ -1,30 +1,25 @@
"""Service layer exports."""
from transcription.services.transcription import (
DEFAULT_PROMPT_FILE,
PromptLoadError,
TranscriptionError,
load_image_payload,
load_prompt_text,
transcribe_document_image,
)
from transcription.services.upload import (
SUPPORTED_UPLOAD_EXTENSIONS,
UploadError,
UploadJobResult,
create_upload_job,
)
from transcription.services.transcription import DEFAULT_PROMPT_FILE
from transcription.services.transcription import PromptLoadError
from transcription.services.transcription import TranscriptionError
from transcription.services.transcription import load_image_payload
from transcription.services.transcription import load_prompt_text
from transcription.services.transcription import transcribe_document_image
from transcription.services.upload import SUPPORTED_UPLOAD_EXTENSIONS
from transcription.services.upload import UploadError
from transcription.services.upload import UploadJobResult
from transcription.services.upload import create_upload_job
__all__ = [
"DEFAULT_PROMPT_FILE",
"SUPPORTED_UPLOAD_EXTENSIONS",
"PromptLoadError",
"TranscriptionError",
"load_image_payload",
"load_prompt_text",
"transcribe_document_image",
"SUPPORTED_UPLOAD_EXTENSIONS",
"UploadError",
"UploadJobResult",
"create_upload_job",
"load_image_payload",
"load_prompt_text",
"transcribe_document_image",
]
+13 -6
View File
@@ -3,14 +3,21 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import UTC
from datetime import datetime
from uuid import UUID
from sqlmodel import Session, select
from sqlmodel import Session
from sqlmodel import select
from transcription.db import get_session
from transcription.errors import AppError, ErrorCategory
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
class LibraryError(AppError):
@@ -132,7 +139,7 @@ def add_revision(
transcript.error_detail = None
session.add(transcript)
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
if accepted:
job.status = JobStatus.COMPLETED
elif job.status == JobStatus.QUEUED:
@@ -174,7 +181,7 @@ def accept_revision(*, revision_id: UUID, session: Session | None = None) -> Tra
job = session.get(Job, revision.job_id)
if job is not None:
job.status = JobStatus.COMPLETED
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
session.add(job)
session.commit()
+10 -10
View File
@@ -6,16 +6,16 @@ import logging
import mimetypes
from pathlib import Path
from transcription.config import Settings, get_settings
from transcription.errors import AppError, ErrorCategory
from transcription.providers import (
ProviderAuthError,
ProviderError,
ProviderResponseError,
TranscriptionProvider,
TranscriptionResult,
get_transcription_provider,
)
from transcription.config import Settings
from transcription.config import get_settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.providers import ProviderAuthError
from transcription.providers import ProviderError
from transcription.providers import ProviderResponseError
from transcription.providers import TranscriptionProvider
from transcription.providers import TranscriptionResult
from transcription.providers import get_transcription_provider
logger = logging.getLogger(__name__)
+11 -6
View File
@@ -5,14 +5,19 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID, uuid4
from uuid import UUID
from uuid import uuid4
from sqlmodel import Session
from transcription.config import Settings, get_settings
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db import get_session
from transcription.errors import AppError, ErrorCategory
from transcription.models import Document, Job, JobStatus
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
logger = logging.getLogger(__name__)
@@ -73,7 +78,7 @@ def create_upload_job(
original_filename=filename,
stored_path=stored_path,
)
except Exception as exc: # noqa: BLE001
except Exception as exc:
_best_effort_delete(stored_path)
raise UploadError(
"Failed to create upload database records",
@@ -152,4 +157,4 @@ def _best_effort_delete(path: Path) -> None:
if path.exists():
path.unlink()
except OSError:
logger.warning("Failed to clean up upload file after DB error: %s", path)
logger.warning("Failed to clean up upload file after DB error: %s", path)
-2
View File
@@ -12,5 +12,3 @@ def register_pages(app: FastAPI) -> None:
register_upload_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
+4 -2
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
from nicegui import ui
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
def to_app_error(exc: Exception, *, operation: str) -> AppError:
@@ -37,4 +39,4 @@ def summarize_error(exc: Exception, *, operation: str) -> str:
error = to_app_error(exc, operation=operation)
if error.category == ErrorCategory.INTERNAL_UNEXPECTED:
return f"Unexpected error (ref: {error.error_id})"
return f"{error.message} (ref: {error.error_id})"
return f"{error.message} (ref: {error.error_id})"
+10 -9
View File
@@ -9,15 +9,16 @@ from nicegui import ui
from sqlmodel import select
from transcription.db import get_session
from transcription.models import Document, Job, Transcript
from transcription.services.library import (
accept_revision,
add_revision,
export_transcripts,
list_revisions,
search_accepted_transcripts,
)
from transcription.ui.error_presenter import show_error, summarize_error
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
from transcription.ui.error_presenter import show_error
from transcription.ui.error_presenter import summarize_error
@dataclass(frozen=True)
+5 -2
View File
@@ -7,8 +7,11 @@ from dataclasses import dataclass
from nicegui import ui
from nicegui.events import UploadEventArguments
from transcription.services.upload import UploadError, UploadJobResult, create_upload_job
from transcription.ui.error_presenter import show_error, summarize_error
from transcription.services.upload import UploadError
from transcription.services.upload import UploadJobResult
from transcription.services.upload import create_upload_job
from transcription.ui.error_presenter import show_error
from transcription.ui.error_presenter import summarize_error
@dataclass
+19 -10
View File
@@ -4,17 +4,26 @@ from __future__ import annotations
import logging
import time
from datetime import datetime, timezone
from datetime import UTC
from datetime import datetime
from threading import Event
from pydantic import ValidationError
from sqlalchemy.engine import Engine
from sqlmodel import Session, select
from sqlmodel import Session
from sqlmodel import select
from transcription.config import Settings, get_settings
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db import get_session
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, format_error_detail
from transcription.models import Document, Job, JobStatus, Transcript
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
from transcription.errors import format_error_detail
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.services.library import add_revision
from transcription.services.transcription import transcribe_document_image
@@ -44,7 +53,7 @@ def _process_next_queued_job(*, session: Session) -> bool:
logger.info("Picked queued job operation=worker.pick job_id=%s", job.id)
job.status = JobStatus.PROCESSING
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
session.add(job)
session.commit()
session.refresh(job)
@@ -75,7 +84,7 @@ def _process_next_queued_job(*, session: Session) -> bool:
session=session,
)
job.status = JobStatus.TRANSCRIBED
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
session.add(job)
session.commit()
logger.info(
@@ -85,7 +94,7 @@ def _process_next_queued_job(*, session: Session) -> bool:
result.provider,
revision.revision_number,
)
except Exception as exc: # noqa: BLE001
except Exception as exc:
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.process_job")
settings = _get_worker_settings()
if _should_retry(job=job, error=error, settings=settings):
@@ -139,7 +148,7 @@ def _requeue_for_retry(*, session: Session, job: Job, error: AppError, settings:
_upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.retry_count += 1
job.status = JobStatus.QUEUED
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
session.add(job)
session.commit()
if settings.worker_retry_backoff_seconds > 0:
@@ -149,7 +158,7 @@ def _requeue_for_retry(*, session: Session, job: Job, error: AppError, settings:
def _finalize_failed_job(*, session: Session, job: Job, error: AppError) -> None:
_upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.status = JobStatus.FAILED
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
session.add(job)
session.commit()
+5 -4
View File
@@ -5,18 +5,19 @@ from __future__ import annotations
import base64
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
import pytest
from transcription.api.errors import register_error_handlers
from transcription.errors import build_error_envelope
from transcription.security import AccessDeniedError, enforce_request_access
from transcription.security import AccessDeniedError
from transcription.security import enforce_request_access
def _basic_header(username: str, password: str) -> str:
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return f"Basic {token}"
@@ -125,4 +126,4 @@ class TestAccessControl:
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
assert response.json() == {"status": "ok"}
+3 -2
View File
@@ -1,11 +1,12 @@
"""Tests for API error response envelope handlers."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from transcription.api.errors import register_error_handlers
from transcription.errors import AppError, ErrorCategory
from transcription.errors import AppError
from transcription.errors import ErrorCategory
@pytest.mark.integration
+7 -6
View File
@@ -1,12 +1,13 @@
"""Tests for Step 3 functional API routes."""
from datetime import datetime, timezone
from datetime import UTC
from datetime import datetime
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from transcription.api.errors import register_error_handlers
from transcription.api.routes import router
@@ -25,7 +26,7 @@ class TestFunctionalRoutes:
def test_get_jobs_returns_serialized_rows(self, monkeypatch):
"""GET /api/jobs returns normalized job rows."""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
job = SimpleNamespace(
id=uuid4(),
document_id=uuid4(),
@@ -53,7 +54,7 @@ class TestFunctionalRoutes:
text="edited text",
source="user",
accepted=False,
created_at=datetime.now(timezone.utc),
created_at=datetime.now(UTC),
)
monkeypatch.setattr("transcription.api.routes.add_revision", lambda **_kwargs: revision)
@@ -78,7 +79,7 @@ class TestFunctionalRoutes:
text="family archive",
source="user",
accepted=True,
created_at=datetime.now(timezone.utc),
created_at=datetime.now(UTC),
)
monkeypatch.setattr("transcription.api.routes.search_accepted_transcripts", lambda query: [result])
@@ -103,7 +104,7 @@ class TestFunctionalRoutes:
"accepted": True,
"source": "user",
"text": "exported",
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(UTC).isoformat(),
}
]
monkeypatch.setattr("transcription.api.routes.export_transcripts", lambda accepted_only=True: records)
+3 -1
View File
@@ -5,7 +5,9 @@ isolated, fast, and leave no artifacts on disk.
"""
import pytest
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel import Session
from sqlmodel import SQLModel
from sqlmodel import create_engine
from sqlmodel.pool import StaticPool
+3 -1
View File
@@ -6,7 +6,9 @@ import pytest
from sqlmodel import select
from transcription.config import Settings
from transcription.models import Job, JobStatus, Transcript
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.providers.base import TranscriptionResult
from transcription.services.upload import create_upload_job
from transcription.worker import process_next_queued_job
+4 -2
View File
@@ -5,8 +5,10 @@ from types import SimpleNamespace
import pytest
from transcription.config import Settings
from transcription.providers.base import ProviderError, ProviderResponseError
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL, OpenRouterTranscriptionProvider
from transcription.providers.base import ProviderError
from transcription.providers.base import ProviderResponseError
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
class _FakeChat:
+11 -3
View File
@@ -1,10 +1,18 @@
"""Tests for Step 3 library services (revisions, search, export)."""
from sqlmodel import select
import pytest
from sqlmodel import select
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
from transcription.services.library import accept_revision, add_revision, export_transcripts, list_revisions, search_accepted_transcripts
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
def _create_job(session) -> Job:
+7 -8
View File
@@ -5,14 +5,13 @@ from pathlib import Path
import pytest
from transcription.config import Settings
from transcription.providers.base import ProviderError, TranscriptionResult
from transcription.services.transcription import (
PromptLoadError,
TranscriptionError,
load_image_payload,
load_prompt_text,
transcribe_document_image,
)
from transcription.providers.base import ProviderError
from transcription.providers.base import TranscriptionResult
from transcription.services.transcription import PromptLoadError
from transcription.services.transcription import TranscriptionError
from transcription.services.transcription import load_image_payload
from transcription.services.transcription import load_prompt_text
from transcription.services.transcription import transcribe_document_image
class _FakeProvider:
@@ -7,7 +7,6 @@ import pytest
from transcription.services.transcription import transcribe_document_image
HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY"))
REAL_IMAGES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "real"
@@ -67,4 +66,4 @@ class TestRealImageExternalTranscription:
f"{result.text}\n"
)
artifact_path.write_text(artifact_text, encoding="utf-8")
assert artifact_path.exists()
assert artifact_path.exists()
+5 -2
View File
@@ -5,8 +5,11 @@ from pathlib import Path
import pytest
from transcription.config import Settings
from transcription.models import Document, Job, JobStatus
from transcription.services.upload import UploadError, create_upload_job
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.services.upload import UploadError
from transcription.services.upload import create_upload_job
@pytest.mark.unit
+9 -3
View File
@@ -7,10 +7,16 @@ import pytest
from sqlmodel import select
from transcription.config import Settings
from transcription.errors import AppError, ErrorCategory
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
from transcription.providers.base import TranscriptionResult
from transcription.worker import process_next_queued_job, run_worker_loop
from transcription.worker import process_next_queued_job
from transcription.worker import run_worker_loop
def _create_queued_job(session, *, filename: str = "doc.jpg", file_path: str = "uploads/doc.jpg") -> Job:
+2 -1
View File
@@ -5,7 +5,8 @@ from pathlib import Path
import pytest
from pydantic import ValidationError
from transcription.config import Provider, Settings
from transcription.config import Provider
from transcription.config import Settings
def _make_settings(**overrides) -> Settings:
+9 -5
View File
@@ -1,7 +1,10 @@
"""Tests for transcription.db — schema bootstrap and session factory."""
from sqlalchemy import inspect, text
from sqlmodel import Session, SQLModel, create_engine
from sqlalchemy import inspect
from sqlalchemy import text
from sqlmodel import Session
from sqlmodel import SQLModel
from sqlmodel import create_engine
from sqlmodel.pool import StaticPool
@@ -21,9 +24,11 @@ class TestSchemaBootstrap:
"""After create_all(), core V1 tables exist."""
engine = _in_memory_engine()
# Ensure models are imported so metadata is populated
from transcription.models import Document, Job, Transcript, TranscriptRevision # noqa: F401
import transcription.db as db_module
from transcription.models import Document # noqa: F401
from transcription.models import Job # noqa: F401
from transcription.models import Transcript # noqa: F401
from transcription.models import TranscriptRevision # noqa: F401
db_module.create_all(engine=engine)
@@ -34,7 +39,6 @@ class TestSchemaBootstrap:
assert "transcript" in table_names
assert "transcriptrevision" in table_names
def test_validate_schema_compatibility_returns_no_issues_for_fresh_schema(self):
"""validate_schema_compatibility reports no issues on fresh schema."""
engine = _in_memory_engine()
+4 -1
View File
@@ -2,7 +2,10 @@
import pytest
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, new_error_id
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
from transcription.errors import new_error_id
@pytest.mark.unit
+5 -3
View File
@@ -1,10 +1,12 @@
"""Tests for transcription.migrations — explicit Step 4 migration safety behavior."""
from sqlalchemy import inspect, text
from sqlalchemy import inspect
from sqlalchemy import text
from sqlmodel import create_engine
from sqlmodel.pool import StaticPool
from transcription.migrations import apply_pending_migrations, list_pending_migrations
from transcription.migrations import apply_pending_migrations
from transcription.migrations import list_pending_migrations
def _in_memory_engine():
@@ -103,4 +105,4 @@ class TestMigrations:
second_apply = apply_pending_migrations(engine=engine)
assert len(first_apply) == 2
assert second_apply == []
assert second_apply == []
+5 -1
View File
@@ -5,7 +5,11 @@ from uuid import UUID
import pytest
from sqlalchemy.exc import IntegrityError
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
def _make_document(**overrides) -> Document:
-1
View File
@@ -2,7 +2,6 @@
from pathlib import Path
PROMPT_PATH = Path("prompts/transcribe_document.md")
+5 -2
View File
@@ -4,8 +4,11 @@ from uuid import uuid4
import pytest
from transcription.models import Document, Job, Transcript
from transcription.ui.jobs_page import fetch_job_detail, fetch_jobs
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
from transcription.ui.jobs_page import fetch_job_detail
from transcription.ui.jobs_page import fetch_jobs
@pytest.mark.integration
+1 -1
View File
@@ -1,8 +1,8 @@
"""Tests for UI page registration wiring."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from transcription.ui import register_pages
+2 -1
View File
@@ -5,7 +5,8 @@ from uuid import uuid4
import pytest
from transcription.services.upload import UploadError, UploadJobResult
from transcription.services.upload import UploadError
from transcription.services.upload import UploadJobResult
from transcription.ui import upload_page