generated from john/python-template
V4.7 Phase 2: Evidence Model Simplification (part 2)
This commit is contained in:
@@ -70,6 +70,7 @@ class JobSourceStatus(StrEnum):
|
|||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
TRANSCRIBED = "transcribed"
|
TRANSCRIBED = "transcribed"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
class JobPurpose(StrEnum):
|
class JobPurpose(StrEnum):
|
||||||
@@ -269,15 +270,6 @@ class Job(SQLModel, table=True):
|
|||||||
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
@property
|
|
||||||
def error_detail(self) -> str | None:
|
|
||||||
"""Return the first available source-level error detail for the job."""
|
|
||||||
for job_source in _loaded_attribute(self, "job_sources") or ():
|
|
||||||
if job_source.error_detail:
|
|
||||||
return job_source.error_detail
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Source(SQLModel, table=True):
|
class Source(SQLModel, table=True):
|
||||||
"""A document source image or PDF page."""
|
"""A document source image or PDF page."""
|
||||||
@@ -321,10 +313,21 @@ class Source(SQLModel, table=True):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def latest_job_source(self) -> Optional["JobSource"]:
|
def latest_job_source(self) -> Optional["JobSource"]:
|
||||||
"""Return the most recent job execution record for this source."""
|
"""Return the most recent job execution record for this source.
|
||||||
if not self.job_sources:
|
|
||||||
return None
|
``JobSource`` carries no timestamp of its own, so recency is the parent
|
||||||
return max(self.job_sources, key=lambda js: js.executed_at)
|
job's creation time. ``(job_id, source_id)`` is unique per source, so
|
||||||
|
this is exactly "the most recent job that included this page".
|
||||||
|
"""
|
||||||
|
job_sources = _loaded_attribute(self, "job_sources") or ()
|
||||||
|
dated = [
|
||||||
|
(job, job_source)
|
||||||
|
for job_source in job_sources
|
||||||
|
if (job := _loaded_attribute(job_source, "job")) is not None
|
||||||
|
]
|
||||||
|
if dated:
|
||||||
|
return max(dated, key=lambda pair: pair[0].date_created)[1]
|
||||||
|
return job_sources[0] if job_sources else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latest_status(self) -> JobSourceStatus | None:
|
def latest_status(self) -> JobSourceStatus | None:
|
||||||
@@ -334,9 +337,19 @@ class Source(SQLModel, table=True):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def latest_error_detail(self) -> str | None:
|
def latest_error_detail(self) -> str | None:
|
||||||
"""Return the error detail from the latest job run, if present."""
|
"""Return the error detail of the latest attempt on the latest job run.
|
||||||
|
|
||||||
|
Failure detail lives on ``ExecutionAttempt``; ``JobSource`` records only
|
||||||
|
which page a job is working on and how far it got.
|
||||||
|
"""
|
||||||
latest = self.latest_job_source
|
latest = self.latest_job_source
|
||||||
return latest.error_detail if latest else None
|
if latest is None:
|
||||||
|
return None
|
||||||
|
attempts = _loaded_attribute(latest, "execution_attempts") or ()
|
||||||
|
for attempt in sorted(attempts, key=lambda item: item.attempt_number, reverse=True):
|
||||||
|
if attempt.error_detail:
|
||||||
|
return attempt.error_detail
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def document_name(self) -> str | None:
|
def document_name(self) -> str | None:
|
||||||
@@ -363,11 +376,6 @@ class JobSource(SQLModel, table=True):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
raw_transcription: str | None = None
|
|
||||||
ai_metadata: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
|
||||||
raw_api_response: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
|
||||||
error_detail: str | None = None
|
|
||||||
executed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
job: Optional["Job"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "raise"})
|
job: Optional["Job"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "raise"})
|
||||||
source: Optional["Source"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "raise"})
|
source: Optional["Source"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "raise"})
|
||||||
@@ -388,7 +396,19 @@ class ExecutionAttempt(SQLModel, table=True):
|
|||||||
job_id: UUID = Field(foreign_key="job.id", index=True)
|
job_id: UUID = Field(foreign_key="job.id", index=True)
|
||||||
source_id: UUID = Field(foreign_key="source.id", index=True)
|
source_id: UUID = Field(foreign_key="source.id", index=True)
|
||||||
attempt_number: int = Field(ge=1)
|
attempt_number: int = Field(ge=1)
|
||||||
status: JobSourceStatus
|
status: JobSourceStatus = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
# Declared identically to job_source.status. Without values_callable
|
||||||
|
# SQLAlchemy persists enum *names*, which is defect [45]: the two
|
||||||
|
# columns spelled the same status differently and never compared equal.
|
||||||
|
SAEnum(
|
||||||
|
JobSourceStatus,
|
||||||
|
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||||
|
native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
provider: str
|
provider: str
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
request_manifest: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
request_manifest: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||||
|
|||||||
@@ -338,10 +338,7 @@ class JobService(ServiceBase):
|
|||||||
for job_source in job.job_sources:
|
for job_source in job.job_sources:
|
||||||
if job_source.status == JobSourceStatus.TRANSCRIBED:
|
if job_source.status == JobSourceStatus.TRANSCRIBED:
|
||||||
continue
|
continue
|
||||||
job_source.status = JobSourceStatus.FAILED
|
job_source.status = JobSourceStatus.CANCELLED
|
||||||
job_source.raw_transcription = None
|
|
||||||
job_source.error_detail = "Cancelled by user"
|
|
||||||
job_source.executed_at = now
|
|
||||||
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
return job
|
return job
|
||||||
@@ -368,20 +365,21 @@ class JobService(ServiceBase):
|
|||||||
suggestion="Cancel processing first, then resubmit remaining sources.",
|
suggestion="Cancel processing first, then resubmit remaining sources.",
|
||||||
)
|
)
|
||||||
|
|
||||||
candidates = [job_source for job_source in job.job_sources if job_source.status == JobSourceStatus.FAILED]
|
# Cancelled pages are re-attemptable: before V4.7 cancel wrote FAILED,
|
||||||
|
# so resubmit already reset them. Excluding CANCELLED here would make
|
||||||
|
# cancelled work permanently unrecoverable.
|
||||||
|
resubmittable = {JobSourceStatus.FAILED, JobSourceStatus.CANCELLED}
|
||||||
|
candidates = [job_source for job_source in job.job_sources if job_source.status in resubmittable]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
raise JobResubmitBlockedError(
|
raise JobResubmitBlockedError(
|
||||||
"Job has no failed sources to resubmit",
|
"Job has no failed or cancelled sources to resubmit",
|
||||||
category=ErrorCategory.VALIDATION,
|
category=ErrorCategory.VALIDATION,
|
||||||
suggestion="Only failed sources can be resubmitted.",
|
suggestion="Only failed or cancelled sources can be resubmitted.",
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
for job_source in candidates:
|
for job_source in candidates:
|
||||||
job_source.status = JobSourceStatus.PENDING
|
job_source.status = JobSourceStatus.PENDING
|
||||||
job_source.raw_transcription = None
|
|
||||||
job_source.error_detail = None
|
|
||||||
job_source.executed_at = now
|
|
||||||
|
|
||||||
job.status = JobStatus.QUEUED
|
job.status = JobStatus.QUEUED
|
||||||
job.date_updated = now
|
job.date_updated = now
|
||||||
|
|||||||
@@ -350,7 +350,11 @@ class SourceService(ServiceBase):
|
|||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Source).options(
|
query = select(Source).options(
|
||||||
selectinload(Source.document),
|
selectinload(Source.document),
|
||||||
selectinload(Source.job_sources),
|
# Both are needed by Source.latest_job_source and
|
||||||
|
# latest_error_detail: recency comes from the parent job, and
|
||||||
|
# failure detail lives on the attempt, not the junction row.
|
||||||
|
selectinload(Source.job_sources).selectinload(orm_attribute(JobSource.job)),
|
||||||
|
selectinload(Source.job_sources).selectinload(orm_attribute(JobSource.execution_attempts)),
|
||||||
)
|
)
|
||||||
if document_id is not None:
|
if document_id is not None:
|
||||||
query = query.where(Source.document_id == document_id)
|
query = query.where(Source.document_id == document_id)
|
||||||
@@ -567,24 +571,12 @@ class SourceService(ServiceBase):
|
|||||||
select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
|
select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
|
||||||
)
|
)
|
||||||
job_source = existing_job_source.first()
|
job_source = existing_job_source.first()
|
||||||
|
outcome = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
|
||||||
if job_source is None:
|
if job_source is None:
|
||||||
job_source = JobSource(
|
job_source = JobSource(job_id=job_id, source_id=source_id, status=outcome)
|
||||||
job_id=job_id,
|
|
||||||
source_id=source_id,
|
|
||||||
status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
|
|
||||||
raw_transcription=text,
|
|
||||||
ai_metadata=metadata_payload,
|
|
||||||
raw_api_response=raw_response_payload,
|
|
||||||
error_detail=error_detail,
|
|
||||||
)
|
|
||||||
_session.add(job_source)
|
_session.add(job_source)
|
||||||
else:
|
else:
|
||||||
job_source.raw_transcription = text
|
job_source.status = outcome
|
||||||
job_source.ai_metadata = metadata_payload
|
|
||||||
job_source.raw_api_response = raw_response_payload
|
|
||||||
job_source.error_detail = error_detail
|
|
||||||
job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
|
|
||||||
job_source.executed_at = datetime.now(UTC)
|
|
||||||
|
|
||||||
finish_time = finished_at or datetime.now(UTC)
|
finish_time = finished_at or datetime.now(UTC)
|
||||||
start_time = started_at or finish_time
|
start_time = started_at or finish_time
|
||||||
@@ -605,7 +597,7 @@ class SourceService(ServiceBase):
|
|||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
attempt_number=(attempt_number or 0) + 1,
|
attempt_number=(attempt_number or 0) + 1,
|
||||||
status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
|
status=outcome,
|
||||||
provider=provider or job.provider or self.settings.provider.value,
|
provider=provider or job.provider or self.settings.provider.value,
|
||||||
model=model or job.model,
|
model=model or job.model,
|
||||||
request_manifest=manifest_payload,
|
request_manifest=manifest_payload,
|
||||||
|
|||||||
@@ -411,7 +411,13 @@ async def process_next_queued_job(
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_job_sources(job: Job) -> list[Source]:
|
def _resolve_job_sources(job: Job) -> list[Source]:
|
||||||
"""Resolve non-transcribed linked sources for a job in deterministic page order."""
|
"""Resolve pending linked sources for a job in deterministic page order.
|
||||||
|
|
||||||
|
A page is work if it has not already succeeded. CANCELLED is included
|
||||||
|
deliberately: resubmit resets cancelled pages to PENDING, so they are
|
||||||
|
re-attemptable, and a cancelled page that somehow reaches a running job is
|
||||||
|
unfinished work rather than a terminal outcome.
|
||||||
|
"""
|
||||||
if not job.job_sources:
|
if not job.job_sources:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -284,9 +284,10 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
with archival_card(extra_classes="gap-2"):
|
with archival_card(extra_classes="gap-2"):
|
||||||
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
||||||
metadata_row("Current Status:", job.status.value)
|
metadata_row("Current Status:", job.status.value)
|
||||||
ui.label("Cancel stops processing and marks remaining non-transcribed sources as failed.").classes(
|
ui.label(
|
||||||
"text-xs ui-text-muted"
|
"Cancel stops processing and marks remaining non-transcribed sources as cancelled. "
|
||||||
)
|
"Cancelled sources can be resubmitted."
|
||||||
|
).classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
async def submit_cancel() -> None:
|
async def submit_cancel() -> None:
|
||||||
try:
|
try:
|
||||||
@@ -327,7 +328,11 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
render_record_not_found("Job")
|
render_record_not_found("Job")
|
||||||
return
|
return
|
||||||
|
|
||||||
failed_count = sum(1 for js in job.job_sources if js.status == JobSourceStatus.FAILED)
|
resubmittable_count = sum(
|
||||||
|
1
|
||||||
|
for js in job.job_sources
|
||||||
|
if js.status in {JobSourceStatus.FAILED, JobSourceStatus.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||||
page_header("Resubmit Job")
|
page_header("Resubmit Job")
|
||||||
@@ -335,9 +340,10 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
with archival_card(extra_classes="gap-2"):
|
with archival_card(extra_classes="gap-2"):
|
||||||
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
||||||
metadata_row("Current Status:", job.status.value)
|
metadata_row("Current Status:", job.status.value)
|
||||||
metadata_row("Failed Sources:", str(failed_count))
|
metadata_row("Resubmittable Sources:", str(resubmittable_count))
|
||||||
ui.label(
|
ui.label(
|
||||||
"Resubmit queues only failed linked sources. Prior execution evidence remains preserved."
|
"Resubmit queues failed and cancelled linked sources. "
|
||||||
|
"Prior execution evidence remains preserved."
|
||||||
).classes("text-xs ui-text-muted")
|
).classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
async def submit_resubmit() -> None:
|
async def submit_resubmit() -> None:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from nicegui import ui
|
|||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.db.models import ExecutionAttempt
|
from transcription.db.models import ExecutionAttempt
|
||||||
from transcription.db.models import JobSource
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.services.sources import LatestExecutionAttempt
|
from transcription.services.sources import LatestExecutionAttempt
|
||||||
from transcription.services.sources import SourceDeleteBlockedError
|
from transcription.services.sources import SourceDeleteBlockedError
|
||||||
@@ -120,7 +121,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
try:
|
try:
|
||||||
source = await sources_service.read_source_detail(parsed_source_id)
|
source = await sources_service.read_source_detail(parsed_source_id)
|
||||||
navigation = await sources_service.read_source_navigation(parsed_source_id)
|
navigation = await sources_service.read_source_navigation(parsed_source_id)
|
||||||
latest_job_source = _latest_job_source(source)
|
latest_job_source = source.latest_job_source
|
||||||
latest_attempt = (
|
latest_attempt = (
|
||||||
await sources_service.read_latest_execution_attempt(job_source_id=latest_job_source.id)
|
await sources_service.read_latest_execution_attempt(job_source_id=latest_job_source.id)
|
||||||
if latest_job_source is not None
|
if latest_job_source is not None
|
||||||
@@ -134,7 +135,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
show_error(exc, title="Load failed", operation="sources.read")
|
show_error(exc, title="Load failed", operation="sources.read")
|
||||||
return
|
return
|
||||||
|
|
||||||
original_transcription = _resolve_original_transcription(source=source, latest_job_source=latest_job_source)
|
original_transcription = _resolve_original_transcription(source=source, latest_attempt=latest_attempt)
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||||
with section_header_row():
|
with section_header_row():
|
||||||
@@ -336,7 +337,10 @@ def _render_source_job_metadata_zone(
|
|||||||
archival_badge(status)
|
archival_badge(status)
|
||||||
|
|
||||||
metadata_row("Job ID:", str(latest_job_source.job_id))
|
metadata_row("Job ID:", str(latest_job_source.job_id))
|
||||||
metadata_row("Executed:", latest_job_source.executed_at.isoformat())
|
metadata_row(
|
||||||
|
"Executed:",
|
||||||
|
latest_attempt.attempt.finished_at.isoformat() if latest_attempt is not None else "not yet executed",
|
||||||
|
)
|
||||||
metadata_row(
|
metadata_row(
|
||||||
"Provider:",
|
"Provider:",
|
||||||
latest_job_source.job.provider if latest_job_source.job and latest_job_source.job.provider else "unknown",
|
latest_job_source.job.provider if latest_job_source.job and latest_job_source.job.provider else "unknown",
|
||||||
@@ -352,30 +356,18 @@ def _render_source_job_metadata_zone(
|
|||||||
else "unknown",
|
else "unknown",
|
||||||
)
|
)
|
||||||
|
|
||||||
if latest_job_source.error_detail:
|
if latest_attempt is not None and latest_attempt.attempt.error_detail:
|
||||||
with ui.column().classes("w-full mt-2"):
|
with ui.column().classes("w-full mt-2"):
|
||||||
ui.label("Failure Detail:").classes("ui-text-muted text-xs mb-1")
|
ui.label("Failure Detail:").classes("ui-text-muted text-xs mb-1")
|
||||||
ui.label(latest_job_source.error_detail).classes("p-2 ui-note-box text-xs")
|
ui.label(latest_attempt.attempt.error_detail).classes("p-2 ui-note-box text-xs")
|
||||||
|
|
||||||
_render_provider_evidence(
|
_render_provider_evidence(latest_attempt=latest_attempt)
|
||||||
latest_job_source,
|
|
||||||
latest_attempt=latest_attempt,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_provider_evidence(
|
def _render_provider_evidence(*, latest_attempt: LatestExecutionAttempt | None) -> None:
|
||||||
job_source: JobSource,
|
|
||||||
*,
|
|
||||||
latest_attempt: LatestExecutionAttempt | None,
|
|
||||||
) -> None:
|
|
||||||
ui.label("Provider Evidence").classes("text-xs font-semibold ui-text-primary mt-3")
|
ui.label("Provider Evidence").classes("text-xs font-semibold ui-text-primary mt-3")
|
||||||
if latest_attempt is None:
|
if latest_attempt is None:
|
||||||
render_empty_state("Exact transport evidence was not captured for this historical execution.", italic=True)
|
render_empty_state("Exact transport evidence was not captured for this historical execution.", italic=True)
|
||||||
_render_json_evidence("Normalized Metadata (AI Metadata)", job_source.ai_metadata)
|
|
||||||
_render_json_evidence(
|
|
||||||
"OpenRouter SDK Response Snapshot (Raw API Response compatibility field)",
|
|
||||||
job_source.raw_api_response,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
attempt = latest_attempt.attempt
|
attempt = latest_attempt.attempt
|
||||||
@@ -516,10 +508,13 @@ def _render_source_transcription_zone(
|
|||||||
icon="refresh",
|
icon="refresh",
|
||||||
).props("flat")
|
).props("flat")
|
||||||
|
|
||||||
if latest_job_source is not None and latest_job_source.status.value == "failed":
|
if latest_job_source is not None and latest_job_source.status in {
|
||||||
ui.label("Source has a failed job execution. Save a human revision to preserve corrected text.").classes(
|
JobSourceStatus.FAILED,
|
||||||
"text-xs ui-text-muted italic"
|
JobSourceStatus.CANCELLED,
|
||||||
)
|
}:
|
||||||
|
ui.label(
|
||||||
|
"Source has an unfinished job execution. Save a human revision to preserve corrected text."
|
||||||
|
).classes("text-xs ui-text-muted italic")
|
||||||
|
|
||||||
|
|
||||||
def _render_machine_candidates(
|
def _render_machine_candidates(
|
||||||
@@ -651,13 +646,7 @@ def _reset_revision_text(revision_input: ui.textarea, source: Source, original_t
|
|||||||
revision_input.value = fallback_text
|
revision_input.value = fallback_text
|
||||||
|
|
||||||
|
|
||||||
def _latest_job_source(source: Source) -> JobSource | None:
|
def _resolve_original_transcription(*, source: Source, latest_attempt: LatestExecutionAttempt | None) -> str | None:
|
||||||
if not source.job_sources:
|
if source.raw_transcription is None and latest_attempt is not None:
|
||||||
return None
|
return latest_attempt.attempt.raw_transcription
|
||||||
return max(source.job_sources, key=lambda item: item.executed_at)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_original_transcription(*, source: Source, latest_job_source: JobSource | None) -> str | None:
|
|
||||||
if source.raw_transcription is None and latest_job_source is not None:
|
|
||||||
return latest_job_source.raw_transcription
|
|
||||||
return source.raw_transcription
|
return source.raw_transcription
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ from uuid import uuid4
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from sqlmodel import col
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import ExecutionAttempt
|
||||||
from transcription.db.models import JobSourceStatus
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.providers.base import ProviderUsage
|
from transcription.providers.base import ProviderUsage
|
||||||
@@ -20,6 +23,15 @@ from transcription.services.store import create_job_for_document
|
|||||||
from transcription.services.workflows import advance_job
|
from transcription.services.workflows import advance_job
|
||||||
|
|
||||||
|
|
||||||
|
async def _attempts_for_job(session, job) -> list[ExecutionAttempt]:
|
||||||
|
"""Load execution attempts for a job; V4.7 moved evidence off JobSource."""
|
||||||
|
job_source_ids = [job_source.id for job_source in job.job_sources]
|
||||||
|
result = await session.exec(
|
||||||
|
select(ExecutionAttempt).where(col(ExecutionAttempt.job_source_id).in_(job_source_ids))
|
||||||
|
)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
|
||||||
def _jpeg_bytes(color: str = "white") -> bytes:
|
def _jpeg_bytes(color: str = "white") -> bytes:
|
||||||
output = io.BytesIO()
|
output = io.BytesIO()
|
||||||
Image.new("RGB", (2, 2), color=color).save(output, format="JPEG")
|
Image.new("RGB", (2, 2), color=color).save(output, format="JPEG")
|
||||||
@@ -116,21 +128,24 @@ class TestPipelineSuccessFlow:
|
|||||||
assert processed is True
|
assert processed is True
|
||||||
assert job is not None
|
assert job is not None
|
||||||
assert job.status == JobStatus.TRANSCRIBED
|
assert job.status == JobStatus.TRANSCRIBED
|
||||||
assert any(job_source.raw_transcription == "Pipeline transcript" for job_source in job.job_sources)
|
attempts = await _attempts_for_job(async_session, job)
|
||||||
|
assert any(attempt.raw_transcription == "Pipeline transcript" for attempt in attempts)
|
||||||
assert job.prompt_name == "transcribe_document.md"
|
assert job.prompt_name == "transcribe_document.md"
|
||||||
assert job.user_prompt is not None
|
assert job.user_prompt is not None
|
||||||
assert job.temperature == 0.2
|
assert job.temperature == 0.2
|
||||||
assert job.top_p == 0.85
|
assert job.top_p == 0.85
|
||||||
assert any(
|
assert any(
|
||||||
job_source.ai_metadata == {"finish_reason": "stop", "usage": {"total_tokens": 42}}
|
attempt.normalized_metadata is not None
|
||||||
for job_source in job.job_sources
|
and attempt.normalized_metadata["finish_reason"] == "stop"
|
||||||
|
and attempt.normalized_metadata["usage"] == {"total_tokens": 42}
|
||||||
|
for attempt in attempts
|
||||||
)
|
)
|
||||||
assert any(
|
assert any(
|
||||||
job_source.raw_api_response
|
attempt.sdk_response_snapshot
|
||||||
== {"id": "resp_123", "choices": [{"message": {"content": "Pipeline transcript"}}]}
|
== {"id": "resp_123", "choices": [{"message": {"content": "Pipeline transcript"}}]}
|
||||||
for job_source in job.job_sources
|
for attempt in attempts
|
||||||
)
|
)
|
||||||
assert all(job_source.error_detail is None for job_source in job.job_sources)
|
assert all(attempt.error_detail is None for attempt in attempts)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_worker_transcribes_all_sources_for_multi_page_job(
|
async def test_worker_transcribes_all_sources_for_multi_page_job(
|
||||||
@@ -191,7 +206,8 @@ class TestPipelineSuccessFlow:
|
|||||||
assert job.status == JobStatus.TRANSCRIBED
|
assert job.status == JobStatus.TRANSCRIBED
|
||||||
assert len(job.job_sources) == 3
|
assert len(job.job_sources) == 3
|
||||||
assert all(job_source.status == JobSourceStatus.TRANSCRIBED for job_source in job.job_sources)
|
assert all(job_source.status == JobSourceStatus.TRANSCRIBED for job_source in job.job_sources)
|
||||||
assert all(job_source.raw_transcription for job_source in job.job_sources)
|
attempts = await _attempts_for_job(async_session, job)
|
||||||
|
assert all(attempt.raw_transcription for attempt in attempts)
|
||||||
assert all(
|
assert all(
|
||||||
job_source.source is not None and job_source.source.raw_transcription for job_source in job.job_sources
|
job_source.source is not None and job_source.source.raw_transcription for job_source in job.job_sources
|
||||||
)
|
)
|
||||||
@@ -261,7 +277,8 @@ class TestPipelineSuccessFlow:
|
|||||||
assert len(job.job_sources) == 2
|
assert len(job.job_sources) == 2
|
||||||
statuses = {job_source.status for job_source in job.job_sources}
|
statuses = {job_source.status for job_source in job.job_sources}
|
||||||
assert statuses == {JobSourceStatus.TRANSCRIBED, JobSourceStatus.FAILED}
|
assert statuses == {JobSourceStatus.TRANSCRIBED, JobSourceStatus.FAILED}
|
||||||
assert any(job_source.error_detail is not None for job_source in job.job_sources)
|
attempts = await _attempts_for_job(async_session, job)
|
||||||
|
assert any(attempt.error_detail is not None for attempt in attempts)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_worker_skips_already_transcribed_sources_on_resubmit(
|
async def test_worker_skips_already_transcribed_sources_on_resubmit(
|
||||||
@@ -293,9 +310,7 @@ class TestPipelineSuccessFlow:
|
|||||||
page_two = next(js for js in job.job_sources if js.source is not None and js.source.page_number == 2)
|
page_two = next(js for js in job.job_sources if js.source is not None and js.source.page_number == 2)
|
||||||
|
|
||||||
page_one.status = JobSourceStatus.TRANSCRIBED
|
page_one.status = JobSourceStatus.TRANSCRIBED
|
||||||
page_one.raw_transcription = "existing transcript"
|
|
||||||
page_two.status = JobSourceStatus.PENDING
|
page_two.status = JobSourceStatus.PENDING
|
||||||
page_two.raw_transcription = None
|
|
||||||
await services.sources.update_job_source(job_source=page_one, session=async_session)
|
await services.sources.update_job_source(job_source=page_one, session=async_session)
|
||||||
await services.sources.update_job_source(job_source=page_two, session=async_session)
|
await services.sources.update_job_source(job_source=page_two, session=async_session)
|
||||||
await services.jobs.update_job_state(job_id=job.id, status=JobStatus.QUEUED, session=async_session)
|
await services.jobs.update_job_state(job_id=job.id, status=JobStatus.QUEUED, session=async_session)
|
||||||
@@ -387,11 +402,10 @@ class TestPipelineFailureFlow:
|
|||||||
assert processed is True
|
assert processed is True
|
||||||
assert job is not None
|
assert job is not None
|
||||||
assert job.status == JobStatus.FAILED
|
assert job.status == JobStatus.FAILED
|
||||||
assert all(job_source.raw_transcription is None for job_source in job.job_sources)
|
attempts = await _attempts_for_job(async_session, job)
|
||||||
assert any(job_source.error_detail is not None for job_source in job.job_sources)
|
assert all(attempt.raw_transcription is None for attempt in attempts)
|
||||||
error_detail = next(
|
assert any(attempt.error_detail is not None for attempt in attempts)
|
||||||
job_source.error_detail for job_source in job.job_sources if job_source.error_detail is not None
|
error_detail = next(attempt.error_detail for attempt in attempts if attempt.error_detail is not None)
|
||||||
)
|
|
||||||
assert "pipeline provider failure" in error_detail
|
assert "pipeline provider failure" in error_detail
|
||||||
assert "[internal_unexpected_error]" in error_detail
|
assert "[internal_unexpected_error]" in error_detail
|
||||||
assert "error_id=" in error_detail
|
assert "error_id=" in error_detail
|
||||||
|
|||||||
@@ -342,7 +342,6 @@ class TestJobService:
|
|||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
source_id=source_one.id,
|
source_id=source_one.id,
|
||||||
status=JobSourceStatus.TRANSCRIBED,
|
status=JobSourceStatus.TRANSCRIBED,
|
||||||
raw_transcription="done",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.add(
|
session.add(
|
||||||
@@ -360,9 +359,8 @@ class TestJobService:
|
|||||||
refreshed = await job_service.read_job(job_id=job.id)
|
refreshed = await job_service.read_job(job_id=job.id)
|
||||||
statuses = {item.status for item in refreshed.job_sources}
|
statuses = {item.status for item in refreshed.job_sources}
|
||||||
assert JobSourceStatus.TRANSCRIBED in statuses
|
assert JobSourceStatus.TRANSCRIBED in statuses
|
||||||
assert JobSourceStatus.FAILED in statuses
|
assert JobSourceStatus.CANCELLED in statuses
|
||||||
pending_entry = next(item for item in refreshed.job_sources if item.status == JobSourceStatus.FAILED)
|
assert JobSourceStatus.FAILED not in statuses
|
||||||
assert pending_entry.error_detail == "Cancelled by user"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resubmit_failed_sources_resets_only_failed(
|
async def test_resubmit_failed_sources_resets_only_failed(
|
||||||
@@ -406,8 +404,6 @@ class TestJobService:
|
|||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
source_id=source_one.id,
|
source_id=source_one.id,
|
||||||
status=JobSourceStatus.FAILED,
|
status=JobSourceStatus.FAILED,
|
||||||
raw_transcription=None,
|
|
||||||
error_detail="prior error",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.add(
|
session.add(
|
||||||
@@ -415,7 +411,6 @@ class TestJobService:
|
|||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
source_id=source_two.id,
|
source_id=source_two.id,
|
||||||
status=JobSourceStatus.TRANSCRIBED,
|
status=JobSourceStatus.TRANSCRIBED,
|
||||||
raw_transcription="done text",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -433,7 +428,6 @@ class TestJobService:
|
|||||||
item for item in refreshed.job_sources if item.source is not None and item.source.page_number == 2
|
item for item in refreshed.job_sources if item.source is not None and item.source.page_number == 2
|
||||||
)
|
)
|
||||||
assert failed_entry.status == JobSourceStatus.PENDING
|
assert failed_entry.status == JobSourceStatus.PENDING
|
||||||
assert failed_entry.error_detail is None
|
|
||||||
assert failed_entry.source is not None
|
assert failed_entry.source is not None
|
||||||
assert failed_entry.source.raw_transcription == "existing text"
|
assert failed_entry.source.raw_transcription == "existing text"
|
||||||
assert transcribed_entry.status == JobSourceStatus.TRANSCRIBED
|
assert transcribed_entry.status == JobSourceStatus.TRANSCRIBED
|
||||||
@@ -486,7 +480,6 @@ class TestJobService:
|
|||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
source_id=source_two.id,
|
source_id=source_two.id,
|
||||||
status=JobSourceStatus.TRANSCRIBED,
|
status=JobSourceStatus.TRANSCRIBED,
|
||||||
raw_transcription="done text",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -494,6 +487,46 @@ class TestJobService:
|
|||||||
with pytest.raises(JobResubmitBlockedError):
|
with pytest.raises(JobResubmitBlockedError):
|
||||||
await job_service.resubmit_failed_sources(job_id=job.id)
|
await job_service.resubmit_failed_sources(job_id=job.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resubmit_failed_sources_includes_cancelled(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
"""Cancel is recoverable: cancelled pages are re-attempted on resubmit."""
|
||||||
|
document = Document(id=uuid4(), name="resubmit-cancelled-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.FAILED)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
async with job_service._session_scope() as session:
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="resubmit-cancelled.jpg",
|
||||||
|
filename="stored-resubmit-cancelled.jpg",
|
||||||
|
file_path="/uploads/stored-resubmit-cancelled.jpg",
|
||||||
|
file_hash="3" * 64,
|
||||||
|
file_size_bytes=1,
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.CANCELLED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert await job_service.resubmit_failed_sources(job_id=job.id) == 1
|
||||||
|
|
||||||
|
refreshed = await job_service.read_job(job_id=job.id)
|
||||||
|
assert refreshed.status == JobStatus.QUEUED
|
||||||
|
assert refreshed.job_sources[0].status == JobSourceStatus.PENDING
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resubmit_failed_sources_blocks_when_processing(
|
async def test_resubmit_failed_sources_blocks_when_processing(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -322,6 +322,10 @@ async def test_update_job_source_transcription_persists_provider_json_payloads(d
|
|||||||
|
|
||||||
stored_rows = await transcriptions.list_job_sources(job_id=job.id)
|
stored_rows = await transcriptions.list_job_sources(job_id=job.id)
|
||||||
assert len(stored_rows) == 1
|
assert len(stored_rows) == 1
|
||||||
assert stored_rows[0].raw_transcription == "provider transcript"
|
assert stored_rows[0].status == JobSourceStatus.TRANSCRIBED
|
||||||
assert stored_rows[0].ai_metadata == metadata
|
|
||||||
assert stored_rows[0].raw_api_response == raw_payload
|
attempt = await transcriptions.read_latest_execution_attempt(job_source_id=stored_rows[0].id)
|
||||||
|
assert attempt is not None
|
||||||
|
assert attempt.attempt.raw_transcription == "provider transcript"
|
||||||
|
assert attempt.attempt.normalized_metadata == metadata
|
||||||
|
assert attempt.attempt.sdk_response_snapshot == raw_payload
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ from pathlib import Path
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlmodel import col
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import ExecutionAttempt
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import JobSource
|
from transcription.db.models import JobSource
|
||||||
from transcription.db.models import JobSourceStatus
|
from transcription.db.models import JobSourceStatus
|
||||||
@@ -87,9 +90,18 @@ class TestWorkflowReliability:
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.status == JobStatus.FAILED
|
assert result.status == JobStatus.FAILED
|
||||||
assert result.error_detail is not None
|
|
||||||
assert "timed out" in result.error_detail.lower()
|
async with services.jobs._session_scope() as session:
|
||||||
assert "20.0s" in result.error_detail
|
attempts = (
|
||||||
|
await session.execute(
|
||||||
|
select(ExecutionAttempt).where(
|
||||||
|
col(ExecutionAttempt.job_source_id).in_([js.id for js in result.job_sources])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
error_detail = next(attempt.error_detail for attempt in attempts if attempt.error_detail is not None)
|
||||||
|
assert "timed out" in error_detail.lower()
|
||||||
|
assert "20.0s" in error_detail
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_completed_page_is_committed_before_next_provider_call_finishes(
|
async def test_completed_page_is_committed_before_next_provider_call_finishes(
|
||||||
|
|||||||
+4
-11
@@ -211,24 +211,17 @@ class TestPersonAndDocumentPersonModel:
|
|||||||
|
|
||||||
|
|
||||||
class TestJobSourceModel:
|
class TestJobSourceModel:
|
||||||
def test_job_source_persists_json_payloads(self, session):
|
def test_job_source_is_a_queue_row_not_an_evidence_row(self, session):
|
||||||
|
"""V4.7: job_source carries only queue state; evidence lives on execution_attempt."""
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
job = _persist_job(session, document)
|
job = _persist_job(session, document)
|
||||||
source = _persist_source(session, document)
|
source = _persist_source(session, document)
|
||||||
job_source = _persist_job_source(
|
job_source = _persist_job_source(session, job, source)
|
||||||
session,
|
|
||||||
job,
|
|
||||||
source,
|
|
||||||
raw_transcription="Page transcript",
|
|
||||||
ai_metadata={"confidence": 0.91, "boxes": [{"x": 1, "y": 2}]},
|
|
||||||
raw_api_response={"provider": "test"},
|
|
||||||
)
|
|
||||||
|
|
||||||
fetched = session.get(JobSource, job_source.id)
|
fetched = session.get(JobSource, job_source.id)
|
||||||
assert fetched is not None
|
assert fetched is not None
|
||||||
assert fetched.status == JobSourceStatus.PENDING
|
assert fetched.status == JobSourceStatus.PENDING
|
||||||
assert fetched.ai_metadata == {"confidence": 0.91, "boxes": [{"x": 1, "y": 2}]}
|
assert set(JobSource.model_fields) == {"id", "job_id", "source_id", "status"}
|
||||||
assert fetched.raw_api_response == {"provider": "test"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestRelationships:
|
class TestRelationships:
|
||||||
|
|||||||
+20
-6
@@ -23,6 +23,7 @@ from transcription.db import session as db_session_module
|
|||||||
from transcription.db import session_scope
|
from transcription.db import session_scope
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import DocumentPerson
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import ExecutionAttempt
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import JobSource
|
from transcription.db.models import JobSource
|
||||||
from transcription.db.models import JobSourceStatus
|
from transcription.db.models import JobSourceStatus
|
||||||
@@ -131,17 +132,30 @@ async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., Awai
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
if transcription_text is not None or error_detail is not None:
|
if transcription_text is not None or error_detail is not None:
|
||||||
|
outcome = JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED
|
||||||
|
job_source = JobSource(job_id=job.id, source_id=source.id, status=outcome)
|
||||||
|
session.add(job_source)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# V4.7: evidence lives on execution_attempt, not job_source.
|
||||||
|
executed_at = datetime.now(UTC)
|
||||||
session.add(
|
session.add(
|
||||||
JobSource(
|
ExecutionAttempt(
|
||||||
|
job_source_id=job_source.id,
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
source_id=source.id,
|
source_id=source.id,
|
||||||
status=(
|
attempt_number=1,
|
||||||
JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED
|
status=outcome,
|
||||||
),
|
provider="openrouter",
|
||||||
|
model="google/gemini-2.5-flash",
|
||||||
|
response_received=transcription_text is not None,
|
||||||
|
sdk_response_snapshot=raw_api_response,
|
||||||
|
normalized_metadata=ai_metadata,
|
||||||
raw_transcription=transcription_text,
|
raw_transcription=transcription_text,
|
||||||
error_detail=error_detail,
|
error_detail=error_detail,
|
||||||
ai_metadata=ai_metadata,
|
started_at=executed_at,
|
||||||
raw_api_response=raw_api_response,
|
finished_at=executed_at,
|
||||||
|
duration_ms=0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class TestJobsPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Resubmit Job" in response.text
|
assert "Resubmit Job" in response.text
|
||||||
assert "Failed Sources:" in response.text
|
assert "Resubmittable Sources:" in response.text
|
||||||
assert "Resubmit now" in response.text
|
assert "Resubmit now" in response.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import pytest
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from transcription.db import session_scope
|
from transcription.db import session_scope
|
||||||
|
from transcription.db.loading import orm_attribute
|
||||||
from transcription.db.loading import selectinload
|
from transcription.db.loading import selectinload
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobSource
|
||||||
from transcription.db.models import JobSourceStatus
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
@@ -56,7 +58,7 @@ class TestSourceModelProperties:
|
|||||||
select(Source)
|
select(Source)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Source.document),
|
selectinload(Source.document),
|
||||||
selectinload(Source.job_sources),
|
selectinload(Source.job_sources).selectinload(orm_attribute(JobSource.execution_attempts)),
|
||||||
)
|
)
|
||||||
.where(Source.document_id == job.document_id)
|
.where(Source.document_id == job.document_id)
|
||||||
)
|
)
|
||||||
@@ -218,8 +220,8 @@ class TestSourcesPageRendering:
|
|||||||
assert "Save revision" in response.text
|
assert "Save revision" in response.text
|
||||||
assert "Previous Page" in response.text
|
assert "Previous Page" in response.text
|
||||||
assert "Next Page" in response.text
|
assert "Next Page" in response.text
|
||||||
assert "AI Metadata" in response.text
|
assert "Normalized Metadata" in response.text
|
||||||
assert "Raw API Response" in response.text
|
assert "OpenRouter SDK Response Snapshot" in response.text
|
||||||
assert "finish_reason" in response.text
|
assert "finish_reason" in response.text
|
||||||
assert "response-123" in response.text
|
assert "response-123" in response.text
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Steps, in execution order:
|
|||||||
orientation, in place, and update ``source.file_hash`` and
|
orientation, in place, and update ``source.file_hash`` and
|
||||||
``source.file_size_bytes`` to describe the rewritten file.
|
``source.file_size_bytes`` to describe the rewritten file.
|
||||||
2. Drop the ``processing_artifact`` table and delete its external files.
|
2. Drop the ``processing_artifact`` table and delete its external files.
|
||||||
|
3. Rewrite ``execution_attempt.status`` from enum *names* to enum *values*, so
|
||||||
|
it compares equal to ``job_source.status`` (defect [45]).
|
||||||
|
4. Drop the five evidence columns from ``job_source``, leaving it a pure work
|
||||||
|
queue of ``id``, ``job_id``, ``source_id`` and ``status``.
|
||||||
|
|
||||||
Design notes:
|
Design notes:
|
||||||
|
|
||||||
@@ -50,6 +54,7 @@ import sys
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import bindparam
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -62,6 +67,7 @@ from transcription.config import Settings
|
|||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
from transcription.db import models as _models # noqa: F401 (registers every table)
|
from transcription.db import models as _models # noqa: F401 (registers every table)
|
||||||
from transcription.db.engine import get_database_url
|
from transcription.db.engine import get_database_url
|
||||||
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.services.normalization import normalize_orientation
|
from transcription.services.normalization import normalize_orientation
|
||||||
from transcription.services.sources import source_mime_type
|
from transcription.services.sources import source_mime_type
|
||||||
|
|
||||||
@@ -81,6 +87,17 @@ EXPECTED_ROW_COUNTS = {
|
|||||||
|
|
||||||
ARTIFACT_TABLE = "processing_artifact"
|
ARTIFACT_TABLE = "processing_artifact"
|
||||||
|
|
||||||
|
#: Evidence columns removed from ``job_source`` in V4.7. Every one of them is
|
||||||
|
#: duplicated byte-for-byte by ``execution_attempt`` across all 77 rows that
|
||||||
|
#: carry evidence, so no information is lost by dropping them.
|
||||||
|
JOB_SOURCE_DROPPED_COLUMNS = (
|
||||||
|
"raw_transcription",
|
||||||
|
"ai_metadata",
|
||||||
|
"raw_api_response",
|
||||||
|
"error_detail",
|
||||||
|
"executed_at",
|
||||||
|
)
|
||||||
|
|
||||||
#: The V4.6 default for the deleted ``Settings.artifact_dir``. The setting no
|
#: The V4.6 default for the deleted ``Settings.artifact_dir``. The setting no
|
||||||
#: longer exists, so the historical location is recorded here instead.
|
#: longer exists, so the historical location is recorded here instead.
|
||||||
DEFAULT_ARTIFACT_DIR = Path("data/artifacts")
|
DEFAULT_ARTIFACT_DIR = Path("data/artifacts")
|
||||||
@@ -195,6 +212,74 @@ def drop_processing_artifacts(connection: Connection, artifact_dir: Path, *, dry
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_attempt_status(connection: Connection, *, dry_run: bool) -> int:
|
||||||
|
"""Step 3: rewrite ``execution_attempt.status`` from enum names to values.
|
||||||
|
|
||||||
|
Defect [45]: ``execution_attempt.status`` was declared without
|
||||||
|
``values_callable``, so SQLAlchemy persisted enum *names* ('TRANSCRIBED')
|
||||||
|
while ``job_source.status`` persisted *values* ('transcribed'). The two
|
||||||
|
columns never compared equal on a single one of the 79 rows. The model
|
||||||
|
declaration is fixed in V4.7; the stored rows are fixed here.
|
||||||
|
"""
|
||||||
|
name_to_value = {member.name: member.value for member in JobSourceStatus}
|
||||||
|
recognised = sorted(set(name_to_value) | set(name_to_value.values()))
|
||||||
|
unknown = (
|
||||||
|
connection.execute(
|
||||||
|
text("select distinct status from execution_attempt where status not in :values").bindparams(
|
||||||
|
bindparam("values", recognised, expanding=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if unknown:
|
||||||
|
raise RuntimeError(f"execution_attempt.status carries unrecognised spellings: {sorted(unknown)}")
|
||||||
|
|
||||||
|
rewritten = 0
|
||||||
|
for name, value in sorted(name_to_value.items()):
|
||||||
|
if name == value:
|
||||||
|
continue
|
||||||
|
count = connection.execute(
|
||||||
|
text("select count(*) from execution_attempt where status = :name"),
|
||||||
|
{"name": name},
|
||||||
|
).scalar_one()
|
||||||
|
if not count:
|
||||||
|
continue
|
||||||
|
print(f" {name} -> {value}: {count} row(s)")
|
||||||
|
rewritten += count
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
connection.execute(
|
||||||
|
text("update execution_attempt set status = :value where status = :name"),
|
||||||
|
{"name": name, "value": value},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" rewritten={rewritten}")
|
||||||
|
return rewritten
|
||||||
|
|
||||||
|
|
||||||
|
def strip_job_source_columns(connection: Connection, *, dry_run: bool) -> int:
|
||||||
|
"""Step 4: drop the evidence columns from ``job_source``.
|
||||||
|
|
||||||
|
Uses ``ALTER TABLE ... DROP COLUMN``, supported by SQLite 3.35+ and by
|
||||||
|
PostgreSQL. Idempotent: a column that is already gone is skipped.
|
||||||
|
"""
|
||||||
|
inspector = sqlalchemy_inspect(connection)
|
||||||
|
present = {column["name"] for column in inspector.get_columns("job_source")}
|
||||||
|
targets = [name for name in JOB_SOURCE_DROPPED_COLUMNS if name in present]
|
||||||
|
if not targets:
|
||||||
|
print(" all evidence columns already dropped")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f" dropping {len(targets)} column(s): {', '.join(targets)}")
|
||||||
|
if dry_run:
|
||||||
|
return len(targets)
|
||||||
|
|
||||||
|
for name in targets:
|
||||||
|
connection.execute(text(f'alter table "job_source" drop column "{name}"'))
|
||||||
|
return len(targets)
|
||||||
|
|
||||||
|
|
||||||
def migrate(*, settings: Settings, artifact_dir: Path, dry_run: bool, strict_counts: bool) -> None:
|
def migrate(*, settings: Settings, artifact_dir: Path, dry_run: bool, strict_counts: bool) -> None:
|
||||||
"""Apply every V4.7 migration step in order."""
|
"""Apply every V4.7 migration step in order."""
|
||||||
engine = create_engine(_sync_url(settings))
|
engine = create_engine(_sync_url(settings))
|
||||||
@@ -207,6 +292,12 @@ def migrate(*, settings: Settings, artifact_dir: Path, dry_run: bool, strict_cou
|
|||||||
|
|
||||||
print(f"\nStep 2: drop {ARTIFACT_TABLE}")
|
print(f"\nStep 2: drop {ARTIFACT_TABLE}")
|
||||||
drop_processing_artifacts(connection, artifact_dir, dry_run=dry_run)
|
drop_processing_artifacts(connection, artifact_dir, dry_run=dry_run)
|
||||||
|
|
||||||
|
print("\nStep 3: normalize execution_attempt.status spelling")
|
||||||
|
normalize_attempt_status(connection, dry_run=dry_run)
|
||||||
|
|
||||||
|
print("\nStep 4: strip evidence columns from job_source")
|
||||||
|
strip_job_source_columns(connection, dry_run=dry_run)
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user