generated from john/python-template
This commit is contained in:
@@ -59,11 +59,28 @@ class ErrorEnvelope:
|
||||
timestamp: str
|
||||
|
||||
|
||||
def canonical_error_category(error: AppError) -> str:
|
||||
"""Map internal categories to canonical API/UI envelope categories."""
|
||||
match error.category:
|
||||
case ErrorCategory.VALIDATION | ErrorCategory.USER_INPUT:
|
||||
return "validation"
|
||||
case ErrorCategory.NOT_FOUND:
|
||||
return "not_found"
|
||||
case ErrorCategory.CONFLICT:
|
||||
return "conflict"
|
||||
case ErrorCategory.EXTERNAL_PROVIDER:
|
||||
return "external"
|
||||
case ErrorCategory.INFRA_TRANSIENT:
|
||||
return "timeout"
|
||||
case _:
|
||||
return "internal"
|
||||
|
||||
|
||||
def build_error_envelope(error: AppError) -> ErrorEnvelope:
|
||||
"""Build an API-safe response envelope from an AppError."""
|
||||
return ErrorEnvelope(
|
||||
error_id=error.error_id,
|
||||
category=error.category.value,
|
||||
category=canonical_error_category(error),
|
||||
message=error.message,
|
||||
suggestion=error.suggestion,
|
||||
timestamp=datetime.now(UTC).isoformat(),
|
||||
|
||||
@@ -476,6 +476,7 @@ class SourceService(ServiceBase):
|
||||
model: str | None = None,
|
||||
request_manifest: RequestManifest | None = None,
|
||||
quality_warnings: dict[str, JsonValue] | None = None,
|
||||
timing_breakdown: dict[str, JsonValue] | None = None,
|
||||
transport_evidence: TransportEvidence | None = None,
|
||||
failure_phase: str | None = None,
|
||||
error_category: str | None = None,
|
||||
@@ -508,7 +509,12 @@ class SourceService(ServiceBase):
|
||||
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
|
||||
metadata_payload = _validate_transcription_metadata(ai_metadata)
|
||||
raw_response_payload = _validate_json_object(raw_api_response, field_name="raw_api_response")
|
||||
attempt_metadata = _merge_quality_warnings(metadata_payload, quality_warnings)
|
||||
timing_payload = _validate_json_object(timing_breakdown, field_name="timing_breakdown")
|
||||
attempt_metadata = _merge_attempt_metadata(
|
||||
metadata=metadata_payload,
|
||||
quality_warnings=quality_warnings,
|
||||
timing_breakdown=timing_payload,
|
||||
)
|
||||
|
||||
existing_job_source = await _session.exec(
|
||||
select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
|
||||
@@ -657,20 +663,24 @@ def _validate_transcription_metadata(
|
||||
return validated.as_json_object()
|
||||
|
||||
|
||||
def _merge_quality_warnings(
|
||||
def _merge_attempt_metadata(
|
||||
metadata: dict[str, JsonValue] | None,
|
||||
*,
|
||||
quality_warnings: dict[str, JsonValue] | None,
|
||||
timing_breakdown: dict[str, JsonValue] | None,
|
||||
) -> dict[str, JsonValue] | None:
|
||||
"""Attach app-computed quality warnings to provider-normalized metadata.
|
||||
"""Attach app-computed metadata to provider-normalized metadata.
|
||||
|
||||
The warnings are derived from the transcription text rather than reported by
|
||||
the provider, so they are namespaced under their own key instead of being
|
||||
mixed into the provider's own fields.
|
||||
App-computed values (quality warnings and timing) are namespaced so provider
|
||||
metadata remains semantically distinct.
|
||||
"""
|
||||
if quality_warnings is None:
|
||||
if quality_warnings is None and timing_breakdown is None:
|
||||
return metadata
|
||||
merged: dict[str, JsonValue] = dict(metadata or {})
|
||||
merged["transcription_quality_warnings"] = quality_warnings
|
||||
if quality_warnings is not None:
|
||||
merged["transcription_quality_warnings"] = quality_warnings
|
||||
if timing_breakdown is not None:
|
||||
merged["processing_timing"] = timing_breakdown
|
||||
return merged
|
||||
|
||||
|
||||
|
||||
@@ -116,7 +116,8 @@ class _SuccessfulPage:
|
||||
result: TranscriptionResult
|
||||
started_at: datetime
|
||||
finished_at: datetime
|
||||
duration_ms: int
|
||||
provider_duration_ms: int
|
||||
processing_duration_ms: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -125,7 +126,8 @@ class _FailedPage:
|
||||
error: AppError
|
||||
started_at: datetime
|
||||
finished_at: datetime
|
||||
duration_ms: int
|
||||
provider_duration_ms: int
|
||||
processing_duration_ms: int
|
||||
request_manifest: RequestManifest | None = None
|
||||
transport_evidence: TransportEvidence | None = None
|
||||
failure_phase: str | None = None
|
||||
@@ -273,7 +275,11 @@ async def process_queued_job( # noqa: PLR0915
|
||||
result=result,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
duration_ms=max(0, int(elapsed_seconds * 1000)),
|
||||
provider_duration_ms=max(0, int(elapsed_seconds * 1000)),
|
||||
processing_duration_ms=max(
|
||||
_duration_ms_between(started_at, finished_at),
|
||||
max(0, int(elapsed_seconds * 1000)),
|
||||
),
|
||||
)
|
||||
successful_pages.append(page_outcome)
|
||||
except TimeoutError:
|
||||
@@ -289,10 +295,14 @@ async def process_queued_job( # noqa: PLR0915
|
||||
error=error,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
duration_ms=max(
|
||||
provider_duration_ms=max(
|
||||
0,
|
||||
int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000),
|
||||
),
|
||||
processing_duration_ms=max(
|
||||
_duration_ms_between(started_at, finished_at),
|
||||
max(0, int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000)),
|
||||
),
|
||||
request_manifest=provider.current_request_manifest,
|
||||
transport_evidence=provider.current_transport_evidence,
|
||||
failure_phase="local_timeout",
|
||||
@@ -321,10 +331,14 @@ async def process_queued_job( # noqa: PLR0915
|
||||
error=error,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
duration_ms=max(
|
||||
provider_duration_ms=max(
|
||||
0,
|
||||
int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000),
|
||||
),
|
||||
processing_duration_ms=max(
|
||||
_duration_ms_between(started_at, finished_at),
|
||||
max(0, int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000)),
|
||||
),
|
||||
request_manifest=(
|
||||
result.request_manifest
|
||||
if result is not None
|
||||
@@ -573,10 +587,14 @@ async def _write_page_outcome(
|
||||
model=result.model,
|
||||
request_manifest=result.request_manifest,
|
||||
quality_warnings=quality_warning_payload(analyze_transcription_quality(result.text)),
|
||||
timing_breakdown={
|
||||
"provider_call_duration_ms": page.provider_duration_ms,
|
||||
"processing_duration_ms": page.processing_duration_ms,
|
||||
},
|
||||
transport_evidence=result.transport_evidence,
|
||||
started_at=page.started_at,
|
||||
finished_at=page.finished_at,
|
||||
duration_ms=page.duration_ms,
|
||||
duration_ms=page.provider_duration_ms,
|
||||
session=session,
|
||||
)
|
||||
return
|
||||
@@ -591,16 +609,24 @@ async def _write_page_outcome(
|
||||
provider=page.provider,
|
||||
model=page.model,
|
||||
request_manifest=page.request_manifest,
|
||||
timing_breakdown={
|
||||
"provider_call_duration_ms": page.provider_duration_ms,
|
||||
"processing_duration_ms": page.processing_duration_ms,
|
||||
},
|
||||
transport_evidence=page.transport_evidence,
|
||||
failure_phase=page.failure_phase,
|
||||
error_category=page.error.category.value,
|
||||
started_at=page.started_at,
|
||||
finished_at=page.finished_at,
|
||||
duration_ms=page.duration_ms,
|
||||
duration_ms=page.provider_duration_ms,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
def _duration_ms_between(started_at: datetime, finished_at: datetime) -> int:
|
||||
return max(0, int((finished_at - started_at).total_seconds() * 1000))
|
||||
|
||||
|
||||
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
|
||||
text_chars = len(result.text)
|
||||
text_lines = _line_count(result.text)
|
||||
|
||||
@@ -72,3 +72,36 @@ def resolve_media_url(path: str | None, *, upload_dir: Path, base_url: str) -> s
|
||||
return absolute_upload_url(f"/uploads/{quote(normalized)}", base_url=base_url)
|
||||
|
||||
return absolute_upload_url(f"/uploads/{quote(path_obj.name)}", base_url=base_url)
|
||||
|
||||
|
||||
def public_media_path_label(path: str | None, *, upload_dir: Path) -> str:
|
||||
"""Return a safe, non-local path label for UI metadata display."""
|
||||
candidate = (path or "").strip()
|
||||
if not candidate:
|
||||
return "unknown"
|
||||
|
||||
normalized = candidate.replace("\\", "/")
|
||||
lowered = normalized.casefold()
|
||||
|
||||
if normalized.startswith(_UPLOAD_ROUTE_PREFIX):
|
||||
return normalized
|
||||
if lowered.startswith("uploads/"):
|
||||
return f"/{normalized}"
|
||||
if lowered.startswith("data/"):
|
||||
relative = normalized.split("/", 1)[1] if "/" in normalized else ""
|
||||
return f"/uploads/{relative}" if relative else "/uploads"
|
||||
if lowered.startswith(("documents/", "persons/")):
|
||||
return f"/uploads/{normalized}"
|
||||
|
||||
resolved_upload_dir = upload_dir.resolve()
|
||||
path_obj = Path(candidate)
|
||||
if path_obj.is_absolute():
|
||||
absolute_candidate = path_obj.resolve()
|
||||
try:
|
||||
relative = absolute_candidate.relative_to(resolved_upload_dir).as_posix()
|
||||
return f"/uploads/{relative}"
|
||||
except ValueError:
|
||||
# Never expose non-managed absolute filesystem paths.
|
||||
return path_obj.name or "unknown"
|
||||
|
||||
return f"/uploads/{quote(path_obj.name)}"
|
||||
|
||||
@@ -29,6 +29,7 @@ from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.formatters import parse_uuid
|
||||
from transcription.ui.components.guards import parsed_record_id
|
||||
from transcription.ui.components.guards import render_record_not_found
|
||||
from transcription.ui.components.media_urls import public_media_path_label
|
||||
from transcription.ui.components.media_urls import resolve_media_url
|
||||
from transcription.ui.components.primitives import destructive_button
|
||||
from transcription.ui.components.primitives import render_empty_state
|
||||
@@ -193,6 +194,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
source=source,
|
||||
latest_job_source=latest_job_source,
|
||||
latest_attempt=latest_attempt,
|
||||
settings=resolve_runtime_settings(request),
|
||||
)
|
||||
|
||||
@ui.page("/sources/{source_id}/delete")
|
||||
@@ -303,9 +305,10 @@ def _render_source_metadata_column(
|
||||
source: Source,
|
||||
latest_job_source: JobSource | None,
|
||||
latest_attempt: LatestExecutionAttempt | None,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||
_render_source_metadata_zone(source)
|
||||
_render_source_metadata_zone(source, settings=settings)
|
||||
_render_source_job_metadata_zone(
|
||||
latest_job_source,
|
||||
latest_attempt=latest_attempt,
|
||||
@@ -313,14 +316,14 @@ def _render_source_metadata_column(
|
||||
_render_source_revision_logistics_zone(source)
|
||||
|
||||
|
||||
def _render_source_metadata_zone(source: Source) -> None:
|
||||
def _render_source_metadata_zone(source: Source, *, settings: Settings) -> None:
|
||||
with archival_card(title="Source Metadata"):
|
||||
metadata_row("Upload Name:", source.upload_name)
|
||||
metadata_row("Stored Filename:", source.filename)
|
||||
metadata_row("Page Number:", str(source.page_number))
|
||||
metadata_row("Document Name:", source.document_name or "Not set")
|
||||
metadata_row("Document ID:", str(source.document_id))
|
||||
metadata_row("Stored Path:", source.file_path)
|
||||
metadata_row("Stored Path:", public_media_path_label(source.file_path, upload_dir=settings.upload_dir))
|
||||
|
||||
|
||||
def _render_source_job_metadata_zone(
|
||||
|
||||
Reference in New Issue
Block a user