Files
transcription/tests/test_v42_evidence.py
T
zoltan57andCopilot App f86c0ff27b V4.7 Phase 1: ingest orientation normalization, ProcessingArtifact removal
Move orientation normalization to the Source-ingest boundary and delete the
ProcessingArtifact subsystem it was built to serve.

Stored pages are now already upright, so nothing downstream derives a rotated
copy: every stored byte is the byte a provider is later sent. Rotation runs in
store_source_file ahead of hashing, so source.file_hash and file_size_bytes
describe exactly what is on disk. normalize_orientation becomes bytes-in /
bytes-out, and JPEG output reuses the source quantization tables and chroma
subsampling instead of re-quantizing at a fixed quality - measured at 50.3-56.1
dB PSNR at -6% size, against 50.0-53.5 dB at +38% for quality=95.

ProcessingArtifact held 2 rows against 77 successful transcriptions; the
subsystem effectively never ran. Deleting it removes the artifact cluster from
sources.py, the derivative resolution in workflows.py, the pre-provider commit
that only existed to make an artifact row durable, and the artifact evidence
dump from the Source detail page. The transcription_quality_warnings payload
folds into execution_attempt.normalized_metadata, so that feature keeps working
without the table.

tools/migrate_v46_to_v47.py carries steps 1 and 2: it rotated the 58 stored
images carrying EXIF orientation 3 in place, updated their recorded hash and
size, dropped processing_artifact and removed its one external file. It is
idempotent, keyed on state rather than a version marker.

tools/migrate_v45_to_v46.py is deleted. That migration is complete, and after
V4.7 it would restore a V4.5 backup into a schema that no longer matches.

Also fixes tests/test_config.py, which read the developer's local .env and
failed whenever WORKER_MAX_RETRIES was set.

Co-authored-by: Copilot App <[email protected]>
2026-08-18 10:16:38 -05:00

304 lines
11 KiB
Python

"""Focused V4.2 evidence, integrity, and benchmark tests."""
from __future__ import annotations
import hashlib
import json
from datetime import UTC
from datetime import datetime
from uuid import uuid4
import httpx
import pytest
from pydantic import JsonValue
from transcription.benchmarking import EditorialAssessment
from transcription.benchmarking import score_transcription
from transcription.config import Settings
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import Source
from transcription.providers.base import ProviderError
from transcription.providers.base import TranscriptionResult
from transcription.providers.evidence import SourceEvidenceReference
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobService
from transcription.services.sources import SourceService
from transcription.services.sources import transcribe_document_image
def _json_object(value: JsonValue) -> dict[str, JsonValue]:
"""Narrow a JSON export member to an object, asserting the export shape."""
assert isinstance(value, dict)
return value
def _json_array(value: JsonValue) -> list[JsonValue]:
"""Narrow a JSON export member to an array, asserting the export shape."""
assert isinstance(value, list)
return value
class _ChunkedAsyncStream(httpx.AsyncByteStream):
def __init__(self, chunks: list[bytes]):
self._chunks = chunks
async def __aiter__(self):
for chunk in self._chunks:
yield chunk
async def aclose(self):
return
@pytest.mark.asyncio
async def test_openrouter_captures_exact_transport_and_secret_safe_manifest():
response_body = (
b'{"id":"gen-1","created":1,"model":"vendor/model","object":"chat.completion",'
b'"system_fingerprint":null,"choices":[{"index":0,"finish_reason":"stop",'
b'"message":{"role":"assistant","content":"Transcript"}}],'
b'"unknown_transport_field":{"retained":true}}'
)
async def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["authorization"] == "Bearer test-key"
return httpx.Response(
200,
content=response_body,
headers={
"Content-Type": "application/json",
"X-Request-Id": "req-123",
"Set-Cookie": "must-not-persist=1",
},
request=request,
)
async_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
provider = OpenRouterTranscriptionProvider(
settings=Settings(openrouter_api_key="test-key"),
async_client=async_client,
)
result = await provider.transcribe(
prompt_text="Literal prompt",
image_bytes=b"source-bytes",
mime_type="image/png",
source_reference=SourceEvidenceReference(
source_id=uuid4(),
digest_sha256=hashlib.sha256(b"source-bytes").hexdigest(),
byte_size=len(b"source-bytes"),
media_type="image/png",
page_number=1,
),
)
assert result.transport_evidence is not None
assert result.transport_evidence.body == response_body
assert result.transport_evidence.safe_headers == {
"content-type": "application/json",
"x-request-id": "req-123",
}
assert b'"unknown_transport_field":{"retained":true}' in result.transport_evidence.body
assert result.request_manifest is not None
serialized_manifest = json.dumps(result.request_manifest.model_dump(mode="json"))
assert "data:image/png;base64" not in serialized_manifest
assert "test-key" not in serialized_manifest
assert result.request_manifest.omitted_optional_parameters == ("temperature", "top_p")
@pytest.mark.asyncio
async def test_openrouter_captures_body_consumed_as_sdk_stream():
response_body = (
b'{"id":"gen-2","created":1,"model":"vendor/model","object":"chat.completion",'
b'"system_fingerprint":null,"choices":[{"index":0,"finish_reason":"stop",'
b'"message":{"role":"assistant","content":"Transcript"}}],'
b'"unknown_streamed_field":true}'
)
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
stream=_ChunkedAsyncStream([response_body[:23], response_body[23:61], response_body[61:]]),
headers={"Content-Type": "application/json"},
request=request,
)
provider = OpenRouterTranscriptionProvider(
settings=Settings(openrouter_api_key="test-key"),
async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
result = await provider.transcribe(
prompt_text="Literal prompt",
image_bytes=b"source-bytes",
mime_type="image/png",
)
assert result.transport_evidence is not None
assert result.transport_evidence.body == response_body
assert b'"unknown_streamed_field":true' in result.transport_evidence.body
await provider.aclose()
@pytest.mark.asyncio
async def test_openrouter_failure_retains_safe_response_evidence():
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
500,
content=b'{"error":{"message":"provider unavailable"}}',
headers={"Content-Type": "application/json", "Retry-After": "2", "Set-Cookie": "secret=1"},
request=request,
)
provider = OpenRouterTranscriptionProvider(
settings=Settings(openrouter_api_key="test-key"),
async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
with pytest.raises(ProviderError) as failure:
await provider.transcribe(
prompt_text="Literal prompt",
image_bytes=b"source-bytes",
mime_type="image/png",
)
evidence = failure.value.transport_evidence
assert evidence is not None
assert evidence.status_code == 500
assert evidence.body == b'{"error":{"message":"provider unavailable"}}'
assert evidence.safe_headers == {"content-type": "application/json", "retry-after": "2"}
assert str(failure.value) == "OpenRouter request failed with HTTP 500: provider unavailable"
@pytest.mark.asyncio
async def test_openrouter_does_not_reuse_prior_response_on_connection_failure():
call_count = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count == 1:
return httpx.Response(500, content=b'{"error":"first"}', request=request)
raise httpx.ConnectError("connection failed", request=request)
provider = OpenRouterTranscriptionProvider(
settings=Settings(openrouter_api_key="test-key"),
async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
with pytest.raises(ProviderError) as first_failure:
await provider.transcribe(prompt_text="First", image_bytes=b"one", mime_type="image/png")
with pytest.raises(ProviderError) as second_failure:
await provider.transcribe(prompt_text="Second", image_bytes=b"two", mime_type="image/png")
assert first_failure.value.transport_evidence is not None
assert second_failure.value.transport_evidence is not None
assert second_failure.value.transport_evidence.response_received is False
assert second_failure.value.transport_evidence.body is None
await provider.aclose()
@pytest.mark.asyncio
async def test_attempts_are_append_only_and_exported_with_integrity(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
sources = SourceService(session_factory=default_session_factory)
document = await documents.create_document(Document(name="Evidence"))
job = await jobs.create_job(Job(document_id=document.id))
source = await sources.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="page.png",
filename="page.png",
file_path="page.png",
file_hash="a" * 64,
file_size_bytes=10,
)
)
await sources.create_job_source(JobSource(job_id=job.id, source_id=source.id))
now = datetime.now(UTC)
await sources.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text=None,
error_detail="first failed",
error_category="external_provider_error",
failure_phase="connection",
started_at=now,
finished_at=now,
)
await sources.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text="second succeeded",
provider="openrouter",
model="vendor/model",
started_at=now,
finished_at=now,
)
attempts = await sources.list_execution_attempts(source_id=source.id)
assert [attempt.attempt_number for attempt in attempts] == [1, 2]
assert attempts[0].status == JobSourceStatus.FAILED
assert attempts[0].error_detail == "first failed"
assert attempts[1].status == JobSourceStatus.TRANSCRIBED
assert attempts[1].raw_transcription == "second succeeded"
export = await sources.build_evidence_export(source_id=source.id)
assert _json_object(export["source"])["digest_sha256"] == "a" * 64
assert [_json_object(item)["attempt_number"] for item in _json_array(export["attempts"])] == [1, 2]
assert "file_path" not in json.dumps(export)
with pytest.raises(JobDeleteBlockedError):
await jobs.delete_job_with_guardrails(job_id=job.id)
detail = await sources.read_source_detail(source.id)
latest_job_source = detail.latest_job_source
assert latest_job_source is not None
assert latest_job_source.execution_attempts == []
latest_attempt = await sources.read_latest_execution_attempt(job_source_id=latest_job_source.id)
assert latest_attempt is not None
assert latest_attempt.attempt.attempt_number == 2
def test_benchmark_scoring_preserves_literal_differences():
score = score_transcription(
execution_attempt_id=uuid4(),
reference="Farm house",
candidate="farm house",
assessment=EditorialAssessment(silent_normalizations=1),
latency_ms=125,
)
assert score.character_edits == 1
assert score.word_edits == 1
assert score.assessment.silent_normalizations == 1
@pytest.mark.asyncio
async def test_standalone_transcription_closes_locally_created_provider(tmp_path, monkeypatch):
image_path = tmp_path / "page.png"
image_path.write_bytes(b"image")
class _Provider:
closed = False
async def transcribe(self, **kwargs):
_ = kwargs
return TranscriptionResult(text="result", provider="fixture", model="model")
async def aclose(self):
self.closed = True
provider = _Provider()
monkeypatch.setattr("transcription.services.sources.get_transcription_provider", lambda **_kwargs: provider)
result = await transcribe_document_image(
image_path,
prompt_text="Prompt",
settings=Settings(openrouter_api_key="test-key"),
)
assert result.text == "result"
assert provider.closed is True