Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- shared/ = portable Android-origin sources vendored from deferred/desktop-server (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only) - backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the old checkout, PYTHONPATH pins THIS backend's code over any editable install - repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh watches shared/ + backend/ - Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010, self-migrates, /healthz ok
This commit is contained in:
commit
76c867fca4
136 changed files with 21099 additions and 0 deletions
160
backend/shonar/services/ai/__init__.py
Normal file
160
backend/shonar/services/ai/__init__.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""AI provider interfaces (M7).
|
||||
|
||||
Two independent axes, both optional and both configured only through
|
||||
environment variables (never hard-coded keys):
|
||||
|
||||
- transcription: none | whisper_http | faster_whisper
|
||||
- LLM (summary/action items): none | openai_compat | ollama
|
||||
|
||||
"none" is a first-class choice: recording, sync, playback, and manual
|
||||
transcripts work with no AI configured at all. The pipeline treats a
|
||||
missing provider as "skip this stage", never as an error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
|
||||
from shonar.core.config import Settings
|
||||
|
||||
|
||||
class AIError(Exception):
|
||||
"""Base for AI failures. Messages must be user-safe: they surface in
|
||||
``processing_error`` and therefore on screens."""
|
||||
|
||||
|
||||
class ProviderConfigError(AIError):
|
||||
"""Persistent misconfiguration (bad credentials, unknown model, missing
|
||||
dependency). Fails the job immediately — retrying cannot help."""
|
||||
|
||||
|
||||
class ProviderTransientError(AIError):
|
||||
"""May succeed on retry (timeouts, 429/5xx). The worker requeues these
|
||||
up to the job's max attempts."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Segment:
|
||||
start: float
|
||||
end: float
|
||||
text: str
|
||||
speaker: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TranscriptResult:
|
||||
text: str
|
||||
language: str | None
|
||||
segments: list[Segment] = field(default_factory=list)
|
||||
model: str = ""
|
||||
|
||||
|
||||
class TranscriptionProvider(Protocol):
|
||||
name: str
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
audio: bytes,
|
||||
mime: str,
|
||||
*,
|
||||
language_hint: str | None = None,
|
||||
on_progress=None, # optional Callable[[int], None], 0..99
|
||||
) -> TranscriptResult: ...
|
||||
|
||||
|
||||
SUMMARY_KEYS = ("short", "detailed", "key_points", "decisions", "action_items", "questions")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SummaryResult:
|
||||
short: str = ""
|
||||
detailed: str = ""
|
||||
key_points: tuple[str, ...] = ()
|
||||
decisions: tuple[str, ...] = ()
|
||||
action_items: tuple[str, ...] = ()
|
||||
questions: tuple[str, ...] = ()
|
||||
model: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"short": self.short,
|
||||
"detailed": self.detailed,
|
||||
"key_points": list(self.key_points),
|
||||
"decisions": list(self.decisions),
|
||||
"action_items": list(self.action_items),
|
||||
"questions": list(self.questions),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict, model: str = "") -> SummaryResult:
|
||||
def text(key: str) -> str:
|
||||
v = raw.get(key)
|
||||
return v if isinstance(v, str) else ""
|
||||
|
||||
def strs(key: str) -> tuple[str, ...]:
|
||||
v = raw.get(key)
|
||||
if not isinstance(v, list):
|
||||
return ()
|
||||
return tuple(s for s in v if isinstance(s, str) and s.strip())
|
||||
|
||||
return cls(
|
||||
short=text("short"),
|
||||
detailed=text("detailed"),
|
||||
key_points=strs("key_points"),
|
||||
decisions=strs("decisions"),
|
||||
action_items=strs("action_items"),
|
||||
questions=strs("questions"),
|
||||
model=model,
|
||||
)
|
||||
|
||||
|
||||
class LlmProvider(Protocol):
|
||||
name: str
|
||||
|
||||
async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: ...
|
||||
|
||||
|
||||
def get_transcription_provider(settings: Settings) -> TranscriptionProvider | None:
|
||||
"""None means "transcription stage skipped", never an error."""
|
||||
kind = settings.transcription_provider.strip().lower()
|
||||
if kind in ("", "none"):
|
||||
return None
|
||||
if kind == "whisper_http":
|
||||
from shonar.services.ai.whisper_http import WhisperHttpProvider
|
||||
|
||||
return WhisperHttpProvider(
|
||||
base_url=settings.transcription_base_url,
|
||||
model=settings.transcription_model,
|
||||
api_key=settings.transcription_api_key,
|
||||
)
|
||||
if kind == "faster_whisper":
|
||||
from shonar.services.ai.faster_whisper import FasterWhisperProvider
|
||||
|
||||
return FasterWhisperProvider(model=settings.transcription_model)
|
||||
raise ProviderConfigError(
|
||||
f"Unknown transcription provider: {settings.transcription_provider!r}"
|
||||
)
|
||||
|
||||
|
||||
def get_llm_provider(settings: Settings) -> LlmProvider | None:
|
||||
"""None means "summary stage skipped", never an error."""
|
||||
kind = settings.llm_provider.strip().lower()
|
||||
if kind in ("", "none"):
|
||||
return None
|
||||
if kind == "openai_compat":
|
||||
from shonar.services.ai.openai_compat import OpenAICompatProvider
|
||||
|
||||
return OpenAICompatProvider(
|
||||
base_url=settings.llm_base_url,
|
||||
model=settings.llm_model,
|
||||
api_key=settings.llm_api_key,
|
||||
)
|
||||
if kind == "ollama":
|
||||
from shonar.services.ai.ollama import OllamaProvider
|
||||
|
||||
return OllamaProvider(
|
||||
base_url=settings.llm_base_url,
|
||||
model=settings.llm_model,
|
||||
)
|
||||
raise ProviderConfigError(f"Unknown LLM provider: {settings.llm_provider!r}")
|
||||
35
backend/shonar/services/ai/_llm.py
Normal file
35
backend/shonar/services/ai/_llm.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Shared summary contract: one system prompt, one JSON shape.
|
||||
|
||||
The model replies with JSON only; partial replies are accepted and missing
|
||||
keys default to empty (a terse-but-valid summary beats a failed job).
|
||||
Transcript input is truncated to bound context — a way in, not a report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shonar.services.ai import SummaryResult
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You summarize voice recordings for the speaker's own later reference. "
|
||||
"Reply with JSON only, exactly these keys: "
|
||||
'{"short": "1-2 sentences", "detailed": "a faithful paragraph", '
|
||||
'"key_points": [], "decisions": [], "action_items": [], "questions": []}. '
|
||||
"Empty arrays when absent. Never invent names, dates, or commitments "
|
||||
"not stated in the transcript."
|
||||
)
|
||||
|
||||
MAX_TRANSCRIPT_CHARS = 12_000
|
||||
|
||||
|
||||
def build_user_message(transcript: str, title: str | None) -> str:
|
||||
text = transcript[:MAX_TRANSCRIPT_CHARS]
|
||||
if len(transcript) > MAX_TRANSCRIPT_CHARS:
|
||||
text += f"\n\n[truncated from {len(transcript)} chars]"
|
||||
head = f'Title: "{title}"\n\n' if title else ""
|
||||
return head + "Transcript:\n" + text
|
||||
|
||||
|
||||
def parse_summary(data: object, model: str) -> SummaryResult:
|
||||
if not isinstance(data, dict):
|
||||
return SummaryResult(model=model)
|
||||
return SummaryResult.from_dict(data, model=model)
|
||||
108
backend/shonar/services/ai/faster_whisper.py
Normal file
108
backend/shonar/services/ai/faster_whisper.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Local transcription via faster-whisper (optional dependency).
|
||||
|
||||
Runs fully on this machine: audio never leaves the server for this stage.
|
||||
The import is lazy so the base install (and every test run) works without
|
||||
the heavyweight dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from shonar.services.ai import ProviderConfigError, Segment, TranscriptResult
|
||||
from shonar.services.ai.model_registry import (
|
||||
download_instructions,
|
||||
is_model_downloaded,
|
||||
validate_model_name,
|
||||
)
|
||||
|
||||
# One loaded model per name, shared across jobs in this worker process.
|
||||
# ctranslate2 inference is thread-safe; a lock serializes first-load only.
|
||||
_MODEL_CACHE: dict[str, object] = {}
|
||||
_MODEL_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _load_model(name: str) -> object:
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
with _MODEL_CACHE_LOCK:
|
||||
model = _MODEL_CACHE.get(name)
|
||||
if model is None:
|
||||
model = WhisperModel(name, device="auto")
|
||||
_MODEL_CACHE[name] = model
|
||||
return model
|
||||
|
||||
|
||||
def ensure_model_available(name: str) -> None:
|
||||
"""Fail fast with download instructions instead of triggering a surprise
|
||||
multi-GB download inside a transcription job."""
|
||||
if not is_model_downloaded(name):
|
||||
raise ProviderConfigError(download_instructions(name))
|
||||
|
||||
|
||||
class FasterWhisperProvider:
|
||||
name = "faster_whisper"
|
||||
|
||||
def __init__(self, model: str = "base") -> None:
|
||||
try:
|
||||
import faster_whisper # noqa: F401
|
||||
except ImportError as e:
|
||||
raise ProviderConfigError(
|
||||
"faster-whisper is not installed (pip install shonar-backend[faster-whisper])."
|
||||
) from e
|
||||
self.model = validate_model_name(model or "base")
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
audio: bytes,
|
||||
mime: str,
|
||||
*,
|
||||
language_hint: str | None = None,
|
||||
on_progress=None, # Callable[[int], None] | None — 0..99 percent
|
||||
) -> TranscriptResult:
|
||||
# faster-whisper is blocking CPU work: keep it off the event loop.
|
||||
return await asyncio.to_thread(self._run, audio, language_hint, on_progress)
|
||||
|
||||
def _run(
|
||||
self,
|
||||
audio: bytes,
|
||||
language_hint: str | None,
|
||||
on_progress=None,
|
||||
) -> TranscriptResult:
|
||||
path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".m4a", delete=False) as f:
|
||||
f.write(audio)
|
||||
path = Path(f.name)
|
||||
ensure_model_available(self.model)
|
||||
model = _load_model(self.model)
|
||||
segments_iter, info = model.transcribe( # type: ignore[union-attr]
|
||||
str(path),
|
||||
beam_size=5,
|
||||
language=language_hint,
|
||||
)
|
||||
duration = float(getattr(info, "duration", 0.0) or 0.0)
|
||||
segments = []
|
||||
last_pct = -1
|
||||
for s in segments_iter:
|
||||
segments.append(Segment(start=s.start, end=s.end, text=s.text.strip()))
|
||||
if on_progress is not None and duration > 0:
|
||||
# Throttle: report only on whole-percent gains. Capped
|
||||
# at 99 — the caller commits 100 when the row finishes.
|
||||
pct = min(99, int(s.end / duration * 100))
|
||||
if pct > last_pct:
|
||||
last_pct = pct
|
||||
on_progress(pct)
|
||||
text = " ".join(s.text for s in segments).strip()
|
||||
return TranscriptResult(
|
||||
text=text,
|
||||
language=getattr(info, "language", None),
|
||||
segments=segments,
|
||||
model=self.model,
|
||||
)
|
||||
finally:
|
||||
if path is not None:
|
||||
path.unlink(missing_ok=True)
|
||||
165
backend/shonar/services/ai/model_registry.py
Normal file
165
backend/shonar/services/ai/model_registry.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""Transcription model registry (Stage 1).
|
||||
|
||||
The supported faster-whisper sizes, their display metadata, validation, and
|
||||
local availability checks. Nothing here downloads anything: faster-whisper
|
||||
fetches from HuggingFace on first use, so "downloaded" is answered by
|
||||
inspecting the HF hub cache, and "available" additionally requires the
|
||||
faster-whisper package itself.
|
||||
|
||||
No silent substitution anywhere: unknown names are rejected, missing
|
||||
downloads fail fast with instructions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shonar.db.models import AppSetting
|
||||
from shonar.services.ai import ProviderConfigError
|
||||
|
||||
DEFAULT_MODEL = "base"
|
||||
DEFAULT_MODEL_KEY = "transcription.default_model"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TranscriptionModelInfo:
|
||||
name: str
|
||||
display_name: str
|
||||
description: str
|
||||
params: str
|
||||
approx_memory: str
|
||||
relative_speed: str
|
||||
|
||||
|
||||
SUPPORTED_TRANSCRIPTION_MODELS: dict[str, TranscriptionModelInfo] = {
|
||||
"tiny": TranscriptionModelInfo(
|
||||
name="tiny",
|
||||
display_name="Tiny",
|
||||
description="Fastest and lightest. Good for quick drafts and slow machines.",
|
||||
params="~39M",
|
||||
approx_memory="~1 GB RAM",
|
||||
relative_speed="~10x real-time (CPU)",
|
||||
),
|
||||
"base": TranscriptionModelInfo(
|
||||
name="base",
|
||||
display_name="Base (default)",
|
||||
description="Balanced default. Works reasonably well on ordinary computers.",
|
||||
params="~74M",
|
||||
approx_memory="~1 GB RAM",
|
||||
relative_speed="~7x real-time (CPU)",
|
||||
),
|
||||
"small": TranscriptionModelInfo(
|
||||
name="small",
|
||||
display_name="Small",
|
||||
description="Better accuracy with higher resource usage.",
|
||||
params="~244M",
|
||||
approx_memory="~2 GB RAM",
|
||||
relative_speed="~4x real-time (CPU)",
|
||||
),
|
||||
"medium": TranscriptionModelInfo(
|
||||
name="medium",
|
||||
display_name="Medium",
|
||||
description="Higher accuracy and slower performance.",
|
||||
params="~769M",
|
||||
approx_memory="~5 GB RAM",
|
||||
relative_speed="~2x real-time (CPU)",
|
||||
),
|
||||
"large-v3": TranscriptionModelInfo(
|
||||
name="large-v3",
|
||||
display_name="Large v3",
|
||||
description="Highest accuracy and greatest resource requirements.",
|
||||
params="~1.5B",
|
||||
approx_memory="~10 GB RAM",
|
||||
relative_speed="~1x real-time (CPU)",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def normalize_model_name(raw: str | None) -> str:
|
||||
"""Case/whitespace-tolerant normalization. Never maps one model to another."""
|
||||
return (raw or "").strip().lower()
|
||||
|
||||
|
||||
def validate_model_name(raw: str | None) -> str:
|
||||
"""Return the normalized name, or raise with a helpful message."""
|
||||
name = normalize_model_name(raw)
|
||||
if name in SUPPORTED_TRANSCRIPTION_MODELS:
|
||||
return name
|
||||
supported = ", ".join(sorted(SUPPORTED_TRANSCRIPTION_MODELS))
|
||||
raise ProviderConfigError(
|
||||
f"Unsupported transcription model {raw!r}. Supported models: {supported}. "
|
||||
"Check the spelling — a different model is never substituted silently."
|
||||
)
|
||||
|
||||
|
||||
def _hf_hub_cache() -> Path:
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
|
||||
return Path(HF_HUB_CACHE)
|
||||
except ImportError:
|
||||
return Path(
|
||||
os.environ.get("HF_HUB_CACHE", str(Path.home() / ".cache" / "huggingface" / "hub"))
|
||||
)
|
||||
|
||||
|
||||
def is_model_downloaded(name: str) -> bool:
|
||||
"""True when a non-empty faster-whisper snapshot for `name` sits in the
|
||||
HuggingFace hub cache (repo Systran/faster-whisper-<name>)."""
|
||||
repo_dir = _hf_hub_cache() / f"models--Systran--faster-whisper-{name}"
|
||||
snapshots = repo_dir / "snapshots"
|
||||
if not snapshots.is_dir():
|
||||
return False
|
||||
return any(s.is_dir() and any(s.iterdir()) for s in snapshots.iterdir())
|
||||
|
||||
|
||||
def is_faster_whisper_installed() -> bool:
|
||||
try:
|
||||
import faster_whisper # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def download_instructions(name: str) -> str:
|
||||
return (
|
||||
f'Model "{name}" is not downloaded. Download it with: '
|
||||
f"POST /api/v1/models/{name}/download "
|
||||
"(needs internet once), or run any transcription with that model selected — "
|
||||
"faster-whisper fetches it from HuggingFace automatically."
|
||||
)
|
||||
|
||||
|
||||
async def get_global_default_model(session: AsyncSession) -> str:
|
||||
row = await session.scalar(select(AppSetting).where(AppSetting.key == DEFAULT_MODEL_KEY))
|
||||
if row is None:
|
||||
return DEFAULT_MODEL
|
||||
model = row.value.get("model") if isinstance(row.value, dict) else None
|
||||
return model if model in SUPPORTED_TRANSCRIPTION_MODELS else DEFAULT_MODEL
|
||||
|
||||
|
||||
async def set_global_default_model(session: AsyncSession, raw: str) -> str:
|
||||
"""Validate + persist the global default. Affects future recordings only —
|
||||
existing rows keep their saved model."""
|
||||
name = validate_model_name(raw)
|
||||
row = await session.scalar(select(AppSetting).where(AppSetting.key == DEFAULT_MODEL_KEY))
|
||||
if row is None:
|
||||
row = AppSetting(key=DEFAULT_MODEL_KEY, value={"model": name})
|
||||
session.add(row)
|
||||
else:
|
||||
row.value = {"model": name}
|
||||
await session.flush()
|
||||
return name
|
||||
|
||||
|
||||
def effective_model(recording_model: str | None, global_default: str) -> str:
|
||||
"""Per-recording override wins; otherwise the global default."""
|
||||
if recording_model and recording_model in SUPPORTED_TRANSCRIPTION_MODELS:
|
||||
return recording_model
|
||||
return global_default
|
||||
78
backend/shonar/services/ai/ollama.py
Normal file
78
backend/shonar/services/ai/ollama.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Summaries via a local Ollama server (`/api/chat`, JSON mode)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
|
||||
from shonar.services.ai import ProviderConfigError, ProviderTransientError, SummaryResult
|
||||
from shonar.services.ai._llm import SYSTEM_PROMPT, build_user_message, parse_summary
|
||||
|
||||
|
||||
class OllamaProvider:
|
||||
name = "ollama"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
model: str = "",
|
||||
timeout_s: float = 900.0,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
if not base_url.strip():
|
||||
raise ProviderConfigError("ollama needs SHONAR_LLM_BASE_URL.")
|
||||
if not model.strip():
|
||||
raise ProviderConfigError("ollama needs SHONAR_LLM_MODEL.")
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.timeout_s = timeout_s
|
||||
self.http_client = http_client
|
||||
|
||||
@asynccontextmanager
|
||||
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||
if self.http_client is not None:
|
||||
yield self.http_client
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
|
||||
yield client
|
||||
|
||||
async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
# qwen3-family models "think" by default: a long reasoning
|
||||
# chain before the JSON answer, brutally slow on CPU and it
|
||||
# does not improve the summary. Ask for the answer directly
|
||||
# (ignored by non-thinking models).
|
||||
"think": False,
|
||||
"options": {"num_ctx": 8192},
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_user_message(transcript, title)},
|
||||
],
|
||||
}
|
||||
try:
|
||||
async with self._client() as client:
|
||||
resp = await client.post(f"{self.base_url}/api/chat", json=payload)
|
||||
except (httpx.TimeoutException, httpx.TransportError) as e:
|
||||
raise ProviderTransientError(f"Ollama unreachable: {type(e).__name__}") from e
|
||||
if resp.status_code == 404:
|
||||
# Missing model and missing route both 404 here; both are
|
||||
# configuration, not weather.
|
||||
raise ProviderConfigError("Ollama has no such model or route (HTTP 404).")
|
||||
if resp.status_code != 200:
|
||||
raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).")
|
||||
try:
|
||||
content = resp.json()["message"]["content"]
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
raise ProviderTransientError("Ollama sent an unreadable reply.") from e
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
data = _json.loads(content)
|
||||
except ValueError as e:
|
||||
raise ProviderTransientError("Ollama reply was not JSON.") from e
|
||||
return parse_summary(data, self.model)
|
||||
82
backend/shonar/services/ai/openai_compat.py
Normal file
82
backend/shonar/services/ai/openai_compat.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Summaries via any OpenAI-compatible chat endpoint (self-hosted
|
||||
vLLM/llama.cpp server, commercial API, …) with JSON mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
|
||||
from shonar.services.ai import ProviderConfigError, ProviderTransientError, SummaryResult
|
||||
from shonar.services.ai._llm import SYSTEM_PROMPT, build_user_message, parse_summary
|
||||
|
||||
|
||||
class OpenAICompatProvider:
|
||||
name = "openai_compat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
model: str = "",
|
||||
api_key: str = "",
|
||||
timeout_s: float = 180.0,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
if not base_url.strip():
|
||||
raise ProviderConfigError("openai_compat needs SHONAR_LLM_BASE_URL.")
|
||||
if not model.strip():
|
||||
raise ProviderConfigError("openai_compat needs SHONAR_LLM_MODEL.")
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.timeout_s = timeout_s
|
||||
self.http_client = http_client
|
||||
|
||||
@asynccontextmanager
|
||||
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||
if self.http_client is not None:
|
||||
yield self.http_client
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
|
||||
yield client
|
||||
|
||||
async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult:
|
||||
headers = (
|
||||
{"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
)
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"temperature": 0.2,
|
||||
"response_format": {"type": "json_object"},
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_user_message(transcript, title)},
|
||||
],
|
||||
}
|
||||
try:
|
||||
async with self._client() as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/v1/chat/completions", headers=headers, json=payload
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.TransportError) as e:
|
||||
raise ProviderTransientError(f"LLM unreachable: {type(e).__name__}") from e
|
||||
if resp.status_code in (401, 403, 404):
|
||||
raise ProviderConfigError(f"LLM refused the request (HTTP {resp.status_code}).")
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
raise ProviderTransientError(f"LLM busy (HTTP {resp.status_code}).")
|
||||
if resp.status_code != 200:
|
||||
raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).")
|
||||
try:
|
||||
body = resp.json()
|
||||
content = body["choices"][0]["message"]["content"]
|
||||
except (ValueError, KeyError, IndexError, TypeError) as e:
|
||||
raise ProviderTransientError("LLM sent an unreadable reply.") from e
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
data = _json.loads(content)
|
||||
except ValueError as e:
|
||||
raise ProviderTransientError("LLM reply was not JSON.") from e
|
||||
return parse_summary(data, self.model)
|
||||
128
backend/shonar/services/ai/whisper_http.py
Normal file
128
backend/shonar/services/ai/whisper_http.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Transcription via any OpenAI-compatible `/v1/audio/transcriptions`
|
||||
endpoint (self-hosted whisper.cpp server, commercial Whisper API, …).
|
||||
Sends `verbose_json` so segment timings come back with the text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
|
||||
from shonar.services.ai import (
|
||||
ProviderConfigError,
|
||||
ProviderTransientError,
|
||||
Segment,
|
||||
TranscriptResult,
|
||||
)
|
||||
|
||||
|
||||
class WhisperHttpProvider:
|
||||
name = "whisper_http"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
model: str = "base",
|
||||
api_key: str = "",
|
||||
timeout_s: float = 300.0,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
if not base_url.strip():
|
||||
raise ProviderConfigError(
|
||||
"whisper_http needs SHONAR_TRANSCRIPTION_BASE_URL."
|
||||
)
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.timeout_s = timeout_s
|
||||
self.http_client = http_client
|
||||
|
||||
@asynccontextmanager
|
||||
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||
if self.http_client is not None:
|
||||
yield self.http_client
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
|
||||
yield client
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
audio: bytes,
|
||||
mime: str,
|
||||
*,
|
||||
language_hint: str | None = None,
|
||||
on_progress=None, # accepted for protocol parity; not reported
|
||||
) -> TranscriptResult:
|
||||
headers = (
|
||||
{"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
)
|
||||
data: dict[str, str] = {"model": self.model, "response_format": "verbose_json"}
|
||||
if language_hint:
|
||||
data["language"] = language_hint
|
||||
files = {"file": (f"audio.{_ext(mime)}", audio, mime or "application/octet-stream")}
|
||||
try:
|
||||
async with self._client() as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/v1/audio/transcriptions",
|
||||
headers=headers,
|
||||
data=data,
|
||||
files=files,
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.TransportError) as e:
|
||||
raise ProviderTransientError(
|
||||
f"Transcription service unreachable: {type(e).__name__}"
|
||||
) from e
|
||||
if resp.status_code in (401, 403, 404):
|
||||
raise ProviderConfigError(
|
||||
f"Transcription service refused the request (HTTP {resp.status_code})."
|
||||
)
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
raise ProviderTransientError(
|
||||
f"Transcription service busy (HTTP {resp.status_code})."
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise ProviderTransientError(
|
||||
f"Transcription failed (HTTP {resp.status_code})."
|
||||
)
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError as e:
|
||||
raise ProviderTransientError("Transcription service sent no JSON.") from e
|
||||
segments = []
|
||||
raw_segs = body.get("segments")
|
||||
if isinstance(raw_segs, list):
|
||||
for s in raw_segs:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
try:
|
||||
segments.append(
|
||||
Segment(
|
||||
start=float(s.get("start", 0.0)),
|
||||
end=float(s.get("end", 0.0)),
|
||||
text=str(s.get("text", "")),
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
text = body.get("text")
|
||||
return TranscriptResult(
|
||||
text=text if isinstance(text, str) else "",
|
||||
language=body.get("language") if isinstance(body.get("language"), str) else None,
|
||||
segments=segments,
|
||||
model=self.model,
|
||||
)
|
||||
|
||||
|
||||
def _ext(mime: str) -> str:
|
||||
return {
|
||||
"audio/mp4": "m4a",
|
||||
"audio/m4a": "m4a",
|
||||
"audio/wav": "wav",
|
||||
"audio/x-wav": "wav",
|
||||
"audio/ogg": "ogg",
|
||||
"audio/opus": "ogg",
|
||||
"audio/webm": "webm",
|
||||
"audio/mpeg": "mp3",
|
||||
}.get(mime.lower().split(";")[0].strip(), "bin")
|
||||
Loading…
Add table
Add a link
Reference in a new issue