Step 3 partially implemented.

This commit is contained in:
Jim Lancaster
2026-06-24 16:59:33 -05:00
parent 8c4a82ec35
commit c6d95f5e73
8 changed files with 456 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
"""Provider exports and factory for transcription adapters."""
from transcription.config import Provider, Settings, get_settings
from transcription.providers.base import (
ProviderAuthError,
ProviderError,
ProviderResponseError,
TranscriptionProvider,
TranscriptionResult,
)
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
def get_transcription_provider(*, settings: Settings | None = None) -> TranscriptionProvider:
"""Return the configured transcription provider adapter."""
runtime_settings = settings or get_settings()
if runtime_settings.provider == Provider.OPENROUTER:
return OpenRouterTranscriptionProvider(settings=runtime_settings)
raise ProviderError(f"Unsupported transcription provider: {runtime_settings.provider}")
__all__ = [
"ProviderAuthError",
"ProviderError",
"ProviderResponseError",
"TranscriptionProvider",
"TranscriptionResult",
"OpenRouterTranscriptionProvider",
"get_transcription_provider",
]
+32
View File
@@ -0,0 +1,32 @@
"""Provider interfaces and shared types for transcription adapters."""
from dataclasses import dataclass
from typing import Protocol
class ProviderError(RuntimeError):
"""Base error for provider failures."""
class ProviderAuthError(ProviderError):
"""Raised when provider authentication fails."""
class ProviderResponseError(ProviderError):
"""Raised when provider responses are malformed or unusable."""
@dataclass(frozen=True)
class TranscriptionResult:
"""Normalized output returned by any transcription provider."""
text: str
provider: str
model: str
class TranscriptionProvider(Protocol):
"""Contract every transcription provider adapter must satisfy."""
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
"""Transcribe the provided image according to the prompt text."""
+131
View File
@@ -0,0 +1,131 @@
"""OpenRouter transcription provider adapter."""
from __future__ import annotations
import base64
import logging
from dataclasses import dataclass
from typing import Any
from openrouter import OpenRouter
from transcription.config import Settings, get_settings
from transcription.providers.base import (
ProviderAuthError,
ProviderError,
ProviderResponseError,
TranscriptionResult,
)
logger = logging.getLogger(__name__)
DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"
@dataclass(frozen=True)
class OpenRouterRequest:
"""Normalized request payload fields for OpenRouter calls."""
model: str
messages: list[dict[str, Any]]
http_referer: str | None
x_open_router_title: str | None
class OpenRouterTranscriptionProvider:
"""Adapter that performs image transcription through OpenRouter."""
def __init__(self, *, settings: Settings | None = None, client: OpenRouter | None = None):
self._settings = settings or get_settings()
self._model = self._settings.provider_model or DEFAULT_OPENROUTER_MODEL
self._client = client or OpenRouter(api_key=self._settings.openrouter_api_key)
@property
def model(self) -> str:
"""Return the resolved OpenRouter model slug."""
return self._model
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
"""Send prompt + image to OpenRouter and return normalized text output."""
request = self._build_request(prompt_text=prompt_text, image_bytes=image_bytes, mime_type=mime_type)
try:
response = self._client.chat.send(
messages=request.messages,
model=request.model,
http_referer=request.http_referer,
x_open_router_title=request.x_open_router_title,
)
except Exception as exc: # noqa: BLE001
message = str(exc).lower()
if "401" in message or "auth" in message or "api key" in message:
raise ProviderAuthError("OpenRouter authentication failed") from exc
raise ProviderError("OpenRouter request failed") from exc
text = self._extract_text(response)
model = self._get_optional_attr(response, "model") or self.model
logger.info("OpenRouter transcription completed using model=%s", model)
return TranscriptionResult(text=text, provider="openrouter", model=model)
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
image_b64 = base64.b64encode(image_bytes).decode("ascii")
data_url = f"data:{mime_type};base64,{image_b64}"
messages: list[dict[str, Any]] = [
{
"role": "user",
"content": [
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
]
return OpenRouterRequest(
model=self.model,
messages=messages,
http_referer=self._settings.openrouter_http_referer,
x_open_router_title=self._settings.openrouter_app_title,
)
def _extract_text(self, response: Any) -> str:
choices = self._get_optional_attr(response, "choices")
if not choices:
raise ProviderResponseError("OpenRouter response missing choices")
first_choice = choices[0]
message = self._get_optional_attr(first_choice, "message")
if message is None:
raise ProviderResponseError("OpenRouter response missing assistant message")
content = self._get_optional_attr(message, "content")
text = self._normalize_content(content)
if not text:
raise ProviderResponseError("OpenRouter response contained no transcription text")
return text
def _normalize_content(self, content: Any) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
text_part = None
if isinstance(item, dict):
text_part = item.get("text")
else:
text_part = self._get_optional_attr(item, "text")
if isinstance(text_part, str) and text_part.strip():
parts.append(text_part.strip())
return "\n".join(parts).strip()
return ""
@staticmethod
def _get_optional_attr(obj: Any, key: str) -> Any:
if obj is None:
return None
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
+19
View File
@@ -0,0 +1,19 @@
"""Service layer exports."""
from transcription.services.transcription import (
DEFAULT_PROMPT_FILE,
PromptLoadError,
TranscriptionError,
load_image_payload,
load_prompt_text,
transcribe_document_image,
)
__all__ = [
"DEFAULT_PROMPT_FILE",
"PromptLoadError",
"TranscriptionError",
"load_image_payload",
"load_prompt_text",
"transcribe_document_image",
]
@@ -0,0 +1,87 @@
"""Prompt loading and provider-backed transcription service."""
from __future__ import annotations
import logging
import mimetypes
from pathlib import Path
from transcription.config import Settings, get_settings
from transcription.providers import ProviderError, TranscriptionProvider, TranscriptionResult, get_transcription_provider
logger = logging.getLogger(__name__)
DEFAULT_PROMPT_FILE = "transcribe_document.md"
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
class PromptLoadError(RuntimeError):
"""Raised when prompt artifacts cannot be loaded safely."""
class TranscriptionError(RuntimeError):
"""Raised when transcription execution fails."""
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
"""Load and validate prompt text from PROMPT_DIR."""
runtime_settings = settings or get_settings()
prompt_path = runtime_settings.prompt_dir / prompt_name
if not prompt_path.exists() or not prompt_path.is_file():
raise PromptLoadError(f"Prompt file not found: {prompt_path}")
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
if not prompt_text:
raise PromptLoadError(f"Prompt file is empty: {prompt_path}")
logger.info("Loaded prompt artifact: %s", prompt_path)
return prompt_text
def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
"""Read image bytes and detect mime type for supported uploads."""
path = Path(image_path)
if not path.exists() or not path.is_file():
raise TranscriptionError(f"Image file not found: {path}")
suffix = path.suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise TranscriptionError(f"Unsupported file type: {suffix}")
mime_type, _ = mimetypes.guess_type(path.name)
if suffix in {".tif", ".tiff"}:
mime_type = "image/tiff"
if not mime_type:
raise TranscriptionError(f"Unable to determine MIME type for: {path}")
return path.read_bytes(), mime_type
def transcribe_document_image(
image_path: str | Path,
*,
prompt_name: str = DEFAULT_PROMPT_FILE,
settings: Settings | None = None,
provider: TranscriptionProvider | None = None,
) -> TranscriptionResult:
"""Transcribe a local image using the configured prompt and provider."""
runtime_settings = settings or get_settings()
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
image_bytes, mime_type = load_image_payload(image_path)
adapter = provider or get_transcription_provider(settings=runtime_settings)
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
try:
result = adapter.transcribe(
prompt_text=prompt_text,
image_bytes=image_bytes,
mime_type=mime_type,
)
except ProviderError as exc:
raise TranscriptionError("Provider transcription failed") from exc
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
return result