V4.5 Complete - Enhanced trancription context, added option to restranscribe source under different models.

This commit is contained in:
Jim Lancaster
2026-08-16 09:06:56 -05:00
parent bdb1b31b0a
commit 7054cd8af9
28 changed files with 2883 additions and 1485 deletions
+30 -1
View File
@@ -19,6 +19,8 @@ from pydantic import ConfigDict
from pydantic import Field
from pydantic import SecretStr
from pydantic import StringConstraints
from pydantic import field_validator
from pydantic import model_validator
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
@@ -33,6 +35,7 @@ NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length
PromptFilename = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, pattern=r"^[^/\\]+$")]
Probability = Annotated[float, Field(ge=0.0, le=1.0)]
Temperature = Annotated[float, Field(ge=0.0, le=2.0)]
DEFAULT_PROVIDER_MODEL = "google/gemini-2.5-flash"
class SqliteSettings(BaseModel):
@@ -79,7 +82,8 @@ class Settings(BaseSettings):
# --- AI provider ---
provider: Provider = Provider.OPENROUTER
openrouter_api_key: SecretStr
provider_model: NonEmptyStr | None = None
provider_model: NonEmptyStr | None = DEFAULT_PROVIDER_MODEL
provider_models: tuple[NonEmptyStr, ...] = ()
openrouter_http_referer: NonEmptyStr | None = None
openrouter_app_title: NonEmptyStr | None = None
default_prompt_name: PromptFilename = "transcribe_document.md"
@@ -108,6 +112,31 @@ class Settings(BaseSettings):
worker_min_transcription_lines: int = Field(default=0, ge=0)
worker_fail_on_finish_reason_length: bool = False
@field_validator("provider_models", mode="before")
@classmethod
def validate_provider_models_input(cls, value: object) -> object:
if value is None:
return ()
if isinstance(value, (list, tuple)) and not value:
raise ValueError("PROVIDER_MODELS must contain at least one model")
return value
@model_validator(mode="after")
def normalize_provider_models(self) -> "Settings":
"""Build the immutable model selector with the configured default first."""
configured = self.provider_models
default_model = self.provider_model or DEFAULT_PROVIDER_MODEL
object.__setattr__(self, "provider_model", default_model)
ordered = (default_model, *configured)
deduplicated: list[str] = []
for model in ordered:
normalized = model.strip()
if normalized not in deduplicated:
deduplicated.append(normalized)
object.__setattr__(self, "provider_models", tuple(deduplicated))
return self
@property
def should_bootstrap_schema(self) -> bool:
"""Return whether startup should auto-create schema for this environment."""
+22
View File
@@ -50,6 +50,11 @@ class JobSourceStatus(StrEnum):
FAILED = "failed"
class JobPurpose(StrEnum):
TRANSCRIPTION = "transcription"
RETRANSCRIPTION = "retranscription"
class DocumentType(SQLModel, table=True):
"""Registry of allowed document types."""
@@ -180,6 +185,18 @@ class Job(SQLModel, table=True):
),
)
retry_count: int = Field(default=0, ge=0)
purpose: JobPurpose = Field(
default=JobPurpose.TRANSCRIPTION,
sa_column=Column(
SAEnum(
JobPurpose,
values_callable=lambda enum_cls: [item.value for item in enum_cls],
native_enum=False,
),
nullable=False,
default=JobPurpose.TRANSCRIPTION.value,
),
)
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
date_updated: datetime = Field(default_factory=lambda: datetime.now(UTC))
provider: str | None = None
@@ -240,6 +257,11 @@ class Source(SQLModel, table=True):
file_hash: str
file_size_bytes: int = Field(sa_column=Column(BigInteger(), nullable=False))
raw_transcription: str | None = None
preferred_execution_attempt_id: UUID | None = Field(
default=None,
foreign_key="execution_attempt.id",
index=True,
)
revised_text: str | None = None
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
date_revised: datetime | None = None
+29
View File
@@ -32,6 +32,7 @@ async def create_all(*, engine: AsyncEngine | None = None) -> None:
await connection.run_sync(SQLModel.metadata.create_all)
await _upgrade_person_family_search_id(connection)
await _upgrade_v42_evidence_tables(connection)
await _upgrade_v45_selection_columns(connection)
await seed_registry_defaults(engine=active_engine)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
@@ -42,6 +43,7 @@ async def upgrade_schema(*, engine: AsyncEngine | None = None) -> None:
async with active_engine.begin() as connection:
await _upgrade_person_family_search_id(connection)
await _upgrade_v42_evidence_tables(connection)
await _upgrade_v45_selection_columns(connection)
async def _upgrade_v42_evidence_tables(connection: AsyncConnection) -> None:
@@ -54,6 +56,33 @@ async def _upgrade_v42_evidence_tables(connection: AsyncConnection) -> None:
await connection.run_sync(create_tables)
async def _upgrade_v45_selection_columns(connection: AsyncConnection) -> None:
"""Add V4.5 purpose and preferred-attempt provenance columns."""
def inspect_columns(sync_connection) -> tuple[set[str], set[str]]:
database = inspect(sync_connection)
tables = set(database.get_table_names())
job_columns = {column["name"] for column in database.get_columns("job")} if "job" in tables else set()
source_columns = (
{column["name"] for column in database.get_columns("source")} if "source" in tables else set()
)
return job_columns, source_columns
job_columns, source_columns = await connection.run_sync(inspect_columns)
if job_columns and "purpose" not in job_columns:
await connection.execute(
text("ALTER TABLE job ADD COLUMN purpose VARCHAR NOT NULL DEFAULT 'transcription'")
)
if source_columns and "preferred_execution_attempt_id" not in source_columns:
await connection.execute(text("ALTER TABLE source ADD COLUMN preferred_execution_attempt_id CHAR(32)"))
await connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_source_preferred_execution_attempt_id "
"ON source (preferred_execution_attempt_id)"
)
)
async def _upgrade_person_family_search_id(connection: AsyncConnection) -> None:
"""Add the nullable V4.1 FamilySearch field to an existing database."""
+1
View File
@@ -111,6 +111,7 @@ class TranscriptionProvider(Protocol):
temperature: float | None = None,
top_p: float | None = None,
source_reference: SourceEvidenceReference | None = None,
requested_model: str | None = None,
) -> TranscriptionResult:
"""Transcribe the provided image according to the prompt text."""
...
+6 -3
View File
@@ -232,6 +232,7 @@ class OpenRouterTranscriptionProvider:
temperature: float | None = None,
top_p: float | None = None,
source_reference: SourceEvidenceReference | None = None,
requested_model: str | None = None,
) -> TranscriptionResult:
"""Send prompt + image to OpenRouter and return normalized text output."""
request = self._build_request(
@@ -240,6 +241,7 @@ class OpenRouterTranscriptionProvider:
mime_type=mime_type,
temperature=temperature,
top_p=top_p,
requested_model=requested_model,
)
manifest = self._build_request_manifest(
request=request,
@@ -303,7 +305,7 @@ class OpenRouterTranscriptionProvider:
transport_evidence=transport,
failure_phase="response_validation",
) from exc
model = validated_response.model or self.model
model = validated_response.model or requested_model or self.model
metadata = self._build_metadata(validated_response)
logger.info("OpenRouter transcription completed using model=%s", model)
return TranscriptionResult(
@@ -339,7 +341,7 @@ class OpenRouterTranscriptionProvider:
omitted = tuple(name for name in ("temperature", "top_p") if name not in explicit)
return RequestManifest(
provider="openrouter",
requested_model=self.model,
requested_model=request.model,
request=JSON_OBJECT_ADAPTER.validate_python(sanitized_request),
source=source_reference,
explicitly_supplied_parameters=explicit,
@@ -481,6 +483,7 @@ class OpenRouterTranscriptionProvider:
mime_type: str,
temperature: float | None,
top_p: float | None,
requested_model: str | None = None,
) -> OpenRouterRequest:
image_b64 = base64.b64encode(image_bytes).decode("ascii")
data_url = f"data:{mime_type};base64,{image_b64}"
@@ -491,7 +494,7 @@ class OpenRouterTranscriptionProvider:
media_content = ImageContent(image_url=ImageUrl(url=data_url))
return OpenRouterRequest(
model=self.model,
model=requested_model or self.model,
messages=(UserMessage(content=(TextContent(text=prompt_text), media_content)),),
http_referer=self._settings.openrouter_http_referer,
x_open_router_title=self._settings.openrouter_app_title,
+101
View File
@@ -0,0 +1,101 @@
"""Metadata-directed orientation normalization for provider image input."""
from __future__ import annotations
import hashlib
import io
from dataclasses import dataclass
from pathlib import Path
from PIL import Image
from PIL import UnidentifiedImageError
from transcription.errors import AppError
from transcription.errors import ErrorCategory
ORIENTATION_TAG = 274
ORIENTATION_SCHEMA = "transcription.orientation-normalization"
ORIENTATION_SCHEMA_VERSION = "1"
ORIENTATION_PRODUCER = "transcription.orientation-normalizer"
ORIENTATION_PRODUCER_VERSION = "1"
_TRANSPOSE_BY_ORIENTATION = {
3: (Image.Transpose.ROTATE_180, 180),
6: (Image.Transpose.ROTATE_270, 90),
8: (Image.Transpose.ROTATE_90, 270),
}
class OrientationNormalizationError(AppError):
"""Raised when a supported raster image cannot be normalized safely."""
@dataclass(frozen=True, slots=True)
class OrientationNormalization:
"""Exact derivative bytes and transformation metadata."""
content: bytes
media_type: str
suffix: str
original_orientation: int
applied_rotation_degrees: int
original_width: int
original_height: int
derivative_width: int
derivative_height: int
@property
def digest_sha256(self) -> str:
return hashlib.sha256(self.content).hexdigest()
def normalize_orientation(path: str | Path, *, media_type: str) -> OrientationNormalization | None:
"""Physically apply supported EXIF rotation, returning None for a safe no-op."""
source_path = Path(path)
if media_type not in {"image/jpeg", "image/png", "image/tiff"}:
return None
try:
with Image.open(source_path) as image:
orientation = int(image.getexif().get(ORIENTATION_TAG, 1))
transformation = _TRANSPOSE_BY_ORIENTATION.get(orientation)
if transformation is None:
return None
transpose, rotation = transformation
if image.format == "TIFF":
original_width = int(image.tag_v2.get(256, image.width))
original_height = int(image.tag_v2.get(257, image.height))
# Pillow applies TIFF orientation while decoding; copying freezes those upright pixels.
normalized = image.copy()
else:
original_width, original_height = image.size
normalized = image.transpose(transpose)
output = io.BytesIO()
exif = normalized.getexif()
if ORIENTATION_TAG in exif:
del exif[ORIENTATION_TAG]
save_kwargs: dict[str, object] = {"format": image.format}
if image.format in {"JPEG", "PNG"}:
save_kwargs["exif"] = exif.tobytes()
if image.format == "JPEG":
save_kwargs.update({"quality": 95, "subsampling": 0})
normalized.save(output, **save_kwargs)
except (OSError, ValueError, UnidentifiedImageError) as exc:
raise OrientationNormalizationError(
f"Source image orientation could not be normalized: {source_path.name}",
category=ErrorCategory.VALIDATION,
suggestion="Verify that the curated Source is a valid supported raster image.",
) from exc
suffix = source_path.suffix.lower()
return OrientationNormalization(
content=output.getvalue(),
media_type=media_type,
suffix=suffix,
original_orientation=orientation,
applied_rotation_degrees=rotation,
original_width=original_width,
original_height=original_height,
derivative_width=normalized.width,
derivative_height=normalized.height,
)
+90
View File
@@ -0,0 +1,90 @@
"""Deterministic, provider-neutral transcription quality warnings."""
from __future__ import annotations
import re
from enum import StrEnum
from pydantic import BaseModel
from pydantic import ConfigDict
QUALITY_ANALYSIS_SCHEMA = "transcription.quality-warnings"
QUALITY_ANALYSIS_VERSION = "1"
QUALITY_ANALYSIS_PRODUCER = "transcription.quality"
QUALITY_ANALYSIS_PRODUCER_VERSION = "1"
_BODY_MARKER_RE = re.compile(
r"\[document body (?:handwritten|typewritten|typeset|mixed)\]",
flags=re.IGNORECASE,
)
_HANDWRITTEN_LINE_RE = re.compile(r"(?m)^\s*\[handwritten:\s*.+\]\s*$", flags=re.IGNORECASE)
_HTML_ENTITY_RE = re.compile(r"&(?:#[0-9]{1,7}|#x[0-9a-f]{1,6}|[a-z][a-z0-9]{1,31});", flags=re.IGNORECASE)
class QualityWarningCode(StrEnum):
"""Stable identifiers for V4.5 output warning rules."""
REPLACEMENT_CHARACTER = "replacement_character"
MULTIPLE_BODY_MARKERS = "multiple_body_markers"
REDUNDANT_HANDWRITING_WRAPPERS = "redundant_handwriting_wrappers"
UNRESOLVED_HTML_ENTITY = "unresolved_html_entity"
class QualityWarning(BaseModel):
"""One immutable warning produced without changing transcription text."""
model_config = ConfigDict(extra="forbid", frozen=True)
code: QualityWarningCode
detail: str
def analyze_transcription_quality(text: str) -> tuple[QualityWarning, ...]:
"""Return deterministic warnings in stable rule order."""
warnings: list[QualityWarning] = []
if "\ufffd" in text:
warnings.append(
QualityWarning(
code=QualityWarningCode.REPLACEMENT_CHARACTER,
detail="Transcription contains one or more Unicode replacement characters.",
)
)
body_markers = _BODY_MARKER_RE.findall(text)
if len(body_markers) > 1:
warnings.append(
QualityWarning(
code=QualityWarningCode.MULTIPLE_BODY_MARKERS,
detail=f"Transcription contains {len(body_markers)} document-body markers; exactly one is expected.",
)
)
if re.search(r"\[document body handwritten\]", text, flags=re.IGNORECASE):
wrappers = _HANDWRITTEN_LINE_RE.findall(text)
if len(wrappers) > 1:
warnings.append(
QualityWarning(
code=QualityWarningCode.REDUNDANT_HANDWRITING_WRAPPERS,
detail=(
"A wholly handwritten document also uses repeated whole-line handwriting wrappers."
),
)
)
if _HTML_ENTITY_RE.search(text):
warnings.append(
QualityWarning(
code=QualityWarningCode.UNRESOLVED_HTML_ENTITY,
detail="Transcription contains a likely unresolved HTML entity.",
)
)
return tuple(warnings)
def quality_warning_payload(warnings: tuple[QualityWarning, ...]) -> dict:
"""Build the versioned JSON artifact payload."""
return {
"schema_name": QUALITY_ANALYSIS_SCHEMA,
"schema_version": QUALITY_ANALYSIS_VERSION,
"warnings": [warning.model_dump(mode="json") for warning in warnings],
}
+231 -11
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import base64
import hashlib
import inspect
import logging
import os
from collections.abc import Sequence
@@ -51,6 +52,11 @@ from transcription.providers import get_transcription_provider
from transcription.providers.evidence import canonical_json_bytes
from .base import ServiceBase
from .normalization import ORIENTATION_PRODUCER
from .normalization import ORIENTATION_PRODUCER_VERSION
from .normalization import ORIENTATION_SCHEMA
from .normalization import ORIENTATION_SCHEMA_VERSION
from .normalization import normalize_orientation
logger = logging.getLogger(__name__)
@@ -96,6 +102,10 @@ class SourceDeleteBlockedError(TranscriptionError):
"""Raised when source deletion is blocked by dependency policy."""
class CandidatePromotionError(TranscriptionError):
"""Raised when a machine attempt cannot be selected for its Source."""
@dataclass(frozen=True, slots=True)
class SourceNavigation:
"""Adjacent Source identifiers within one ordered Document."""
@@ -104,6 +114,20 @@ class SourceNavigation:
next_id: UUID | None
@dataclass(frozen=True, slots=True)
class ProviderInput:
"""Resolved immutable bytes and evidence identity for one provider request."""
path: Path
digest_sha256: str
byte_size: int
media_type: str
width: int | None = None
height: int | None = None
derivative_id: UUID | None = None
transformation: str | None = None
class SourceService(ServiceBase):
"""Manage source records, media payloads, revisions, and page execution output."""
@@ -348,6 +372,30 @@ class SourceService(ServiceBase):
)
return job_source
async def read_job_source_for_job(
self,
*,
job_id: UUID,
source_id: UUID,
session: AsyncSession | None = None,
) -> JobSource:
"""Read the unique JobSource association for one job and Source."""
async with self._session_scope(session) as _session:
job_source = (
await _session.exec(
select(JobSource)
.where(JobSource.job_id == job_id)
.where(JobSource.source_id == source_id)
)
).first()
if job_source is None:
raise TranscriptionNotFoundError(
f"Source {source_id} is not linked to Job {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh the Job and retry.",
)
return job_source
async def update_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> JobSource:
"""Update an existing job_source record."""
async with self._session_scope(session) as _session:
@@ -472,6 +520,7 @@ class SourceService(ServiceBase):
provider: str | None = None,
model: str | None = None,
request_manifest: RequestManifest | None = None,
model_input_artifact_id: UUID | None = None,
transport_evidence: TransportEvidence | None = None,
failure_phase: str | None = None,
error_category: str | None = None,
@@ -511,9 +560,6 @@ class SourceService(ServiceBase):
metadata_payload = _validate_transcription_metadata(ai_metadata)
raw_response_payload = _validate_json_object(raw_api_response, field_name="raw_api_response")
if text is not None:
source.raw_transcription = text
existing_job_source = await _session.exec(
select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
)
@@ -586,10 +632,76 @@ class SourceService(ServiceBase):
else max(0, int((finish_time - start_time).total_seconds() * 1000)),
)
_session.add(attempt)
await _session.flush()
manifest_derivative_id = (
request_manifest.source.derivative_id if request_manifest is not None else None
)
if (
model_input_artifact_id is not None
and manifest_derivative_id is not None
and model_input_artifact_id != manifest_derivative_id
):
raise TranscriptionError(
"Provider-input artifact does not match the request manifest",
category=ErrorCategory.CONFLICT,
suggestion="Persist the exact normalized input consumed by this attempt.",
)
derivative_id = model_input_artifact_id or manifest_derivative_id
if derivative_id is not None:
derivative = await _session.get(ProcessingArtifact, derivative_id)
if derivative is None or derivative.source_id != source_id:
raise TranscriptionError(
"Provider-input derivative evidence is missing or belongs to another Source",
category=ErrorCategory.CONFLICT,
suggestion="Restore the normalized input artifact before persisting the attempt.",
)
derivative.execution_attempt_id = attempt.id
if (
text is not None
and source.raw_transcription is None
and source.preferred_execution_attempt_id is None
):
source.raw_transcription = text
source.preferred_execution_attempt_id = attempt.id
await self._finalize(session=_session, caller_session=session, refresh=(job, source, job_source, attempt))
return job_source
async def promote_machine_attempt(
self,
*,
source_id: UUID,
execution_attempt_id: UUID,
session: AsyncSession | None = None,
) -> Source:
"""Atomically select one successful machine attempt as the Source projection."""
async with self._session_scope(session) as _session:
source = await _session.get(Source, source_id)
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Source Detail and retry.",
)
attempt = await _session.get(ExecutionAttempt, execution_attempt_id)
if (
attempt is None
or attempt.source_id != source_id
or attempt.status != JobSourceStatus.TRANSCRIBED
or not attempt.raw_transcription
):
raise CandidatePromotionError(
"Only a successful transcription attempt belonging to this Source can be selected",
category=ErrorCategory.VALIDATION,
suggestion="Select an available successful candidate from Source Detail.",
)
source.preferred_execution_attempt_id = attempt.id
source.raw_transcription = attempt.raw_transcription
await self._finalize(session=_session, caller_session=session, refresh=(source,))
return source
async def list_execution_attempts(
self,
*,
@@ -689,6 +801,110 @@ class SourceService(ServiceBase):
external_path.unlink(missing_ok=True)
raise
async def create_binary_artifact(
self,
*,
source_id: UUID,
artifact_type: str,
media_type: str,
schema_name: str,
schema_version: str,
producer: str,
producer_version: str,
content: bytes,
suffix: str,
coordinate_metadata: dict[str, JsonValue] | None = None,
session: AsyncSession | None = None,
) -> ProcessingArtifact:
"""Persist exact binary derivative bytes in application-managed artifact storage."""
artifact_id = uuid4()
safe_suffix = suffix if suffix.startswith(".") and suffix[1:].isalnum() else ".bin"
relative_path = Path(str(source_id)) / f"{artifact_id}{safe_suffix.lower()}"
external_path = self.settings.artifact_dir / relative_path
self._write_external_artifact(path=external_path, content=content)
artifact = ProcessingArtifact(
id=artifact_id,
source_id=source_id,
artifact_type=artifact_type,
media_type=media_type,
schema_name=schema_name,
schema_version=schema_version,
producer=producer,
producer_version=producer_version,
external_reference=relative_path.as_posix(),
payload_sha256=hashlib.sha256(content).hexdigest(),
byte_size=len(content),
coordinate_metadata=coordinate_metadata,
)
try:
return await self.create_processing_artifact(artifact, session=session)
except Exception:
external_path.unlink(missing_ok=True)
raise
async def resolve_provider_input(
self,
source: Source,
*,
session: AsyncSession | None = None,
) -> ProviderInput:
"""Resolve original or physically orientation-normalized provider input."""
media_type = source_mime_type(source.file_path)
normalized = normalize_orientation(source.file_path, media_type=media_type)
if normalized is None:
return ProviderInput(
path=Path(source.file_path),
digest_sha256=source.file_hash.lower(),
byte_size=source.file_size_bytes,
media_type=media_type,
)
metadata: dict[str, JsonValue] = {
"units": "pixels",
"origin": "top-left",
"width": normalized.derivative_width,
"height": normalized.derivative_height,
"transformations": [f"rotate-{normalized.applied_rotation_degrees}-degrees-clockwise"],
"source_id": str(source.id),
"original_digest_sha256": source.file_hash.lower(),
"original_byte_size": source.file_size_bytes,
"original_orientation": normalized.original_orientation,
"applied_rotation_degrees": normalized.applied_rotation_degrees,
"original_width": normalized.original_width,
"original_height": normalized.original_height,
"derivative_width": normalized.derivative_width,
"derivative_height": normalized.derivative_height,
"derivative_digest_sha256": normalized.digest_sha256,
"derivative_byte_size": len(normalized.content),
"original_media_type": media_type,
"derivative_media_type": normalized.media_type,
}
artifact = await self.create_binary_artifact(
source_id=source.id,
artifact_type="orientation_normalized_model_input",
media_type=normalized.media_type,
schema_name=ORIENTATION_SCHEMA,
schema_version=ORIENTATION_SCHEMA_VERSION,
producer=ORIENTATION_PRODUCER,
producer_version=ORIENTATION_PRODUCER_VERSION,
content=normalized.content,
suffix=normalized.suffix,
coordinate_metadata=metadata,
session=session,
)
if artifact.external_reference is None:
raise RuntimeError("Orientation derivative did not receive an external reference")
return ProviderInput(
path=self.settings.artifact_dir / artifact.external_reference,
digest_sha256=artifact.payload_sha256,
byte_size=artifact.byte_size,
media_type=artifact.media_type,
width=normalized.derivative_width,
height=normalized.derivative_height,
derivative_id=artifact.id,
transformation=f"{ORIENTATION_SCHEMA}@{ORIENTATION_SCHEMA_VERSION}",
)
def _write_external_artifact(self, *, path: Path, content: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = path.with_suffix(f"{path.suffix}.tmp")
@@ -986,6 +1202,7 @@ async def transcribe_document_image(
settings: Settings | None = None,
provider: TranscriptionProvider | None = None,
source_reference: SourceEvidenceReference | None = None,
requested_model: str | None = None,
) -> TranscriptionResult:
"""Transcribe a local image using the configured prompt and provider."""
runtime_settings = settings or get_settings()
@@ -1009,14 +1226,17 @@ async def transcribe_document_image(
try:
with handle_transcription_errors():
result = await adapter.transcribe(
prompt_text=prompt_execution.user_prompt,
image_bytes=image_bytes,
mime_type=mime_type,
temperature=prompt_execution.temperature,
top_p=prompt_execution.top_p,
source_reference=source_reference,
)
transcribe_kwargs = {
"prompt_text": prompt_execution.user_prompt,
"image_bytes": image_bytes,
"mime_type": mime_type,
"temperature": prompt_execution.temperature,
"top_p": prompt_execution.top_p,
"source_reference": source_reference,
}
if "requested_model" in inspect.signature(adapter.transcribe).parameters:
transcribe_kwargs["requested_model"] = requested_model
result = await adapter.transcribe(**transcribe_kwargs)
finally:
if owns_adapter:
close = getattr(adapter, "aclose", None)
+101 -8
View File
@@ -4,6 +4,7 @@ import logging
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from uuid import UUID
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -11,6 +12,8 @@ from ..config import Settings
from ..config import get_settings
from ..db.models import Document
from ..db.models import Job
from ..db.models import JobPurpose
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import JobStatus
from ..db.models import Source
@@ -29,10 +32,15 @@ from . import ServiceBundle
from .documents import DocumentService
from .people import DocumentPersonInput
from .people import PeopleService
from .quality import QUALITY_ANALYSIS_PRODUCER
from .quality import QUALITY_ANALYSIS_PRODUCER_VERSION
from .quality import QUALITY_ANALYSIS_SCHEMA
from .quality import QUALITY_ANALYSIS_VERSION
from .quality import analyze_transcription_quality
from .quality import quality_warning_payload
from .sources import PromptExecution
from .sources import build_prompt_execution
from .sources import hash_prompt_text
from .sources import source_mime_type
from .sources import transcribe_document_image
logger = logging.getLogger(__name__)
@@ -66,6 +74,46 @@ async def update_document_with_people(
return updated
async def create_source_retranscription_job(
*,
source_id,
model: str,
services: ServiceBundle,
settings: Settings | None = None,
) -> Job:
"""Create one immutable queued retranscription Job for an existing Source."""
runtime_settings = settings or get_settings()
if model not in runtime_settings.provider_models:
raise AppError(
f"Model is not configured for retranscription: {model}",
category=ErrorCategory.VALIDATION,
suggestion="Select one of the configured provider models.",
)
prompt = build_prompt_execution(settings=runtime_settings)
async with transaction_scope(session_factory=services.jobs.session_factory) as session:
source = await services.sources.read_source(source_id, session=session)
job = await services.jobs.create_job(
Job(
document_id=source.document_id,
purpose=JobPurpose.RETRANSCRIPTION,
provider=runtime_settings.provider.value,
model=model,
prompt_name=prompt.prompt_name,
prompt_hash=prompt.prompt_hash,
system_prompt=prompt.system_prompt,
user_prompt=prompt.user_prompt,
temperature=prompt.temperature,
top_p=prompt.top_p,
),
session=session,
)
await services.sources.create_job_source(
JobSource(job_id=job.id, source_id=source.id),
session=session,
)
return job
@dataclass(frozen=True)
class _SuccessfulPage:
source: Source
@@ -73,6 +121,7 @@ class _SuccessfulPage:
started_at: datetime
finished_at: datetime
duration_ms: int
model_input_artifact_id: UUID | None = None
@dataclass(frozen=True)
@@ -89,6 +138,7 @@ class _FailedPage:
normalized_metadata: dict | None = None
provider: str | None = None
model: str | None = None
model_input_artifact_id: UUID | None = None
async def advance_job(
@@ -168,22 +218,31 @@ async def process_queued_job( # noqa: PLR0915
started_at = datetime.now(UTC)
monotonic_started_at = asyncio.get_running_loop().time()
result: TranscriptionResult | None = None
provider_input = None
page_outcome: _SuccessfulPage | _FailedPage
try:
provider_input = await services.sources.resolve_provider_input(source, session=session)
if session is not None:
await session.commit()
source_reference = SourceEvidenceReference(
source_id=source.id,
digest_sha256=source.file_hash.lower(),
byte_size=source.file_size_bytes,
media_type=source_mime_type(source.file_path),
digest_sha256=provider_input.digest_sha256,
byte_size=provider_input.byte_size,
media_type=provider_input.media_type,
page_number=source.page_number,
width=provider_input.width,
height=provider_input.height,
derivative_id=provider_input.derivative_id,
transformation=provider_input.transformation,
)
result = await asyncio.wait_for(
_call_transcriber(
source=source,
input_path=provider_input.path,
prompt_execution=prompt_execution,
settings=runtime_settings,
provider=services.sources.provider,
source_reference=source_reference,
requested_model=source_job.model,
),
timeout=runtime_settings.worker_provider_timeout_seconds,
)
@@ -215,6 +274,7 @@ async def process_queued_job( # noqa: PLR0915
started_at=started_at,
finished_at=finished_at,
duration_ms=max(0, int(elapsed_seconds * 1000)),
model_input_artifact_id=provider_input.derivative_id,
)
successful_pages.append(page_outcome)
except TimeoutError:
@@ -241,6 +301,9 @@ async def process_queued_job( # noqa: PLR0915
None,
),
failure_phase="local_timeout",
model_input_artifact_id=(
provider_input.derivative_id if provider_input is not None else None
),
)
failed_pages.append(page_outcome)
logger.error(
@@ -295,6 +358,9 @@ async def process_queued_job( # noqa: PLR0915
normalized_metadata=result.metadata_payload() if result is not None else None,
provider=result.provider if result is not None else None,
model=result.model if result is not None else None,
model_input_artifact_id=(
provider_input.derivative_id if provider_input is not None else None
),
)
failed_pages.append(page_outcome)
logger.error(
@@ -470,12 +536,36 @@ async def _write_page_outcome(
provider=result.provider,
model=result.model,
request_manifest=result.request_manifest,
model_input_artifact_id=page.model_input_artifact_id,
transport_evidence=result.transport_evidence,
started_at=page.started_at,
finished_at=page.finished_at,
duration_ms=page.duration_ms,
session=session,
)
job_source = await services.sources.read_job_source_for_job(
job_id=job.id,
source_id=source.id,
session=session,
)
attempt = await services.sources.read_latest_execution_attempt(
job_source_id=job_source.id,
session=session,
)
if attempt is None:
raise RuntimeError("Successful transcription did not create execution evidence")
warnings = analyze_transcription_quality(result.text)
await services.sources.create_json_artifact(
source_id=source.id,
execution_attempt_id=attempt.id,
artifact_type="transcription_quality_warnings",
schema_name=QUALITY_ANALYSIS_SCHEMA,
schema_version=QUALITY_ANALYSIS_VERSION,
producer=QUALITY_ANALYSIS_PRODUCER,
producer_version=QUALITY_ANALYSIS_PRODUCER_VERSION,
payload=quality_warning_payload(warnings),
session=session,
)
return
await services.sources.update_job_source_transcription(
@@ -488,6 +578,7 @@ async def _write_page_outcome(
provider=page.provider,
model=page.model,
request_manifest=page.request_manifest,
model_input_artifact_id=page.model_input_artifact_id,
transport_evidence=page.transport_evidence,
failure_phase=page.failure_phase,
error_category=page.error.category.value,
@@ -572,16 +663,17 @@ def _find_provider_error(exc: BaseException) -> ProviderError | None:
async def _call_transcriber(
*,
source: Source,
input_path,
prompt_execution: PromptExecution,
settings: Settings,
provider: TranscriptionProvider,
source_reference: SourceEvidenceReference,
requested_model: str | None,
) -> TranscriptionResult:
"""Call the current transcriber while supporting legacy injected test doubles."""
if "source_reference" in inspect.signature(transcribe_document_image).parameters:
return await transcribe_document_image(
source.file_path,
input_path,
prompt_name=prompt_execution.prompt_name,
prompt_text=prompt_execution.user_prompt,
temperature=prompt_execution.temperature,
@@ -589,9 +681,10 @@ async def _call_transcriber(
settings=settings,
provider=provider,
source_reference=source_reference,
requested_model=requested_model,
)
return await transcribe_document_image(
source.file_path,
input_path,
prompt_name=prompt_execution.prompt_name,
prompt_text=prompt_execution.user_prompt,
temperature=prompt_execution.temperature,
+84 -14
View File
@@ -9,16 +9,21 @@ from uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.models import Job
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.session import session_scope
from transcription.services import ServiceBundle
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobCancelBlockedError
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobResubmitBlockedError
from transcription.services.jobs import JobService
from transcription.services.sources import SourceService
from transcription.services.store import create_job_for_document
from transcription.services.workflows import create_source_retranscription_job
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge
@@ -70,8 +75,13 @@ def register_page() -> None: # noqa: PLR0915
await render_table()
@ui.page("/jobs/new")
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
async def job_create_page( # noqa: PLR0915
request: Request,
session_factory: SessionFactoryDep,
) -> None:
documents_service = DocumentService(session_factory=session_factory)
sources_service = SourceService(session_factory=session_factory)
settings = _resolve_runtime_settings(request)
render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
@@ -79,6 +89,16 @@ def register_page() -> None: # noqa: PLR0915
"Create Processing Job", subtitle="Queue source files for AI transcription and entity processing."
)
requested_source_id = _parse_uuid(request.query_params.get("source_id"))
try:
locked_source = (
await sources_service.read_source_detail(requested_source_id)
if requested_source_id is not None
else None
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Source unavailable", operation="jobs.retranscribe.load")
return
documents = await documents_service.list_documents()
if not documents:
_render_no_documents_card()
@@ -88,24 +108,67 @@ def register_page() -> None: # noqa: PLR0915
with archival_card(extra_classes="gap-3"):
document_options = {str(doc.id): doc.name for doc in documents}
document_select = (
ui.select(document_options, label="Target Document")
.props("outlined")
.classes("w-full ui-form-surface")
)
requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options:
document_select.value = requested_document_id
if locked_source is not None:
ui.label(f"Source: {locked_source.upload_name} ({locked_source.id})").classes(
"text-sm ui-text-primary"
)
document_label = (
locked_source.document.name if locked_source.document else str(locked_source.document_id)
)
ui.label(f"Document: {document_label}").classes("text-sm ui-text-primary")
document_select = None
else:
document_select = (
ui.select(document_options, label="Target Document")
.props("outlined")
.classes("w-full ui-form-surface")
)
requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options:
document_select.value = requested_document_id
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
provider_input = ui.input(label="Provider").props("outlined").classes("ui-form-surface")
model_input = ui.input(label="Model").props("outlined").classes("ui-form-surface")
if locked_source is not None:
provider_input = (
ui.input(label="Provider", value=settings.provider.value)
.props("outlined readonly")
.classes("ui-form-surface")
)
model_input = (
ui.select(list(settings.provider_models), label="Model", value=settings.provider_model)
.props("outlined")
.classes("ui-form-surface")
)
else:
provider_input = ui.input(label="Provider").props("outlined").classes("ui-form-surface")
model_input = ui.input(label="Model").props("outlined").classes("ui-form-surface")
_render_upload_section(uploaded_files)
if locked_source is None:
_render_upload_section(uploaded_files)
async def submit_create() -> None:
if not document_select.value:
if locked_source is not None:
try:
services = ServiceBundle(
documents=documents_service,
jobs=JobService(session_factory=session_factory, settings=settings),
sources=SourceService(session_factory=session_factory, settings=settings),
)
result_job = await create_source_retranscription_job(
source_id=locked_source.id,
model=str(model_input.value),
services=services,
settings=settings,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create job failed", operation="jobs.retranscribe")
return
resolve_worker_notifier(request.app.state).notify()
ui.notify(f"Created retranscription job {result_job.id}", type="positive")
ui.navigate.to(f"/jobs/{result_job.id}")
return
if document_select is None or not document_select.value:
ui.notify("Document is required.", type="warning")
return
if not uploaded_files:
@@ -501,5 +564,12 @@ def _parse_uuid(value: str | None) -> UUID | None:
return None
def _resolve_runtime_settings(request: Request) -> Settings:
app_settings = getattr(request.app.state, "settings", None)
if isinstance(app_settings, Settings):
return app_settings
return get_settings()
def _latest_prompt_name(job: Job) -> str | None:
return job.prompt_name
+140 -1
View File
@@ -128,6 +128,7 @@ def register_page() -> None: # noqa: PLR0915
source_artifacts = list(
await sources_service.list_processing_artifact_summaries(source_id=parsed_source_id)
)
attempts = list(await sources_service.list_execution_attempts(source_id=parsed_source_id))
except TranscriptionNotFoundError:
ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
return
@@ -149,6 +150,11 @@ def register_page() -> None: # noqa: PLR0915
on_click=lambda: ui.navigate.to("/sources"),
icon="arrow_back",
).props("flat")
ui.button(
"Retranscribe Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?source_id={source.id}"),
icon="refresh",
).classes("ui-btn-primary")
ui.button(
"Export Evidence",
on_click=lambda: _download_evidence(
@@ -163,6 +169,11 @@ def register_page() -> None: # noqa: PLR0915
icon="delete",
extra_classes="text-xs",
)
if any(
artifact.artifact_type == "orientation_normalized_model_input"
for artifact in source_artifacts
):
archival_badge("Orientation normalized")
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4 gap-2"):
@@ -178,6 +189,11 @@ def register_page() -> None: # noqa: PLR0915
latest_job_source=latest_job_source,
sources_service=sources_service,
)
_render_machine_candidates(
source=source,
attempts=attempts,
sources_service=sources_service,
)
_render_source_metadata_column(
source=source,
latest_job_source=latest_job_source,
@@ -552,6 +568,129 @@ def _render_source_transcription_zone(
)
def _render_machine_candidates(
*,
source: Source,
attempts: list[ExecutionAttempt],
sources_service: SourceService,
) -> None:
successful = [
attempt
for attempt in attempts
if attempt.status.value == "transcribed" and attempt.raw_transcription
]
candidates = [
attempt for attempt in successful if attempt.id != source.preferred_execution_attempt_id
]
preferred_attempt = next(
(
attempt
for attempt in successful
if attempt.id == source.preferred_execution_attempt_id
),
None,
)
with ui.column().classes("col-span-12 lg:col-span-8 gap-2"): # noqa: PLR1702, SIM117
with archival_card(title="Candidate Machine Transcriptions"):
if preferred_attempt is not None:
_render_attempt_warnings(preferred_attempt, label="Preferred machine transcription warnings")
if not source.raw_transcription:
render_empty_state("No successful machine transcription exists yet.", italic=True)
return
if not candidates:
render_empty_state("No candidate machine transcriptions are available.", italic=True)
return
if source.revised_text:
ui.label(
"Selecting a machine candidate does not change the saved human revision used for display and print."
).classes("text-xs ui-text-muted")
for attempt in sorted(candidates, key=lambda item: item.created_at, reverse=True):
warning_count = _attempt_warning_count(attempt)
title = (
f"{attempt.created_at.isoformat()} | {attempt.provider} | "
f"{attempt.model or 'unknown model'} | Job {attempt.job_id}"
)
with ui.expansion(title, icon="warning" if warning_count else "compare").classes(
"w-full ui-row-surface"
):
if warning_count:
_render_attempt_warnings(attempt, label="Candidate warnings")
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-3"):
with ui.column().classes("gap-1"):
ui.label("Preferred machine transcription").classes("text-xs font-semibold")
ui.label(source.raw_transcription).classes(
"p-2 ui-note-box text-xs whitespace-pre-wrap"
)
with ui.column().classes("gap-1"):
ui.label("Candidate transcription").classes("text-xs font-semibold")
ui.label(attempt.raw_transcription or "").classes(
"p-2 ui-note-box text-xs whitespace-pre-wrap"
)
async def promote(candidate_id: UUID = attempt.id) -> None:
try:
await sources_service.promote_machine_attempt(
source_id=source.id,
execution_attempt_id=candidate_id,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Promotion failed", operation="sources.candidate.promote")
return
ui.notify("Preferred machine transcription updated", type="positive")
ui.navigate.to(f"/sources/{source.id}")
with ui.dialog() as confirmation, ui.card():
ui.label("Use this machine transcription?")
ui.label("All earlier attempts and any human revision will be preserved.").classes(
"text-xs ui-text-muted"
)
async def confirm_promotion(callback=promote) -> None:
confirmation.close()
await callback()
with ui.row().classes("justify-end gap-2"):
ui.button("Cancel", on_click=confirmation.close).props("flat")
ui.button(
"Use this transcription",
on_click=confirm_promotion,
).classes("ui-btn-primary")
ui.button(
"Use this transcription",
on_click=confirmation.open,
icon="check_circle",
).classes("ui-btn-primary")
def _attempt_warning_count(attempt: ExecutionAttempt) -> int:
return len(_attempt_warnings(attempt))
def _attempt_warnings(attempt: ExecutionAttempt) -> list[dict[str, object]]:
for artifact in attempt.artifacts:
if artifact.artifact_type != "transcription_quality_warnings" or artifact.inline_payload is None:
continue
warnings = artifact.inline_payload.get("warnings")
if isinstance(warnings, list):
return [warning for warning in warnings if isinstance(warning, dict)]
return []
def _render_attempt_warnings(attempt: ExecutionAttempt, *, label: str) -> None:
warnings = _attempt_warnings(attempt)
if not warnings:
return
with ui.column().classes("w-full gap-1 p-2 ui-note-box"):
ui.label(label).classes("text-xs font-semibold ui-text-danger")
for warning in warnings:
detail = warning.get("detail")
code = warning.get("code")
ui.label(f"{code}: {detail}").classes("text-xs ui-text-danger")
def _reset_revision_text(revision_input: ui.textarea, source: Source, original_transcription: str | None) -> None:
fallback_text = source.revised_text if source.revised_text is not None else (original_transcription or "")
revision_input.value = fallback_text
@@ -564,7 +703,7 @@ def _latest_job_source(source: Source) -> JobSource | None:
def _resolve_original_transcription(*, source: Source, latest_job_source: JobSource | None) -> str | None:
if latest_job_source is not None and latest_job_source.raw_transcription:
if source.raw_transcription is None and latest_job_source is not None:
return latest_job_source.raw_transcription
return source.raw_transcription