V4.2 complete

This commit is contained in:
Jim Lancaster
2026-08-14 15:59:38 -05:00
parent 6bd4cbb0a7
commit c9f5dca064
12 changed files with 282 additions and 21 deletions
+10 -4
View File
@@ -43,12 +43,18 @@ async def dispose_database_runtime() -> None:
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
"""Initialize lifespan-owned async DB resources once per process."""
global _runtime
runtime = _runtime
if runtime is not None:
return runtime
active_settings = settings or get_settings()
database_url = get_database_url(active_settings)
runtime = _runtime
if runtime is not None:
runtime_url = runtime.engine.url.render_as_string(hide_password=False)
if runtime_url != database_url:
raise RuntimeError(
"Database runtime is already initialized for a different database: "
f"{runtime_url!r} != {database_url!r}"
)
return runtime
engine = get_engine(database_url)
session_factory = get_session_factory(database_url)
runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
+52 -6
View File
@@ -4,7 +4,10 @@ from __future__ import annotations
import base64
import hashlib
import json
import logging
from collections.abc import AsyncIterator
from collections.abc import Callable
from typing import Annotated
from typing import Any
from typing import Literal
@@ -36,7 +39,25 @@ from transcription.providers.evidence import filter_safe_response_headers
logger = logging.getLogger(__name__)
DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"
OPENROUTER_ADAPTER_VERSION = "1"
OPENROUTER_ADAPTER_VERSION = "2"
class _CapturingAsyncByteStream(httpx.AsyncByteStream):
"""Copy streamed response bytes without changing what the SDK consumes."""
def __init__(self, stream: httpx.AsyncByteStream, on_complete: Callable[[bytes], None]):
self._stream = stream
self._on_complete = on_complete
async def __aiter__(self) -> AsyncIterator[bytes]:
content = bytearray()
async for chunk in self._stream:
content.extend(chunk)
yield chunk
self._on_complete(bytes(content))
async def aclose(self) -> None:
await self._stream.aclose()
class _CapturingAsyncClient:
@@ -45,10 +66,15 @@ class _CapturingAsyncClient:
def __init__(self, client: httpx.AsyncClient):
self._client = client
self.last_response: httpx.Response | None = None
self.last_body: bytes | None = None
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
response = await self._client.send(request, **kwargs)
self.last_response = response
try:
self.last_body = response.content
except httpx.ResponseNotRead:
response.stream = _CapturingAsyncByteStream(response.stream, self._capture_body)
return response
def build_request(self, *args: Any, **kwargs: Any) -> httpx.Request:
@@ -59,6 +85,10 @@ class _CapturingAsyncClient:
def reset(self) -> None:
self.last_response = None
self.last_body = None
def _capture_body(self, body: bytes) -> None:
self.last_body = body
class _ProviderModel(BaseModel):
@@ -245,7 +275,7 @@ class OpenRouterTranscriptionProvider:
else "connection"
)
raise ProviderError(
"OpenRouter request failed",
self._transport_error_message(transport),
request_manifest=manifest,
transport_evidence=transport,
failure_phase=failure_phase,
@@ -354,10 +384,7 @@ class OpenRouterTranscriptionProvider:
if response is None:
return TransportEvidence(response_received=False)
headers = filter_safe_response_headers(response.headers)
try:
body = response.content
except httpx.ResponseNotRead:
body = None
body = self._capturing_client.last_body if self._capturing_client is not None else None
return TransportEvidence(
response_received=True,
status_code=response.status_code,
@@ -369,6 +396,25 @@ class OpenRouterTranscriptionProvider:
generation_id=headers.get("x-openrouter-generation-id"),
)
@staticmethod
def _transport_error_message(transport: TransportEvidence) -> str:
message = "OpenRouter request failed"
if transport.status_code is not None:
message += f" with HTTP {transport.status_code}"
if transport.body is None:
return message
try:
payload = json.loads(transport.body)
except (UnicodeDecodeError, json.JSONDecodeError):
return message
if not isinstance(payload, dict):
return message
error = payload.get("error")
detail = error.get("message") if isinstance(error, dict) else None
if isinstance(detail, str) and detail.strip():
return f"{message}: {detail.strip()[:500]}"
return message
def _build_metadata(self, response: OpenRouterResponse) -> TranscriptionMetadata:
choice = response.choices[0]
finish_reason = choice.finish_reason.strip() if choice.finish_reason and choice.finish_reason.strip() else None
+80
View File
@@ -1,6 +1,8 @@
import logging
from collections.abc import Sequence
from datetime import UTC
from datetime import datetime
from pathlib import Path
from uuid import UUID
from sqlalchemy import func
@@ -13,11 +15,14 @@ from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import JobStatus
from ..db.models import ProcessingArtifact
from ..db.models import Source
from ..errors import AppError
from ..errors import ErrorCategory
from .base import ServiceBase
logger = logging.getLogger(__name__)
class JobDeleteBlockedError(AppError):
"""Raised when a job delete operation is blocked by lifecycle policy."""
@@ -253,6 +258,81 @@ class JobService(ServiceBase):
await _session.delete(job)
await self._finalize(session=_session, caller_session=session)
async def delete_job_and_evidence(self, *, job_id: UUID) -> None:
"""Explicitly delete a terminal job and all evidence owned by its attempts."""
external_references: list[str] = []
async with self._session_scope() as session:
job = (
await session.exec(
select(Job)
.options(selectinload(Job.job_sources))
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
).first()
if job is None:
raise self._not_found(job_id)
if job.status == JobStatus.PROCESSING:
raise JobDeleteBlockedError(
"Job delete blocked while status is processing",
category=ErrorCategory.VALIDATION,
suggestion="Wait for processing to complete, or cancel it before deleting evidence.",
)
attempts = list(
(
await session.exec(
select(ExecutionAttempt).where(ExecutionAttempt.job_id == job_id)
)
).all()
)
if attempts:
attempt_ids = [attempt.id for attempt in attempts]
artifacts = list(
(
await session.exec(
select(ProcessingArtifact).where(
ProcessingArtifact.execution_attempt_id.in_(attempt_ids)
)
)
).all()
)
external_references = [
artifact.external_reference
for artifact in artifacts
if artifact.external_reference is not None
]
for artifact in artifacts:
await session.delete(artifact)
await session.flush()
for attempt in attempts:
await session.delete(attempt)
await session.flush()
for job_source in list(job.job_sources):
await session.delete(job_source)
await session.flush()
await session.delete(job)
await self._finalize(session=session, caller_session=None)
for external_reference in external_references:
self._delete_external_artifact(external_reference)
def _delete_external_artifact(self, external_reference: str) -> None:
relative_path = Path(external_reference)
if relative_path.is_absolute() or ".." in relative_path.parts:
logger.warning("Skipped unsafe external artifact reference during job deletion: %s", external_reference)
return
artifact_root = self.settings.artifact_dir.resolve()
artifact_path = (artifact_root / relative_path).resolve()
if artifact_root not in artifact_path.parents:
logger.warning("Skipped external artifact outside configured root: %s", external_reference)
return
try:
artifact_path.unlink(missing_ok=True)
except OSError:
logger.warning("Failed to delete external artifact: %s", artifact_path)
async def cancel_job(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
"""Cancel a queued/processing job and stop remaining source work."""
async with self._session_scope(session) as _session:
+1 -1
View File
@@ -1155,7 +1155,7 @@ def handle_transcription_errors():
) from exc
except ProviderError as exc:
raise TranscriptionError(
"Provider transcription failed",
f"Provider transcription failed: {exc}",
category=ErrorCategory.EXTERNAL_PROVIDER,
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
retriable=True,
+9 -4
View File
@@ -330,15 +330,20 @@ def register_page() -> None: # noqa: PLR0915
)
return
ui.label("This action permanently deletes the job.").classes("text-xs ui-text-danger font-medium")
ui.label("This action permanently deletes the job and its immutable execution evidence.").classes(
"text-xs ui-text-danger font-medium"
)
if job.job_sources:
ui.label("Related JobSource links will be removed as part of delete.").classes(
ui.label(
"Related JobSource links, execution attempts, transport responses, and attempt artifacts "
"will be removed. Source records and files remain until deleted separately."
).classes(
"text-xs ui-text-muted"
)
async def submit_delete() -> None:
try:
await jobs_service.delete_job_with_guardrails(job_id=job.id)
await jobs_service.delete_job_and_evidence(job_id=job.id)
except JobDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
@@ -355,7 +360,7 @@ def register_page() -> None: # noqa: PLR0915
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button(
"Delete job permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
"Delete job and evidence", on_click=submit_delete, icon="delete_forever", variant="solid"
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")