diff --git a/backend/shonar/api/v1/recordings.py b/backend/shonar/api/v1/recordings.py index 9a23575..e8ed2ab 100644 --- a/backend/shonar/api/v1/recordings.py +++ b/backend/shonar/api/v1/recordings.py @@ -337,6 +337,7 @@ async def reprocess_recording( user: CurrentUser, session: SessionDep, job: str = Query(default="summarize", pattern="^(transcribe|summarize)$"), + model: str | None = Query(default=None, max_length=64), ): """Force one pipeline stage to run again (Summarize / Re-transcribe). @@ -345,7 +346,8 @@ async def reprocess_recording( deliberate request. Running jobs are left alone (409 instead of a duplicate). """ - from shonar.db.models import JobStatus, JobType as JT, ProcessingStatus + from shonar.db.models import JobStatus, ProcessingStatus + from shonar.db.models import JobType as JT from shonar.services import processing as proc rec = await _owned_recording(session, user, recording_id) @@ -378,6 +380,16 @@ async def reprocess_recording( existing.started_at = None existing.finished_at = None if job_type is JT.transcribe: + if model is not None: + # A re-transcribe may switch models; the saved per-recording + # override is what the worker reads, so persist it here. + from shonar.services.ai import ProviderConfigError + from shonar.services.ai.model_registry import validate_model_name + + try: + rec.transcription_model = validate_model_name(model) + except ProviderConfigError as e: + raise HTTPException(422, str(e)) from e rec.processing_status = ProcessingStatus.processing rec.processing_error = None await session.flush() diff --git a/backend/tests/test_inline_queue.py b/backend/tests/test_inline_queue.py index a0888f6..d71c277 100644 --- a/backend/tests/test_inline_queue.py +++ b/backend/tests/test_inline_queue.py @@ -99,3 +99,55 @@ async def test_inline_retries_transient_failure(client, monkeypatch): 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