Summarize: LAN primary with local-Ollama rescue + in-app failure guidance

- Engine: when the primary summarizer is out of retries (or misconfigured),
  run_summarize now finishes the job on the configured rescue provider
  (SHONAR_LLM_FALLBACK_*), tags the summary with the provider that wrote it,
  and stores a human note in job.error; success clears stale notes.
- App (auto/lan): passes Ollama as the rescue provider when it is up.
- Detail screen: shows the rescue-swap note in plain words, a red
  'Summary failed' line with Settings -> Summarizer fix instructions and a
  Retry summary button on hard failure.
- Settings copy explains the fallback. 3 new pytest cases (14/14 pass);
  live E2E on 2026-09-18: sarcastic summary v6 via LAN on attempt 2.
This commit is contained in:
avi 2026-09-17 21:44:02 -05:00
commit 3061629dc5
6 changed files with 172 additions and 8 deletions

View file

@ -110,11 +110,13 @@ class FakeLlm:
_UNSET = object()
def use_fakes(monkeypatch, stt=_UNSET, llm=_UNSET):
def use_fakes(monkeypatch, stt=_UNSET, llm=_UNSET, fallback=None):
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)
monkeypatch.setattr(processing, "get_llm_fallback_provider",
lambda settings: fallback)
async def jobs_for(recording_id):
@ -409,3 +411,56 @@ async def test_ai_endpoints_enforce_ownership(client, monkeypatch):
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
# --- summarize fallback to the rescue summarizer --------------------------------
async def test_summarize_fallback_rescues_after_primary_exhausted(client, monkeypatch):
primary = FakeLlm(fail=ProviderTransientError("LLM reply was not JSON."))
fb = FakeLlm()
fb.name = "fake-ollama"
use_fakes(monkeypatch, FakeTranscriber(), primary, fallback=fb)
token = await user_tokens(client, email="m7-fb1@example.com")
rec = await upload_recording(client, token, client_id="m7-fb-1")
await processing.run_transcribe({}, rec["id"])
# Last try: primary out of budget -> rescue runs and the note lands in job.error.
await processing.run_summarize({"job_try": 3}, rec["id"])
job = (await jobs_for(rec["id"]))[JobType.summarize]
assert job.status == JobStatus.succeeded
assert "fake-llm" in (job.error or "") and "fake-ollama" in (job.error or "")
h = {"Authorization": f"Bearer {token}"}
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
assert s.status_code == 200
assert s.json()["provider"] == "fake-ollama"
assert fb.seen == ["hello world from the meeting"]
async def test_summarize_fallback_also_fails_marks_job_failed(client, monkeypatch):
err = ProviderTransientError("LLM reply was not JSON.")
fb = FakeLlm(fail=ProviderTransientError("ollama down"))
fb.name = "fake-ollama"
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm(fail=err), fallback=fb)
token = await user_tokens(client, email="m7-fb2@example.com")
rec = await upload_recording(client, token, client_id="m7-fb-2")
await processing.run_transcribe({}, rec["id"])
await processing.run_summarize({"job_try": 3}, rec["id"])
job = (await jobs_for(rec["id"]))[JobType.summarize]
assert job.status == JobStatus.failed
assert "not JSON" in job.error # the primary's error is what we surface
assert fb.seen == ["hello world from the meeting"] # fallback was tried
async def test_summarize_keeps_retry_budget_before_fallback(client, monkeypatch):
# Try 1 of 3 transient: normal arq retry wins, fallback stays untouched.
fb = FakeLlm()
fb.name = "fake-ollama"
use_fakes(monkeypatch, FakeTranscriber(),
FakeLlm(fail=ProviderTransientError("boom")), fallback=fb)
token = await user_tokens(client, email="m7-fb3@example.com")
rec = await upload_recording(client, token, client_id="m7-fb-3")
await processing.run_transcribe({}, rec["id"])
with pytest.raises(ProviderTransientError):
await processing.run_summarize({"job_try": 1}, rec["id"])
job = (await jobs_for(rec["id"]))[JobType.summarize]
assert job.status == JobStatus.queued
assert fb.seen == []