Update job detail page to track revisions and display document image next to transcription text.

This commit is contained in:
Jim Lancaster
2026-06-28 18:02:19 -05:00
parent e2e421835f
commit 761765636a
19 changed files with 744 additions and 111 deletions
+50 -19
View File
@@ -6,9 +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, JobStatus, Transcript, TranscriptRevision
from transcription.providers.base import TranscriptionResult
from transcription.services.upload import create_upload_job
from transcription.services.store import create_upload_job
from transcription.worker import process_next_queued_job
@@ -16,24 +16,37 @@ from transcription.worker import process_next_queued_job
class TestPipelineSuccessFlow:
"""Verify end-to-end success lifecycle behavior."""
def test_upload_then_worker_persists_transcribed_terminal_state(self, session, tmp_path: Path, monkeypatch):
@pytest.mark.asyncio
async def test_upload_then_worker_persists_transcribed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
"""Upload followed by worker processing persists transcript and transcribed status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
upload_result = create_upload_job(
upload_result = await create_upload_job(
filename="pipeline.jpg",
file_bytes=b"pipeline-bytes",
session=session,
session=async_session,
settings=settings,
)
def _fake_transcribe(_path: str) -> TranscriptionResult:
return TranscriptionResult(text="Pipeline transcript", provider="openrouter", model="test-model")
async def _fake_transcribe(_path: str) -> TranscriptionResult:
return TranscriptionResult(
text="Pipeline transcript",
provider="openrouter",
prompt_name="transcribe_document.md",
model="test-model",
)
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
processed = process_next_queued_job(session=session)
job = session.get(Job, upload_result.job_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first()
processed = await process_next_queued_job(session=async_session)
job = await async_session.get(Job, upload_result.job_id)
transcript = (await async_session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id))).first()
revisions = (
await async_session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == upload_result.job_id)
.order_by(TranscriptRevision.version_number)
)
).all()
assert processed is True
assert job is not None
@@ -41,30 +54,43 @@ class TestPipelineSuccessFlow:
assert transcript is not None
assert transcript.text == "Pipeline transcript"
assert transcript.error_detail is None
assert transcript.model == "test-model"
assert len(revisions) == 1
assert revisions[0].version_number == 1
assert revisions[0].source == "ai"
assert revisions[0].text == "Pipeline transcript"
@pytest.mark.integration
class TestPipelineFailureFlow:
"""Verify end-to-end failure lifecycle behavior."""
def test_upload_then_worker_persists_failed_terminal_state(self, session, tmp_path: Path, monkeypatch):
@pytest.mark.asyncio
async def test_upload_then_worker_persists_failed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
"""Upload followed by worker processing persists error detail and failed status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
upload_result = create_upload_job(
upload_result = await create_upload_job(
filename="pipeline.jpg",
file_bytes=b"pipeline-bytes",
session=session,
session=async_session,
settings=settings,
)
def _fake_transcribe(_path: str) -> TranscriptionResult:
async def _fake_transcribe(_path: str) -> TranscriptionResult:
raise RuntimeError("pipeline provider failure")
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
processed = process_next_queued_job(session=session)
job = session.get(Job, upload_result.job_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first()
processed = await process_next_queued_job(session=async_session)
job = await async_session.get(Job, upload_result.job_id)
transcript = (await async_session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id))).first()
revisions = (
await async_session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == upload_result.job_id)
.order_by(TranscriptRevision.version_number)
)
).all()
assert processed is True
assert job is not None
@@ -74,3 +100,8 @@ class TestPipelineFailureFlow:
assert "pipeline provider failure" in transcript.error_detail
assert "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" in transcript.error_detail
assert len(revisions) == 1
assert revisions[0].version_number == 1
assert revisions[0].source == "ai"
assert revisions[0].text is None
assert "pipeline provider failure" in (revisions[0].error_detail or "")
+30 -55
View File
@@ -1,96 +1,71 @@
"""Tests for transcription.db — schema bootstrap and session factory."""
"""Tests for transcription.db — async schema bootstrap/runtime behavior."""
from sqlalchemy import inspect, text
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
def _in_memory_engine():
"""Create a fresh in-memory SQLite engine for isolated db tests."""
return create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
from sqlalchemy import inspect
from sqlalchemy import text
import pytest
class TestSchemaBootstrap:
"""Verify create_all produces the expected table set."""
"""Verify async create_all produces the expected table set."""
def test_create_all_creates_expected_tables(self):
"""After create_all(), document, job, and transcript tables exist."""
engine = _in_memory_engine()
# Ensure models are imported so metadata is populated
from transcription.models import Document, Job, Transcript # noqa: F401
@pytest.mark.asyncio
async def test_create_all_creates_expected_tables(self, default_settings):
"""After async create_all(), document/job/transcript/revision tables exist."""
# 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.db.operations import create_all
from transcription.db.runtime import get_engine
db_module.create_all(engine=engine)
engine = get_engine(settings=default_settings)
await create_all(engine=engine)
async with engine.begin() as connection:
table_names = set(await connection.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names()))
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
assert "document" in table_names
assert "job" in table_names
assert "transcript" in table_names
assert "transcriptrevision" in table_names
class TestSessionFactory:
"""Verify get_session yields and cleans up sessions."""
"""Verify async get_session yields a usable AsyncSession."""
def test_get_session_yields_session(self):
"""get_session() yields a usable Session object."""
engine = _in_memory_engine()
SQLModel.metadata.create_all(engine)
@pytest.mark.asyncio
async def test_get_session_yields_session(self, default_settings):
"""get_session() yields an AsyncSession with a live connection."""
from transcription.db.runtime import get_session
import transcription.db as db_module
with db_module.get_session(engine=engine) as session:
assert isinstance(session, Session)
def test_session_is_closed_after_generator_exit(self):
"""After the context manager exits, the session is closed."""
engine = _in_memory_engine()
SQLModel.metadata.create_all(engine)
import transcription.db as db_module
with db_module.get_session(engine=engine) as session:
# Session is usable inside the context
session.execute(text("SELECT 1"))
captured = session
# After exiting, the session's internal connection is released
# (no active transaction bound to the session)
assert captured._transaction is None
async with get_session(settings=default_settings) as session:
result = await session.exec(text("SELECT 1"))
assert result.first()[0] == 1
class TestBootstrapPolicy:
"""Verify schema bootstrap policy defaults and overrides."""
"""Verify startup schema bootstrap policy via Settings property."""
def test_production_defaults_to_no_bootstrap(self):
"""Production defaults to explicit non-bootstrap startup behavior."""
from transcription.config import Settings
from transcription.db import should_bootstrap_schema
settings = Settings(openrouter_api_key="test-key", environment="production")
assert should_bootstrap_schema(settings) is False
assert settings.should_bootstrap_schema is False
def test_development_defaults_to_bootstrap(self):
"""Development defaults to schema bootstrap for local workflows."""
from transcription.config import Settings
from transcription.db import should_bootstrap_schema
settings = Settings(openrouter_api_key="test-key", environment="development")
assert should_bootstrap_schema(settings) is True
assert settings.should_bootstrap_schema is True
def test_explicit_override_wins(self):
"""Explicit bootstrap_schema_on_startup overrides environment default."""
from transcription.config import Settings
from transcription.db import should_bootstrap_schema
settings = Settings(
openrouter_api_key="test-key",
environment="production",
bootstrap_schema_on_startup=True,
)
assert should_bootstrap_schema(settings) is True
assert settings.should_bootstrap_schema is True
+131 -7
View File
@@ -1,11 +1,11 @@
"""Tests for transcription.models — Document, Job, Transcript persistence and relationships."""
"""Tests for transcription.models — Document, Job, Transcript, TranscriptRevision models."""
from uuid import UUID
import pytest
from sqlalchemy.exc import IntegrityError
from transcription.models import Document, Job, JobStatus, Transcript
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
def _make_document(**overrides) -> Document:
@@ -113,7 +113,7 @@ class TestTranscriptModel:
"""A Transcript with text set and error_detail None persists correctly."""
doc = _persist_document(session)
job = _persist_job(session, doc)
transcript = Transcript(job_id=job.id, text="Dear Sir, ...")
transcript = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Dear Sir, ...")
session.add(transcript)
session.commit()
session.refresh(transcript)
@@ -127,7 +127,12 @@ class TestTranscriptModel:
"""A Transcript with text None and error_detail set persists correctly."""
doc = _persist_document(session)
job = _persist_job(session, doc)
transcript = Transcript(job_id=job.id, error_detail="Provider timeout")
transcript = Transcript(
job_id=job.id,
provider="openrouter",
prompt_name="transcribe_document.md",
error_detail="Provider timeout",
)
session.add(transcript)
session.commit()
session.refresh(transcript)
@@ -142,16 +147,101 @@ class TestTranscriptModel:
doc = _persist_document(session)
job = _persist_job(session, doc)
t1 = Transcript(job_id=job.id, text="First")
t1 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="First")
session.add(t1)
session.commit()
t2 = Transcript(job_id=job.id, text="Duplicate")
t2 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Duplicate")
session.add(t2)
with pytest.raises(IntegrityError):
session.commit()
class TestTranscriptRevisionModel:
"""Verify TranscriptRevision persistence and version uniqueness constraints."""
def test_revision_record_persists(self, session):
"""A TranscriptRevision with version metadata persists correctly."""
doc = _persist_document(session)
job = _persist_job(session, doc)
revision = TranscriptRevision(
job_id=job.id,
version_number=1,
provider="openrouter",
prompt_name="transcribe_document.md",
model="google/gemini-2.5-flash",
source="ai",
text="Initial text",
)
session.add(revision)
session.commit()
session.refresh(revision)
fetched = session.get(TranscriptRevision, revision.id)
assert fetched is not None
assert fetched.version_number == 1
assert fetched.text == "Initial text"
assert fetched.source == "ai"
def test_job_version_pair_is_unique(self, session):
"""Duplicate version_number for same job raises integrity error."""
doc = _persist_document(session)
job = _persist_job(session, doc)
first = TranscriptRevision(
job_id=job.id,
version_number=1,
provider="openrouter",
prompt_name="transcribe_document.md",
source="ai",
text="Initial",
)
duplicate = TranscriptRevision(
job_id=job.id,
version_number=1,
provider="openrouter",
prompt_name="transcribe_document.md",
source="user",
text="Edited",
)
session.add(first)
session.commit()
session.add(duplicate)
with pytest.raises(IntegrityError):
session.commit()
def test_same_version_number_allowed_for_different_jobs(self, session):
"""Version numbers are scoped per job, not globally."""
doc1 = _persist_document(session)
job1 = _persist_job(session, doc1)
doc2 = _make_document(filename="letter2.jpg", file_path="/uploads/letter2.jpg")
session.add(doc2)
session.commit()
session.refresh(doc2)
job2 = _persist_job(session, doc2)
r1 = TranscriptRevision(
job_id=job1.id,
version_number=1,
provider="openrouter",
prompt_name="transcribe_document.md",
source="ai",
text="Job1 v1",
)
r2 = TranscriptRevision(
job_id=job2.id,
version_number=1,
provider="openrouter",
prompt_name="transcribe_document.md",
source="ai",
text="Job2 v1",
)
session.add(r1)
session.add(r2)
session.commit()
class TestRelationships:
"""Verify SQLModel relationship navigation between models."""
@@ -169,7 +259,12 @@ class TestRelationships:
"""job.transcript returns the linked Transcript."""
doc = _persist_document(session)
job = _persist_job(session, doc)
transcript = Transcript(job_id=job.id, text="Transcribed text")
transcript = Transcript(
job_id=job.id,
provider="openrouter",
prompt_name="transcribe_document.md",
text="Transcribed text",
)
session.add(transcript)
session.commit()
@@ -177,3 +272,32 @@ class TestRelationships:
assert job.transcript is not None
assert isinstance(job.transcript, Transcript)
assert job.transcript.text == "Transcribed text"
def test_job_exposes_transcript_revisions(self, session):
"""job.transcript_revisions returns all linked revisions."""
doc = _persist_document(session)
job = _persist_job(session, doc)
session.add(
TranscriptRevision(
job_id=job.id,
version_number=1,
provider="openrouter",
prompt_name="transcribe_document.md",
source="ai",
text="v1",
)
)
session.add(
TranscriptRevision(
job_id=job.id,
version_number=2,
provider="openrouter",
prompt_name="transcribe_document.md",
source="user",
text="v2",
)
)
session.commit()
session.refresh(job)
assert len(job.transcript_revisions) == 2