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:
avi 2026-09-14 17:14:54 -05:00
commit 76c867fca4
136 changed files with 21099 additions and 0 deletions

View 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}")

View 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)

View 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)

View 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

View 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)

View 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)

View 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")