generated from john/python-template
V4.2 complete
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user