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
153
backend/tests/test_inline_queue.py
Normal file
153
backend/tests/test_inline_queue.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Inline queue tests (desktop bundled-lite engine): with
|
||||
``queue_backend=inline`` an upload runs transcribe→summarize automatically
|
||||
in-process — no arq, no Redis. Providers are fakes (see test_ai_pipeline).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from shonar.core.config import get_settings
|
||||
from shonar.db import session as db_session
|
||||
from shonar.db.models import JobStatus, JobType, ProcessingJob
|
||||
from shonar.services import inline_queue, processing
|
||||
|
||||
from .test_ai_pipeline import FakeLlm, FakeTranscriber, upload_recording, use_fakes, user_tokens
|
||||
|
||||
|
||||
def use_inline(monkeypatch):
|
||||
"""Force queue_backend=inline for everything resolved via processing."""
|
||||
inline_settings = get_settings().model_copy(update={"queue_backend": "inline"})
|
||||
monkeypatch.setattr(processing, "get_settings", lambda: inline_settings)
|
||||
|
||||
|
||||
async def _wait_terminal(recording_id: str, want: int = 2, timeout: float = 20.0):
|
||||
"""Poll the DB until `want` jobs reach a terminal state."""
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
rows: list[ProcessingJob] = []
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
async with db_session._session_factory() as s:
|
||||
rows = list(
|
||||
(
|
||||
await s.scalars(
|
||||
select(ProcessingJob).where(
|
||||
ProcessingJob.recording_id == uuid.UUID(recording_id)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if sum(
|
||||
1
|
||||
for j in rows
|
||||
if j.status in (JobStatus.succeeded, JobStatus.failed, JobStatus.skipped)
|
||||
) >= want:
|
||||
return {j.job_type: j.status for j in rows}
|
||||
await asyncio.sleep(0.1)
|
||||
raise AssertionError(
|
||||
f"jobs did not reach terminal state in {timeout}s: "
|
||||
f"{[(j.job_type, j.status) for j in rows]}"
|
||||
)
|
||||
|
||||
|
||||
async def test_inline_backend_processes_upload_without_arq(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
use_inline(monkeypatch)
|
||||
await inline_queue.start()
|
||||
try:
|
||||
token = await user_tokens(client, email="inline@shonar.dev")
|
||||
rec = await upload_recording(client, token, client_id="inline-1")
|
||||
|
||||
statuses = await _wait_terminal(rec["id"], want=2)
|
||||
assert statuses[JobType.transcribe] == JobStatus.succeeded
|
||||
assert statuses[JobType.summarize] == JobStatus.succeeded
|
||||
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "completed"
|
||||
t = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||
assert t.json()["text"] == "hello world from the meeting"
|
||||
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||
assert s.json()["content"]["short"] == "Standup happened."
|
||||
finally:
|
||||
await inline_queue.stop()
|
||||
|
||||
|
||||
async def test_inline_retries_transient_failure(client, monkeypatch):
|
||||
stt = FakeTranscriber()
|
||||
state = {"tries": 0}
|
||||
original = stt.transcribe
|
||||
|
||||
async def flaky(audio, mime, *, language_hint=None):
|
||||
state["tries"] += 1
|
||||
if state["tries"] == 1:
|
||||
raise processing.ProviderTransientError("temporary hiccup")
|
||||
return await original(audio, mime, language_hint=language_hint)
|
||||
|
||||
stt.transcribe = flaky
|
||||
use_fakes(monkeypatch, stt, FakeLlm())
|
||||
use_inline(monkeypatch)
|
||||
monkeypatch.setattr(inline_queue, "RETRY_DELAY_SECONDS", 0.05)
|
||||
await inline_queue.start()
|
||||
try:
|
||||
token = await user_tokens(client, email="inline2@shonar.dev")
|
||||
rec = await upload_recording(client, token, client_id="inline-2")
|
||||
statuses = await _wait_terminal(rec["id"], want=2)
|
||||
assert statuses[JobType.transcribe] == JobStatus.succeeded
|
||||
assert state["tries"] >= 2 # the retry actually happened
|
||||
finally:
|
||||
await inline_queue.stop()
|
||||
|
||||
|
||||
async def test_reprocess_reruns_in_place_with_model(client, monkeypatch):
|
||||
"""POST /reprocess?job=transcribe&model= re-runs the pipeline on the
|
||||
SAME recording (no re-upload) and persists the model override."""
|
||||
stt = FakeTranscriber()
|
||||
use_fakes(monkeypatch, stt, FakeLlm())
|
||||
use_inline(monkeypatch)
|
||||
await inline_queue.start()
|
||||
try:
|
||||
token = await user_tokens(client, email="reproc@shonar.dev")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rec = await upload_recording(client, token, client_id="reproc-1")
|
||||
await _wait_terminal(rec["id"], want=2)
|
||||
calls_before = stt.calls
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/recordings/{rec['id']}/reprocess?job=transcribe&model=small",
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
statuses = await _wait_terminal(rec["id"], want=2)
|
||||
assert statuses[JobType.transcribe] == JobStatus.succeeded
|
||||
assert stt.calls == calls_before + 1 # re-ran, no new recording
|
||||
|
||||
# Same recording id, model override persisted on the row.
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["id"] == rec["id"]
|
||||
assert r.json()["transcription_model"] == "small"
|
||||
finally:
|
||||
await inline_queue.stop()
|
||||
|
||||
|
||||
async def test_reprocess_validation(client, monkeypatch):
|
||||
token = await user_tokens(client, email="reproc2@shonar.dev")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rec = await upload_recording(client, token, client_id="reproc-2")
|
||||
# Nothing transcribed yet -> summarize refuses.
|
||||
r = await client.post(
|
||||
f"/api/v1/recordings/{rec['id']}/reprocess?job=summarize", headers=h)
|
||||
assert r.status_code == 409
|
||||
# Bad model name -> 422 (never silently substituted).
|
||||
r = await client.post(
|
||||
f"/api/v1/recordings/{rec['id']}/reprocess?job=transcribe&model=not-a-model",
|
||||
headers=h)
|
||||
assert r.status_code == 422
|
||||
# Not the owner -> 404.
|
||||
other = await user_tokens(client, email="reproc3@shonar.dev")
|
||||
r = await client.post(
|
||||
f"/api/v1/recordings/{rec['id']}/reprocess?job=transcribe",
|
||||
headers={"Authorization": f"Bearer {other}"})
|
||||
assert r.status_code == 404
|
||||
Loading…
Add table
Add a link
Reference in a new issue