feat: guard summaries against degenerate (garbage) backend output

Observed live: an overheated MI50 returns a single char repeated ("?????") as a
successful 200, which neither the timeout nor the exception fallback catches — so
a degraded GPU would silently save capped garbage gists. Validate each summary
call's output: flag text (>=24 non-space chars) whose most-common non-whitespace
char exceeds 50%, raise DegenerateOutput, and let the existing retry->cloud
fallback handle it. Real prose (top char <20%) won't false-positive; short output
is exempt; cloud garbage raises rather than looping.

Tests: _looks_degenerate flags repeated-char / passes real prose / ignores short;
degenerate MI50 output falls back to cloud; cloud garbage raises. 177 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
This commit is contained in:
2026-07-04 07:26:17 +00:00
parent 29a4d59661
commit e631797187
3 changed files with 85 additions and 2 deletions
+45
View File
@@ -95,3 +95,48 @@ def test_happy_path_uses_primary_only(calls, monkeypatch):
assert out == "mi50-gist"
assert [c["backend"] for c in calls.recorded] == ["mi50"] # no retries, no fallback
# --- degenerate ("?" garbage) output guard: a wedged local model returns junk as
# a successful 200, so treat it as a failure and fall back to cloud. ---
def test_looks_degenerate_flags_repeated_char():
assert summary._looks_degenerate("?" * 60) is True
assert summary._looks_degenerate("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") is True
def test_looks_degenerate_passes_real_prose():
gist = ("Brian sat down at the Meadows 1/3 in seat 6 with two straddles active; "
"he tagged a seat-3 calling station and finished the session up 240.")
assert summary._looks_degenerate(gist) is False
def test_looks_degenerate_ignores_short_output():
# Too short to judge — don't false-positive a terse-but-valid reply.
assert summary._looks_degenerate("ok") is False
def test_degenerate_mi50_output_falls_back_to_cloud(calls, monkeypatch):
_set_key(monkeypatch)
def responder(backend):
if backend == "mi50":
return "?" * 200 # garbage-as-200, not an exception
return "a real cloud gist of the session, diverse and coherent."
calls.fake.responder = responder
out = summary._summarize_text("transcript", "mi50")
assert "cloud gist" in out
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50", "cloud"]
def test_degenerate_cloud_output_raises_no_infinite_loop(calls, monkeypatch):
_set_key(monkeypatch)
calls.fake.responder = lambda backend: "?" * 200 # every backend returns garbage
with pytest.raises(Exception):
summary._summarize_text("t", "mi50")
# mi50 x2, then one cloud fallback that's also garbage -> give up, no loop.
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50", "cloud"]