From c6d95f5e733cc492375e3ec2f85e587b039b044d Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:59:33 -0500 Subject: [PATCH] Step 3 partially implemented. --- prompts/system.txt | 10 ++ src/transcription/providers/__init__.py | 31 +++++ src/transcription/providers/base.py | 32 +++++ src/transcription/providers/openrouter.py | 131 ++++++++++++++++++++ src/transcription/services/__init__.py | 19 +++ src/transcription/services/transcription.py | 87 +++++++++++++ tests/providers/test_openrouter.py | 120 ++++++++++++++++++ tools/schemas/search_docs.json | 26 ++++ 8 files changed, 456 insertions(+) create mode 100644 prompts/system.txt create mode 100644 src/transcription/providers/base.py create mode 100644 src/transcription/providers/openrouter.py create mode 100644 src/transcription/services/transcription.py create mode 100644 tests/providers/test_openrouter.py create mode 100644 tools/schemas/search_docs.json diff --git a/prompts/system.txt b/prompts/system.txt new file mode 100644 index 0000000..5e777ef --- /dev/null +++ b/prompts/system.txt @@ -0,0 +1,10 @@ +You are an assistant that may call tools. + +Tool safety rules: +1) Tool arguments MUST be strict JSON matching the schema exactly. +2) Never place disallowed, sensitive, explicit, or policy-violating text directly into tool arguments. +3) If user content may be unsafe, first produce a brief neutral summary and pass only that summary. +4) Prefer IDs, enums, booleans, and short fields over raw free-form text. +5) Keep all string arguments <= 300 chars unless schema says otherwise. +6) If you cannot safely provide valid tool args, do not call the tool; respond with "NO_TOOL_CALL" and explain briefly. +7) Never include markdown/code fences in tool arguments. \ No newline at end of file diff --git a/src/transcription/providers/__init__.py b/src/transcription/providers/__init__.py index e69de29..6a5c042 100644 --- a/src/transcription/providers/__init__.py +++ b/src/transcription/providers/__init__.py @@ -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", +] diff --git a/src/transcription/providers/base.py b/src/transcription/providers/base.py new file mode 100644 index 0000000..288c2df --- /dev/null +++ b/src/transcription/providers/base.py @@ -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.""" diff --git a/src/transcription/providers/openrouter.py b/src/transcription/providers/openrouter.py new file mode 100644 index 0000000..a990b4b --- /dev/null +++ b/src/transcription/providers/openrouter.py @@ -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) diff --git a/src/transcription/services/__init__.py b/src/transcription/services/__init__.py index e69de29..0ac47be 100644 --- a/src/transcription/services/__init__.py +++ b/src/transcription/services/__init__.py @@ -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", +] diff --git a/src/transcription/services/transcription.py b/src/transcription/services/transcription.py new file mode 100644 index 0000000..fe9be20 --- /dev/null +++ b/src/transcription/services/transcription.py @@ -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 diff --git a/tests/providers/test_openrouter.py b/tests/providers/test_openrouter.py new file mode 100644 index 0000000..027daa2 --- /dev/null +++ b/tests/providers/test_openrouter.py @@ -0,0 +1,120 @@ +"""Tests for transcription.providers.openrouter.""" + +from types import SimpleNamespace + +import pytest + +from transcription.config import Settings +from transcription.providers.base import ProviderError, ProviderResponseError +from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL, OpenRouterTranscriptionProvider + + +class _FakeChat: + def __init__(self, response=None, error: Exception | None = None): + self._response = response + self._error = error + self.calls = [] + + def send(self, **kwargs): + self.calls.append(kwargs) + if self._error: + raise self._error + return self._response + + +class _FakeClient: + def __init__(self, response=None, error: Exception | None = None): + self.chat = _FakeChat(response=response, error=error) + + +@pytest.mark.unit +class TestOpenRouterProviderInit: + """Verify OpenRouter provider initialization behavior.""" + + def test_model_falls_back_to_default_when_unset(self): + """Provider uses adapter default model when provider_model is None.""" + settings = Settings(openrouter_api_key="test-key", provider_model=None) + provider = OpenRouterTranscriptionProvider(settings=settings, client=_FakeClient()) + assert provider.model == DEFAULT_OPENROUTER_MODEL + + def test_model_uses_configured_value(self): + """Provider uses configured provider_model when present.""" + settings = Settings(openrouter_api_key="test-key", provider_model="vendor/custom-model") + provider = OpenRouterTranscriptionProvider(settings=settings, client=_FakeClient()) + assert provider.model == "vendor/custom-model" + + +@pytest.mark.unit +class TestOpenRouterProviderTranscribe: + """Verify OpenRouter request construction and response parsing.""" + + def test_includes_optional_referer_and_title_when_set(self): + """Transcribe sends app attribution fields when configured.""" + response = {"model": "vendor/model-a", "choices": [{"message": {"content": "Transcript text"}}]} + client = _FakeClient(response=response) + settings = Settings( + openrouter_api_key="test-key", + openrouter_http_referer="https://example.test", + openrouter_app_title="Transcription App", + ) + provider = OpenRouterTranscriptionProvider(settings=settings, client=client) + + result = provider.transcribe( + prompt_text="Prompt body", + image_bytes=b"img-bytes", + mime_type="image/png", + ) + + send_call = client.chat.calls[0] + assert send_call["http_referer"] == "https://example.test" + assert send_call["x_open_router_title"] == "Transcription App" + assert result.text == "Transcript text" + + def test_parses_successful_response_text(self): + """Transcribe returns normalized text from a valid response payload.""" + response = { + "model": "vendor/model-b", + "choices": [{"message": {"content": [{"text": "Line 1"}, {"text": "Line 2"}]}}], + } + provider = OpenRouterTranscriptionProvider( + settings=Settings(openrouter_api_key="test-key"), + client=_FakeClient(response=response), + ) + + result = provider.transcribe( + prompt_text="Prompt body", + image_bytes=b"img-bytes", + mime_type="image/jpeg", + ) + + assert result.text == "Line 1\nLine 2" + assert result.provider == "openrouter" + assert result.model == "vendor/model-b" + + def test_maps_sdk_exception_to_provider_error(self): + """Transcribe converts SDK failures to ProviderError.""" + provider = OpenRouterTranscriptionProvider( + settings=Settings(openrouter_api_key="test-key"), + client=_FakeClient(error=RuntimeError("network down")), + ) + + with pytest.raises(ProviderError): + provider.transcribe( + prompt_text="Prompt body", + image_bytes=b"img-bytes", + mime_type="image/png", + ) + + def test_raises_on_empty_or_invalid_response(self): + """Transcribe raises ProviderResponseError for missing completion text.""" + provider = OpenRouterTranscriptionProvider( + settings=Settings(openrouter_api_key="test-key"), + client=_FakeClient(response=SimpleNamespace(choices=[])), + ) + + with pytest.raises(ProviderResponseError): + provider.transcribe( + prompt_text="Prompt body", + image_bytes=b"img-bytes", + mime_type="image/png", + ) diff --git a/tools/schemas/search_docs.json b/tools/schemas/search_docs.json new file mode 100644 index 0000000..dde2887 --- /dev/null +++ b/tools/schemas/search_docs.json @@ -0,0 +1,26 @@ +{ + "name": "search_docs", + "description": "Search internal docs with a short safe query.", + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "query": { + "type": "string", + "description": "Short neutral search phrase", + "minLength": 1, + "maxLength": 180 + }, + "scope": { + "type": "string", + "enum": ["public", "internal"] + }, + "top_k": { + "type": "integer", + "minimum": 1, + "maximum": 10 + } + }, + "required": ["query", "scope"] + } +} \ No newline at end of file