288 lines
14 KiB
Python
288 lines
14 KiB
Python
"""Capability routing: validated remote results, then an explicit local backend.
|
|
|
|
Phase E supplies HTTP adapters and injectable local contracts. Hash embeddings are
|
|
still a development placeholder; speech models are installed in phase F.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import math
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Protocol
|
|
|
|
import httpx
|
|
|
|
from app.contracts import (
|
|
EmbeddingResult, LocalBackendStatus, ModelBinding, ModelRoutingConfig,
|
|
ModelRoutingResponse, ProviderType, SpeakerMatchResult,
|
|
)
|
|
from app.database.db import connect, transaction
|
|
from app.errors import ApiError
|
|
from app.providers.base import ProviderError
|
|
from app.providers.credentials import CredentialResolver, CredentialStoreError
|
|
from app.providers.registry import ProviderNotFoundError, ProviderRegistry
|
|
from app.retrieval.embedding import EmbeddingProvider, HashEmbeddingProvider
|
|
|
|
CAPABILITIES = ("embedding", "transcription", "speaker_matching")
|
|
HTTP_TYPES = {ProviderType.openai_chat, ProviderType.openai_compatible}
|
|
MAX_MEDIA_BYTES = 25 * 1024 * 1024
|
|
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
|
|
|
|
class LocalSpeechBackend(Protocol):
|
|
available: bool
|
|
|
|
async def transcribe(self, source: Path, language: str | None) -> str: ...
|
|
|
|
async def match(self, source: Path, reference: Path) -> float: ...
|
|
|
|
|
|
class PendingSpeechBackend:
|
|
available = False
|
|
|
|
async def transcribe(self, source: Path, language: str | None) -> str:
|
|
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "本地音频转写模型尚未安装,将在阶段 F 接入。")
|
|
|
|
async def match(self, source: Path, reference: Path) -> float:
|
|
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "本地声纹模型尚未安装,将在阶段 F 接入。")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RoutedTranscript:
|
|
text: str
|
|
source: str
|
|
fallback_reason: str | None = None
|
|
|
|
|
|
def invalid_response() -> ProviderError:
|
|
return ProviderError("PROVIDER_INVALID_RESPONSE", "Model API returned an invalid result.")
|
|
|
|
|
|
def finite_number(value: object) -> bool:
|
|
if type(value) not in (int, float):
|
|
return False
|
|
try:
|
|
return math.isfinite(value)
|
|
except (OverflowError, ValueError):
|
|
return False
|
|
|
|
|
|
class ModelRoutingService:
|
|
def __init__(self, providers: ProviderRegistry, credentials: CredentialResolver, *,
|
|
local_embedding: EmbeddingProvider | None = None,
|
|
local_speech: LocalSpeechBackend | None = None,
|
|
transport: httpx.AsyncBaseTransport | None = None) -> None:
|
|
self.providers = providers
|
|
self.credentials = credentials
|
|
self.local_embedding = local_embedding or HashEmbeddingProvider()
|
|
self.local_speech = local_speech or PendingSpeechBackend()
|
|
self.transport = transport
|
|
|
|
@staticmethod
|
|
def _connection():
|
|
conn = connect()
|
|
conn.execute("CREATE TABLE IF NOT EXISTS model_routing (id INTEGER PRIMARY KEY CHECK(id=1), config_json TEXT NOT NULL)")
|
|
return conn
|
|
|
|
def configuration(self) -> ModelRoutingConfig:
|
|
conn = self._connection()
|
|
try:
|
|
row = conn.execute("SELECT config_json FROM model_routing WHERE id=1").fetchone()
|
|
return ModelRoutingConfig.model_validate_json(row[0]) if row else ModelRoutingConfig()
|
|
except ValueError as exc:
|
|
raise ApiError(500, "MODEL_ROUTING_STORAGE_INVALID", "Saved model routing could not be loaded.") from exc
|
|
finally:
|
|
conn.close()
|
|
|
|
def describe(self) -> ModelRoutingResponse:
|
|
return ModelRoutingResponse(config=self.configuration(), local_backends=[
|
|
LocalBackendStatus(capability="embedding", status="placeholder" if isinstance(self.local_embedding, HashEmbeddingProvider) else "ready",
|
|
message="当前为 hash-v1 确定性占位向量,真实本地语义模型尚未集成。" if isinstance(self.local_embedding, HashEmbeddingProvider) else "本地 Embedding 模型已就绪。"),
|
|
*[LocalBackendStatus(capability=capability, status="ready" if self.local_speech.available else "not_installed",
|
|
message="本地模型已就绪。" if self.local_speech.available else "阶段 F 接入本地模型;当前保留回退接口。")
|
|
for capability in ("transcription", "speaker_matching")],
|
|
])
|
|
|
|
def update(self, config: ModelRoutingConfig) -> ModelRoutingResponse:
|
|
for capability in CAPABILITIES:
|
|
binding = getattr(config, capability)
|
|
if binding:
|
|
try:
|
|
provider = self.providers.get_any(binding.provider_id).config
|
|
except ProviderNotFoundError as exc:
|
|
raise ApiError(422, "PROVIDER_NOT_FOUND", "请选择已保存的提供商。") from exc
|
|
if provider.provider_type not in HTTP_TYPES:
|
|
raise ApiError(422, "MODEL_ROUTING_PROTOCOL_UNSUPPORTED", "该能力当前需要 OpenAI Compatible HTTP 接口。")
|
|
conn = self._connection()
|
|
try:
|
|
with transaction(conn):
|
|
row = conn.execute("SELECT config_json FROM model_routing WHERE id=1").fetchone()
|
|
current = ModelRoutingConfig.model_validate_json(row[0]) if row else ModelRoutingConfig()
|
|
if current.version != config.version:
|
|
raise ApiError(409, "MODEL_ROUTING_VERSION_CONFLICT", "配置已更新,请重新加载后再保存。")
|
|
saved = config.model_copy(update={"version": config.version + 1})
|
|
conn.execute("INSERT OR REPLACE INTO model_routing VALUES (1, ?)", (saved.model_dump_json(),))
|
|
finally:
|
|
conn.close()
|
|
return self.describe()
|
|
|
|
def uses_provider(self, provider_id: str) -> bool:
|
|
config = self.configuration()
|
|
return any(binding and binding.provider_id == provider_id for binding in
|
|
(getattr(config, name) for name in CAPABILITIES))
|
|
|
|
def _remote(self, binding: ModelBinding) -> tuple[str, dict[str, str]]:
|
|
try:
|
|
provider = self.providers.get(binding.provider_id).config
|
|
except ProviderNotFoundError as exc:
|
|
raise ProviderError("PROVIDER_UNAVAILABLE", "Configured provider is unavailable.") from exc
|
|
if provider.provider_type not in HTTP_TYPES:
|
|
raise ProviderError("PROVIDER_CAPABILITY_UNSUPPORTED", "Provider does not support this HTTP capability.")
|
|
try:
|
|
key = self.credentials.resolve(provider.credential_id)
|
|
except CredentialStoreError as exc:
|
|
raise ProviderError("PROVIDER_CREDENTIAL_UNAVAILABLE", "Provider credential is unavailable.") from exc
|
|
if provider.credential_id and not key:
|
|
raise ProviderError("PROVIDER_CREDENTIAL_MISSING", "Provider credential is not configured.")
|
|
url = (provider.base_url or "https://api.openai.com/v1").rstrip("/") + binding.endpoint
|
|
return url, {"Authorization": f"Bearer {key}"} if key else {}
|
|
|
|
async def _request(self, binding: ModelBinding, *, remote: tuple[str, dict[str, str]] | None = None, **kwargs) -> tuple[dict, str]:
|
|
url, headers = remote or self._remote(binding)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30, transport=self.transport) as client:
|
|
async with client.stream("POST", url, headers=headers, **kwargs) as response:
|
|
response.raise_for_status()
|
|
body = bytearray()
|
|
async for chunk in response.aiter_bytes():
|
|
body.extend(chunk)
|
|
if len(body) > MAX_RESPONSE_BYTES:
|
|
raise invalid_response()
|
|
data = json.loads(body)
|
|
except httpx.TimeoutException as exc:
|
|
raise ProviderError("PROVIDER_TIMEOUT", "Model API timed out.") from exc
|
|
except httpx.HTTPStatusError as exc:
|
|
code = {401: "PROVIDER_AUTH_FAILED", 403: "PROVIDER_AUTH_FAILED", 404: "MODEL_NOT_FOUND", 429: "PROVIDER_RATE_LIMITED"}.get(exc.response.status_code, "PROVIDER_UNAVAILABLE")
|
|
raise ProviderError(code, f"Model API returned HTTP {exc.response.status_code}.") from exc
|
|
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
|
raise ProviderError("PROVIDER_UNAVAILABLE", "Model API is unavailable.") from exc
|
|
except (ValueError, UnicodeError) as exc:
|
|
raise invalid_response() from exc
|
|
if not isinstance(data, dict) or data.get("error"):
|
|
raise invalid_response()
|
|
return data, url
|
|
|
|
async def embed(self, texts: list[str]) -> EmbeddingResult:
|
|
binding = self.configuration().embedding
|
|
reason = None
|
|
if binding and texts:
|
|
try:
|
|
vectors = []
|
|
dimension = binding.dimensions
|
|
# Freeze the origin across batches, even if the user edits the provider.
|
|
remote = self._remote(binding)
|
|
for start in range(0, len(texts), 32):
|
|
batch = texts[start:start + 32]
|
|
payload = {"model": binding.model, "input": batch, "encoding_format": "float"}
|
|
if binding.dimensions is not None:
|
|
payload["dimensions"] = binding.dimensions
|
|
data, url = await self._request(binding, remote=remote, json=payload)
|
|
items = data.get("data")
|
|
if not isinstance(items, list) or len(items) != len(batch):
|
|
raise invalid_response()
|
|
indexed = {}
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
raise invalid_response()
|
|
index, vector = item.get("index"), item.get("embedding")
|
|
if type(index) is not int or index in indexed or not 0 <= index < len(batch):
|
|
raise invalid_response()
|
|
if not isinstance(vector, list) or not 1 <= len(vector) <= 16384:
|
|
raise invalid_response()
|
|
if any(not finite_number(value) for value in vector):
|
|
raise invalid_response()
|
|
dimension = dimension or len(vector)
|
|
norm = math.hypot(*vector)
|
|
if len(vector) != dimension or not norm or not math.isfinite(norm):
|
|
raise invalid_response()
|
|
indexed[index] = [value / norm for value in vector]
|
|
vectors.extend(indexed[index] for index in range(len(batch)))
|
|
identity = json.dumps([url, binding.model, dimension], separators=(",", ":"))
|
|
return EmbeddingResult(vectors=vectors, source="api", dimensions=dimension,
|
|
model_id="api-" + hashlib.sha256(identity.encode()).hexdigest())
|
|
except ProviderError as exc:
|
|
reason = exc.code
|
|
vectors = await self.local_embedding.embed_documents(texts)
|
|
return EmbeddingResult(vectors=vectors, source="local", model_id=self.local_embedding.model_id,
|
|
dimensions=self.local_embedding.dim, fallback_reason=reason)
|
|
|
|
@staticmethod
|
|
def _media_file(path: Path):
|
|
try:
|
|
handle = path.open("rb")
|
|
except OSError as exc:
|
|
raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Audio attachment was not found.") from exc
|
|
import os
|
|
if not 0 < os.fstat(handle.fileno()).st_size <= MAX_MEDIA_BYTES:
|
|
handle.close()
|
|
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Audio attachment must be between 1 byte and 25 MiB.")
|
|
return handle
|
|
|
|
async def transcribe(self, source: Path, language: str | None) -> RoutedTranscript:
|
|
binding = self.configuration().transcription
|
|
if binding is None:
|
|
with self._media_file(source):
|
|
pass
|
|
reason = None
|
|
if binding:
|
|
try:
|
|
fields = {"model": binding.model}
|
|
if language:
|
|
fields["language"] = language
|
|
with self._media_file(source) as handle:
|
|
data, _ = await self._request(binding, data=fields,
|
|
files={"file": (source.name, handle, "application/octet-stream")})
|
|
text = data.get("text")
|
|
if not isinstance(text, str) or not text.strip():
|
|
raise invalid_response()
|
|
return RoutedTranscript(text=text, source="api")
|
|
except ProviderError as exc:
|
|
reason = exc.code
|
|
try:
|
|
text = await self.local_speech.transcribe(source, language)
|
|
if not isinstance(text, str) or not text.strip():
|
|
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "Local transcription was empty.")
|
|
return RoutedTranscript(text=text, source="local", fallback_reason=reason)
|
|
except ProviderError as exc:
|
|
raise ApiError(503, exc.code, exc.message, {"fallback_reason": reason}) from exc
|
|
|
|
async def match_speakers(self, source: Path, reference: Path) -> SpeakerMatchResult:
|
|
binding = self.configuration().speaker_matching
|
|
if binding is None:
|
|
with self._media_file(source), self._media_file(reference):
|
|
pass
|
|
reason = None
|
|
if binding:
|
|
try:
|
|
# Explicit application contract, not an OpenAI-standard endpoint.
|
|
with self._media_file(source) as audio, self._media_file(reference) as sample:
|
|
data, _ = await self._request(binding, data={"model": binding.model}, files={
|
|
"file": (source.name, audio, "application/octet-stream"),
|
|
"reference_file": (reference.name, sample, "application/octet-stream"),
|
|
})
|
|
score = data.get("score")
|
|
if not finite_number(score) or not 0 <= score <= 1:
|
|
raise invalid_response()
|
|
return SpeakerMatchResult(score=score, source="api")
|
|
except ProviderError as exc:
|
|
reason = exc.code
|
|
try:
|
|
score = await self.local_speech.match(source, reference)
|
|
if not finite_number(score) or not 0 <= score <= 1:
|
|
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "Local speaker matching was invalid.")
|
|
return SpeakerMatchResult(score=score, source="local", fallback_reason=reason)
|
|
except ProviderError as exc:
|
|
raise ApiError(503, exc.code, exc.message, {"fallback_reason": reason}) from exc
|