generated from john/python-template
Update job detail page to track revisions and display document image next to transcription text.
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user