generated from john/python-template
V4.5 Complete - Enhanced trancription context, added option to restranscribe source under different models.
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user