generated from john/python-template
Step 3 partially implemented.
This commit is contained in:
@@ -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.
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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."""
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user