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
245
backend/tests/test_models.py
Normal file
245
backend/tests/test_models.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""Stage 1: per-recording transcription models + models API.
|
||||
|
||||
Global default (base) with optional per-recording overrides; the worker
|
||||
uses the exact saved model; history is never rewritten; unknown models
|
||||
are rejected; missing downloads fail fast with instructions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from shonar.db import session as db_session
|
||||
from shonar.db.models import Recording
|
||||
from shonar.services import processing
|
||||
|
||||
|
||||
def wav_bytes(payload_len: int = 64) -> bytes:
|
||||
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
|
||||
data = data[:payload_len]
|
||||
return (
|
||||
b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
||||
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, 8000, 8000, 1, 8)
|
||||
+ b"data" + struct.pack("<I", len(data))
|
||||
) + data
|
||||
|
||||
|
||||
AUTH = {"email": "models@example.com", "password": "models-test-passw0rd-123"}
|
||||
|
||||
|
||||
async def user_tokens(client):
|
||||
r = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": AUTH["email"], "password": AUTH["password"]},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
async def upload_recording(client, token, client_id, session_model=None, finalize_model=None):
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
data = wav_bytes()
|
||||
body = {"declared_mime_type": "audio/wav", "declared_size_bytes": len(data),
|
||||
"client_recording_id": client_id}
|
||||
if session_model is not None:
|
||||
body["transcription_model"] = session_model
|
||||
r = await client.post("/api/v1/uploads", json=body, headers=h)
|
||||
assert r.status_code == 201, r.text
|
||||
sid = r.json()["id"]
|
||||
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
|
||||
headers={**h, "content-type": "application/octet-stream"})
|
||||
assert r.status_code == 201, r.text
|
||||
fbody = {"duration_seconds": 5.0}
|
||||
if finalize_model is not None:
|
||||
fbody["transcription_model"] = finalize_model
|
||||
r = await client.post(f"/api/v1/uploads/{sid}/finalize", json=fbody, headers=h)
|
||||
return r
|
||||
|
||||
|
||||
async def test_models_endpoint_lists_five(client):
|
||||
token = await user_tokens(client)
|
||||
r = await client.get("/api/v1/models", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["default_model"] == "base"
|
||||
assert [m["name"] for m in body["models"]] == ["tiny", "base", "small", "medium", "large-v3"]
|
||||
base = next(m for m in body["models"] if m["name"] == "base")
|
||||
assert base["is_default"] is True
|
||||
assert "ordinary computers" in base["description"]
|
||||
for m in body["models"]:
|
||||
assert isinstance(m["downloaded"], bool)
|
||||
assert isinstance(m["available"], bool)
|
||||
|
||||
|
||||
async def test_default_get_put_validation(client):
|
||||
token = await user_tokens(client)
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get("/api/v1/models/default", headers=h)
|
||||
assert r.json() == {"default_model": "base"}
|
||||
r = await client.put("/api/v1/models/default", json={"model": "small"}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"default_model": "small"}
|
||||
r = await client.put("/api/v1/models/default", json={"model": "xxl-turbo"}, headers=h)
|
||||
assert r.status_code == 422
|
||||
assert "Supported models" in r.text
|
||||
# rejected change did not stick
|
||||
r = await client.get("/api/v1/models/default", headers=h)
|
||||
assert r.json() == {"default_model": "small"}
|
||||
|
||||
|
||||
async def test_finalize_override_and_default_history(client):
|
||||
token = await user_tokens(client)
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await upload_recording(client, token, "m-override-1", finalize_model="small")
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["transcription_model"] == "small"
|
||||
|
||||
r = await upload_recording(client, token, "m-default-1")
|
||||
assert r.json()["transcription_model"] == "base"
|
||||
|
||||
# Changing the default affects future rows only.
|
||||
r = await client.put("/api/v1/models/default", json={"model": "tiny"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
r = await upload_recording(client, token, "m-default-2")
|
||||
assert r.json()["transcription_model"] == "tiny"
|
||||
|
||||
async with db_session._session_factory() as s:
|
||||
got = dict((await s.execute(
|
||||
select(Recording.client_recording_id, Recording.transcription_model)
|
||||
.where(Recording.client_recording_id.in_(
|
||||
["m-override-1", "m-default-1", "m-default-2"])))
|
||||
).all())
|
||||
assert got == {"m-override-1": "small", "m-default-1": "base", "m-default-2": "tiny"}
|
||||
|
||||
|
||||
async def test_session_override_and_finalize_wins(client):
|
||||
token = await user_tokens(client)
|
||||
r = await upload_recording(client, token, "m-sess-1", session_model="small")
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["transcription_model"] == "small"
|
||||
|
||||
r = await upload_recording(client, token, "m-sess-2", session_model="small",
|
||||
finalize_model="tiny")
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["transcription_model"] == "tiny"
|
||||
|
||||
|
||||
async def test_finalize_invalid_override_rejected(client):
|
||||
token = await user_tokens(client)
|
||||
r = await upload_recording(client, token, "m-bad-1", finalize_model="xxl-turbo")
|
||||
assert r.status_code == 422
|
||||
assert "Supported models" in r.text
|
||||
|
||||
|
||||
class SpyTranscriber:
|
||||
"""Stands in for FasterWhisperProvider; records the model it was built with."""
|
||||
|
||||
name = "faster_whisper"
|
||||
seen_models: list = []
|
||||
|
||||
def __init__(self, model: str = "base"):
|
||||
type(self).seen_models.append(model)
|
||||
self.model = model
|
||||
|
||||
async def transcribe(self, audio, mime, *, language_hint=None):
|
||||
from shonar.services.ai import Segment, TranscriptResult
|
||||
|
||||
return TranscriptResult(
|
||||
text="spy text", language="en",
|
||||
segments=[Segment(0.0, 1.0, "spy text")], model=self.model,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_spy():
|
||||
SpyTranscriber.seen_models = []
|
||||
yield
|
||||
SpyTranscriber.seen_models = []
|
||||
|
||||
|
||||
async def _run_with_spy(monkeypatch, recording_id):
|
||||
import shonar.services.ai.faster_whisper as fw
|
||||
|
||||
# The worker rebuilds a faster_whisper provider from the saved model;
|
||||
# the late import inside run_transcribe picks up this spy.
|
||||
monkeypatch.setattr(fw, "FasterWhisperProvider", SpyTranscriber)
|
||||
monkeypatch.setattr(
|
||||
processing, "get_transcription_provider",
|
||||
lambda settings: SpyTranscriber(model="ignored"),
|
||||
)
|
||||
await processing.run_transcribe({}, recording_id)
|
||||
|
||||
|
||||
async def test_worker_uses_saved_model(client, monkeypatch):
|
||||
token = await user_tokens(client)
|
||||
rec = (await upload_recording(client, token, "m-work-1", finalize_model="small")).json()
|
||||
await _run_with_spy(monkeypatch, rec["id"])
|
||||
# Last construction is the worker rebuild from the saved model.
|
||||
assert SpyTranscriber.seen_models[-1] == "small"
|
||||
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["model"] == "small"
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||
job = next(j for j in r.json() if j["job_type"] == "transcribe")
|
||||
assert job["status"] == "succeeded"
|
||||
assert job["stage"] is None
|
||||
assert job["progress"] == 100
|
||||
|
||||
|
||||
async def test_worker_keeps_history_after_default_change(client, monkeypatch):
|
||||
token = await user_tokens(client)
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rec = (await upload_recording(client, token, "m-work-2")).json()
|
||||
assert rec["transcription_model"] == "base"
|
||||
r = await client.put("/api/v1/models/default", json={"model": "tiny"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
await _run_with_spy(monkeypatch, rec["id"])
|
||||
assert SpyTranscriber.seen_models[-1] == "base"
|
||||
|
||||
|
||||
async def test_unavailable_model_fails_with_instructions(client, monkeypatch):
|
||||
import shonar.services.ai.faster_whisper as fw
|
||||
from shonar.services.ai.faster_whisper import FasterWhisperProvider
|
||||
|
||||
# Real provider class, but the model is (simulated) not downloaded.
|
||||
monkeypatch.setattr(fw, "is_model_downloaded", lambda name: False)
|
||||
monkeypatch.setattr(
|
||||
processing, "get_transcription_provider",
|
||||
lambda settings: FasterWhisperProvider(model="base"),
|
||||
)
|
||||
token = await user_tokens(client)
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rec = (await upload_recording(client, token, "m-work-3")).json()
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "failed"
|
||||
assert "not downloaded" in (r.json()["processing_error"] or "")
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||
job = next(j for j in r.json() if j["job_type"] == "transcribe")
|
||||
assert job["status"] == "failed"
|
||||
assert "download" in (job["error"] or "").lower()
|
||||
|
||||
|
||||
async def test_reupload_explicit_override_updates_model(client):
|
||||
token = await user_tokens(client)
|
||||
r = await upload_recording(client, token, "m-reup-1", finalize_model="small")
|
||||
assert r.json()["transcription_model"] == "small"
|
||||
# Same client id, new explicit choice: model history moves with it.
|
||||
r = await upload_recording(client, token, "m-reup-1", finalize_model="tiny")
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["transcription_model"] == "tiny"
|
||||
# Same client id, no choice: history untouched.
|
||||
r = await upload_recording(client, token, "m-reup-1")
|
||||
assert r.json()["transcription_model"] == "tiny"
|
||||
|
||||
|
||||
async def test_models_need_auth(client):
|
||||
r = await client.get("/api/v1/models")
|
||||
assert r.status_code in (401, 403)
|
||||
Loading…
Add table
Add a link
Reference in a new issue