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
410
backend/tests/test_ai_pipeline.py
Normal file
410
backend/tests/test_ai_pipeline.py
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
"""AI pipeline tests (M7): status flow, versioning, failure modes, endpoints.
|
||||
|
||||
Provider fakes stand in for real STT/LLM services (no network, no model
|
||||
downloads); adapter wire-protocol tests live in test_ai_adapters.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from shonar.db import session as db_session
|
||||
from shonar.db.models import (
|
||||
JobStatus,
|
||||
JobType,
|
||||
ProcessingJob,
|
||||
Transcript,
|
||||
)
|
||||
from shonar.services import processing
|
||||
from shonar.services.ai import (
|
||||
ProviderConfigError,
|
||||
ProviderTransientError,
|
||||
Segment,
|
||||
SummaryResult,
|
||||
TranscriptResult,
|
||||
)
|
||||
|
||||
|
||||
def wav_bytes(payload_len: int = 64) -> bytes:
|
||||
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
|
||||
data = data[:payload_len]
|
||||
header = (
|
||||
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))
|
||||
)
|
||||
return header + data
|
||||
|
||||
|
||||
AUTH = {"email": "m7@example.com", "password": "m7-test-passw0rd-123"}
|
||||
|
||||
|
||||
async def user_tokens(client, email=AUTH["email"], password=AUTH["password"]):
|
||||
r = await client.post("/api/v1/auth/register", json={"email": email, "password": password})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
async def upload_recording(client, token, client_id=None, title="M7 standup"):
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
data = wav_bytes()
|
||||
r = await client.post(
|
||||
"/api/v1/uploads",
|
||||
json={"declared_mime_type": "audio/wav", "declared_size_bytes": len(data),
|
||||
"client_recording_id": client_id, "title": title},
|
||||
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
|
||||
r = await client.post(f"/api/v1/uploads/{sid}/finalize",
|
||||
json={"duration_seconds": 5.0}, headers=h)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
class FakeTranscriber:
|
||||
name = "fake-stt"
|
||||
|
||||
def __init__(self, text="hello world from the meeting", fail=None):
|
||||
self.text = text
|
||||
self.fail = fail
|
||||
self.calls = 0
|
||||
|
||||
async def transcribe(self, audio, mime, *, language_hint=None):
|
||||
self.calls += 1
|
||||
assert len(audio) > 0 and mime == "audio/wav"
|
||||
if self.fail is not None:
|
||||
raise self.fail
|
||||
return TranscriptResult(
|
||||
text=self.text, language="en",
|
||||
segments=[Segment(0.0, 1.0, self.text)], model="fake-stt-1",
|
||||
)
|
||||
|
||||
|
||||
class FakeLlm:
|
||||
name = "fake-llm"
|
||||
|
||||
def __init__(self, fail=None):
|
||||
self.fail = fail
|
||||
self.seen = []
|
||||
|
||||
async def summarize(self, transcript, *, title=None):
|
||||
self.seen.append(transcript)
|
||||
if self.fail is not None:
|
||||
raise self.fail
|
||||
return SummaryResult(
|
||||
short="Standup happened.", detailed="The team met and spoke.",
|
||||
key_points=("a",), decisions=(), action_items=("ship it",),
|
||||
questions=(), model="fake-llm-1",
|
||||
)
|
||||
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def use_fakes(monkeypatch, stt=_UNSET, llm=_UNSET):
|
||||
tprov = FakeTranscriber() if stt is _UNSET else stt
|
||||
lprov = FakeLlm() if llm is _UNSET else llm
|
||||
monkeypatch.setattr(processing, "get_transcription_provider", lambda settings: tprov)
|
||||
monkeypatch.setattr(processing, "get_llm_provider", lambda settings: lprov)
|
||||
|
||||
|
||||
async def jobs_for(recording_id):
|
||||
async with db_session._session_factory() as s:
|
||||
rows = (await s.scalars(
|
||||
select(ProcessingJob).where(ProcessingJob.recording_id == uuid.UUID(recording_id))
|
||||
)).all()
|
||||
return {j.job_type: j for j in rows}
|
||||
|
||||
|
||||
# --- no AI configured --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_none_configured_marks_ai_disabled(client):
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token)
|
||||
assert rec["processing_status"] == "ai_disabled"
|
||||
assert await jobs_for(rec["id"]) == {}
|
||||
# Status endpoints: nothing there yet, but the recording exists.
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||
assert r.status_code == 404
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||
assert r.status_code == 404
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||
assert r.status_code == 200 and r.json() == []
|
||||
|
||||
|
||||
# --- happy path ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_full_pipeline_transcribe_then_summarize(client, monkeypatch):
|
||||
stt, llm = FakeTranscriber(), FakeLlm()
|
||||
use_fakes(monkeypatch, stt, llm)
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-full-1")
|
||||
assert rec["processing_status"] == "queued"
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert set(jobs) == {JobType.transcribe}
|
||||
assert jobs[JobType.transcribe].status == JobStatus.queued
|
||||
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.succeeded
|
||||
assert set(jobs) == {JobType.transcribe, JobType.summarize}
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "processing"
|
||||
|
||||
t = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||
assert t.status_code == 200, t.text
|
||||
body = t.json()
|
||||
assert body["text"] == "hello world from the meeting"
|
||||
assert body["version"] == 1 and body["provider"] == "fake-stt"
|
||||
assert body["segments"][0]["text"].startswith("hello")
|
||||
assert llm.seen == []
|
||||
|
||||
await processing.run_summarize({}, rec["id"])
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.summarize].status == JobStatus.succeeded
|
||||
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||
assert s.status_code == 200, s.text
|
||||
content = s.json()["content"]
|
||||
assert content["short"] == "Standup happened."
|
||||
assert content["action_items"] == ["ship it"]
|
||||
assert set(content) == {"short", "detailed", "key_points", "decisions",
|
||||
"action_items", "questions"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "completed"
|
||||
assert llm.seen == ["hello world from the meeting"]
|
||||
|
||||
jobs_rows = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||
assert jobs_rows.status_code == 200
|
||||
assert {j["job_type"] for j in jobs_rows.json()} == {"transcribe", "summarize"}
|
||||
|
||||
|
||||
async def test_enqueue_is_idempotent(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-idem-1")
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
await processing.run_summarize({}, rec["id"])
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, uuid.UUID(rec["id"]))
|
||||
queued = await processing.enqueue_for_recording(s, row)
|
||||
assert queued == []
|
||||
n = await s.scalar(
|
||||
select(func.count(ProcessingJob.id)).where(
|
||||
ProcessingJob.recording_id == uuid.UUID(rec["id"]))
|
||||
)
|
||||
assert n == 2
|
||||
await s.commit()
|
||||
|
||||
|
||||
# --- failure modes -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_transient_failure_retries_then_fails(client, monkeypatch):
|
||||
stt = FakeTranscriber(fail=ProviderTransientError("stt down"))
|
||||
use_fakes(monkeypatch, stt, FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-fail-1")
|
||||
# First tries raise for arq retry; the attempt rolls back with the
|
||||
# transaction, so the row still reads queued (arq tracks the tries).
|
||||
with pytest.raises(ProviderTransientError):
|
||||
await processing.run_transcribe({"job_try": 1}, rec["id"])
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.queued
|
||||
# Last try marks the job (and recording) failed with a safe message.
|
||||
await processing.run_transcribe({"job_try": 3}, rec["id"])
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.failed
|
||||
assert jobs[JobType.transcribe].error == "stt down"
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "failed"
|
||||
assert r.json()["processing_error"] == "stt down"
|
||||
|
||||
|
||||
async def test_config_error_fails_fast(client, monkeypatch):
|
||||
stt = FakeTranscriber(fail=ProviderConfigError("bad credentials"))
|
||||
use_fakes(monkeypatch, stt, FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-cfg-1")
|
||||
await processing.run_transcribe({"job_try": 1}, rec["id"]) # no raise
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.failed
|
||||
assert stt.calls == 1
|
||||
|
||||
|
||||
# --- versioning -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_rerun_supersedes_auto_but_not_user_edits(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber("v1 text"), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-ver-1")
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
rid = uuid.UUID(rec["id"])
|
||||
|
||||
async def versions():
|
||||
async with db_session._session_factory() as s:
|
||||
rows = (await s.scalars(
|
||||
select(Transcript).where(Transcript.recording_id == rid)
|
||||
.order_by(Transcript.version))).all()
|
||||
return [(r.version, r.text, r.superseded_at is not None, r.edited_by_user)
|
||||
for r in rows]
|
||||
|
||||
assert await versions() == [(1, "v1 text", False, False)]
|
||||
|
||||
# Queue another transcription run manually (re-entry path).
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
await processing.enqueue_for_recording(s, row)
|
||||
await s.commit()
|
||||
use_fakes(monkeypatch, FakeTranscriber("v2 text"), FakeLlm())
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
assert await versions() == [(1, "v1 text", True, False), (2, "v2 text", False, False)]
|
||||
|
||||
# A user edit wins: the next auto run inserts nothing.
|
||||
async with db_session._session_factory() as s:
|
||||
v2 = await s.scalar(
|
||||
select(Transcript).where(Transcript.recording_id == rid,
|
||||
Transcript.version == 2))
|
||||
v2.edited_by_user = True
|
||||
v2.text = "user corrected text"
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
await processing.enqueue_for_recording(s, row)
|
||||
await s.commit()
|
||||
use_fakes(monkeypatch, FakeTranscriber("v3 text"), FakeLlm())
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
got = await versions()
|
||||
assert len(got) == 2 and got[1][1] == "user corrected text"
|
||||
|
||||
|
||||
# --- llm-only and skips ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_llm_only_without_transcript_queues_nothing(client, monkeypatch):
|
||||
use_fakes(monkeypatch, None, FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-llm-1")
|
||||
assert rec["processing_status"] == "uploaded"
|
||||
assert await jobs_for(rec["id"]) == {}
|
||||
|
||||
|
||||
async def test_llm_only_with_manual_transcript_summarizes(client, monkeypatch):
|
||||
use_fakes(monkeypatch, None, FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-llm-2")
|
||||
rid = uuid.UUID(rec["id"])
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
s.add(Transcript(recording_id=rid, version=1, provider="manual",
|
||||
text="handwritten notes", edited_by_user=True))
|
||||
await s.flush()
|
||||
queued = await processing.enqueue_for_recording(s, row)
|
||||
assert queued == [JobType.summarize]
|
||||
await s.commit()
|
||||
await processing.run_summarize({}, rec["id"])
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||
assert s.status_code == 200
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "completed"
|
||||
|
||||
|
||||
# --- sweep ------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_sweep_requeues_stale_jobs(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-sweep-1")
|
||||
rid = uuid.UUID(rec["id"])
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
# Simulate a crashed worker + a lost transport, respectively.
|
||||
await processing.reset_or_create(s, row, JobType.transcribe)
|
||||
jobs = await jobs_for(rec["id"])
|
||||
jobs[JobType.transcribe].status = JobStatus.running
|
||||
await s.commit()
|
||||
# reset_or_create leaves running rows alone, so force the second shape:
|
||||
async with db_session._session_factory() as s:
|
||||
extra = ProcessingJob(recording_id=rid, job_type=JobType.summarize,
|
||||
status=JobStatus.queued)
|
||||
s.add(extra)
|
||||
await s.commit()
|
||||
count = await processing.sweep_stale()
|
||||
assert count == 2
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.queued
|
||||
assert jobs[JobType.summarize].status == JobStatus.queued
|
||||
|
||||
|
||||
async def test_sweep_leaves_live_running_jobs_alone(client, monkeypatch):
|
||||
"""Regression: sweep re-enqueued every in-flight transcription every
|
||||
5 minutes; with local whisper each copy ran concurrently and one worker
|
||||
OOM-swap-swelled the box. A running job started recently is live work."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-sweep-live")
|
||||
rid = uuid.UUID(rec["id"])
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
await processing.reset_or_create(s, row, JobType.transcribe)
|
||||
live = (await s.scalars(
|
||||
select(ProcessingJob).where(
|
||||
ProcessingJob.recording_id == rid,
|
||||
ProcessingJob.job_type == JobType.transcribe)
|
||||
)).one()
|
||||
live.status = JobStatus.running
|
||||
live.started_at = datetime.now(UTC) # worker is chewing it right now
|
||||
stale = ProcessingJob(recording_id=rid, job_type=JobType.summarize,
|
||||
status=JobStatus.running, attempt=1)
|
||||
stale.started_at = datetime.now(UTC) - timedelta(
|
||||
hours=processing.STALE_RUNNING_AFTER.total_seconds() / 3600 + 1)
|
||||
s.add(stale)
|
||||
await s.commit()
|
||||
count = await processing.sweep_stale()
|
||||
assert count == 1 # only the orphaned old run
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.running
|
||||
assert jobs[JobType.summarize].status == JobStatus.queued
|
||||
|
||||
|
||||
# --- ownership ---------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ai_endpoints_enforce_ownership(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-own-1")
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
other = await user_tokens(client, email="m7-other@example.com",
|
||||
password="m7-test-passw0rd-456")
|
||||
h = {"Authorization": f"Bearer {other}"}
|
||||
for path in ("transcript", "summary", "jobs"):
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/{path}", headers=h)
|
||||
assert r.status_code == 404, path
|
||||
Loading…
Add table
Add a link
Reference in a new issue