Root cause (v0.3.9 field report): the refine paths had no-op output guards. Cinematic's ADAPT step only checked _looks_like_target_script, which returns True unconditionally for every Latin-script target (no _SCRIPT_RANGES entry) — so any non-empty LLM reply (hallucinated dialogue, refusals, commentary, or the REFLECT critique itself) shipped as the dub line. Autofit's adjust_for_slot accepted ANY non-empty reply, and its best-candidate tracker (closest rate_ratio to 1.0) actively selected the most-padded output, while _EXPAND_PROMPT invited invention with no ceiling. Both call paths also ran at the provider-default temperature 1.0, unlike the working Fast path which pins 0.2. The fix, class-level: - Shared divergence guard translator.refine_output_ok (length window 0.4–2.5x, env-tunable via OMNIVOICE_REFINE_RATIO_MIN/MAX, with an absolute cap for short references; target-script check; critique-echo detection). Rejected ADAPT output degrades to the literal with error="adapt-diverged" (wrong-script keeps its adapt-wrong-script:<lang> marker), riding the existing degradation machinery unchanged. - Autofit validates every reply against the ORIGINAL input text (divergence compounds across attempts otherwise); rejected candidates are discarded (attempt burned, graceful degradation to the input preserved) with error="fit-diverged"; lines under 15% of their slot skip LLM expansion entirely (fit-skip-short) — they could only "fill" the slot with fabricated dialogue. - temperature=0.2 pinned on the cinematic (_chat) and fit (llm.chat) calls; chat/chat_messages gained an optional temperature param that is only sent when set, so refinement/director/glossary callers keep provider defaults. - Prompts hardened: ADAPT forbids introducing facts/names/dialogue not in the source line; EXPAND forbids inventing information and more than doubling the line. - speech_rate strict-mode docstring made honest: strict changes only the upper tolerance bound; expansion still runs (now guard-bounded). Fail-before/pass-after regression tests for the reported bugs (10x runaway ADAPT on an es target, critique echo, hallucinated slot-fill expansion, refusal replies, tiny-line expansion skip, pinned temperature) plus the previously-untested wrong-script fallback and legit-output acceptance. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
132 lines
5.8 KiB
Python
132 lines
5.8 KiB
Python
"""Autofit translation style — strict fit-to-slot (v0.3.8).
|
||
|
||
Autofit = Cinematic + a hard "never exceed the segment time" bound. In the
|
||
speech-rate fit pass that means the accepted upper ratio is 1.0 (fit within the
|
||
slot) instead of Cinematic's looser TOL_HIGH (1.08). Verifies the strict mode
|
||
keeps trimming past the loose tolerance and that the no-LLM path still degrades
|
||
gracefully.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
|
||
class _FakeTrimmer:
|
||
"""Minimal non-Off LLM stand-in that trims any line to a short fixed length
|
||
so the fit loop converges deterministically."""
|
||
def chat(self, *, system, user, timeout=None, temperature=None):
|
||
return "A" * 14 # ratio 0.93 for slot 1.0s @ en 15 cps → inside [0.92, 1.0]
|
||
|
||
|
||
class _FakeChatter:
|
||
"""Non-Off LLM stand-in that always returns one fixed reply; counts calls."""
|
||
def __init__(self, reply: str):
|
||
self.reply = reply
|
||
self.calls = 0
|
||
self.last_temperature = None
|
||
|
||
def chat(self, *, system, user, timeout=None, temperature=None):
|
||
self.calls += 1
|
||
self.last_temperature = temperature
|
||
return self.reply
|
||
|
||
|
||
@pytest.fixture
|
||
def speech_rate(monkeypatch):
|
||
from services import speech_rate as _sr
|
||
monkeypatch.setattr(_sr, "get_active_llm_backend", lambda: _FakeTrimmer())
|
||
return _sr
|
||
|
||
|
||
def test_loose_accepts_slight_overrun_without_llm(speech_rate):
|
||
# 16 chars @ 15 cps over a 1.0s slot → ratio ~1.067: within Cinematic's
|
||
# TOL_HIGH (1.08), so it's accepted as-is with no LLM call.
|
||
res = speech_rate.adjust_for_slot("A" * 16, slot_seconds=1.0, target_lang="en", strict=False)
|
||
assert res["attempts"] == 0
|
||
assert res["rate_ratio"] > 1.0 # it overran the slot but was tolerated
|
||
|
||
|
||
def test_autofit_strict_trims_until_within_slot(speech_rate):
|
||
# Same overrun, but Autofit must not accept > 1.0 — it trims via the LLM.
|
||
res = speech_rate.adjust_for_slot("A" * 16, slot_seconds=1.0, target_lang="en", strict=True)
|
||
assert res["rate_ratio"] <= 1.0 + 1e-6
|
||
assert res["attempts"] >= 1 # the loose path would have been 0
|
||
|
||
|
||
def test_strict_no_llm_degrades_gracefully(monkeypatch):
|
||
monkeypatch.setenv("OMNIVOICE_LLM_BACKEND", "off")
|
||
from services import speech_rate as _sr
|
||
res = _sr.adjust_for_slot("A" * 30, slot_seconds=1.0, target_lang="en", strict=True)
|
||
assert res.get("error") == "no-llm"
|
||
assert res["text"] == "A" * 30 # unchanged, no crash
|
||
|
||
|
||
def test_strict_within_tolerance_is_noop(speech_rate):
|
||
# A line already at ratio ~1.0 needs no work even in strict mode.
|
||
res = speech_rate.adjust_for_slot("A" * 15, slot_seconds=1.0, target_lang="en", strict=True)
|
||
assert res["attempts"] == 0
|
||
assert res["rate_ratio"] == pytest.approx(1.0, abs=0.01)
|
||
|
||
|
||
# ── Divergence guard (v0.3.9 field report: Autofit invented dialogue) ───────
|
||
|
||
|
||
def test_expand_hallucination_rejected_output_stays_input(monkeypatch):
|
||
"""The reported bug: a short line over a long slot invited the LLM to
|
||
fabricate a slot-filling wall of dialogue, and the accept step took ANY
|
||
non-empty reply (the best-tracker then favored the most-padded one). The
|
||
guard must discard it and keep the input text."""
|
||
from services import speech_rate as _sr
|
||
text = "B" * 24 # 1.6s @ en 15cps over a 10s slot → ratio 0.16
|
||
fake = _FakeChatter("A" * 145) # a "perfect" slot fill = ~6× the input line
|
||
monkeypatch.setattr(_sr, "get_active_llm_backend", lambda: fake)
|
||
res = _sr.adjust_for_slot(text, slot_seconds=10.0, target_lang="en", strict=True)
|
||
assert res["text"] == text # hallucination never adopted
|
||
assert res.get("error") == "fit-diverged"
|
||
assert fake.calls == _sr.MAX_ATTEMPTS # attempts burned, loop still bounded
|
||
|
||
|
||
def test_refusal_reply_rejected_keeps_input(monkeypatch):
|
||
"""A refusal/commentary reply must not replace a long line just because
|
||
its length happens to land near the slot budget."""
|
||
from services import speech_rate as _sr
|
||
text = "C" * 60 # 4s over a 1s slot → heavy trim ask
|
||
fake = _FakeChatter("Sorry, I cannot.") # 16 chars ≈ the 15-char slot budget
|
||
monkeypatch.setattr(_sr, "get_active_llm_backend", lambda: fake)
|
||
res = _sr.adjust_for_slot(text, slot_seconds=1.0, target_lang="en")
|
||
assert res["text"] == text
|
||
assert res.get("error") == "fit-diverged"
|
||
|
||
|
||
def test_legitimate_expansion_accepted(monkeypatch):
|
||
from services import speech_rate as _sr
|
||
text = "D" * 40 # ratio 0.67 over a 4s slot @ en
|
||
fake = _FakeChatter("E" * 57) # ~1.4× the line → ratio 0.95, honest fill
|
||
monkeypatch.setattr(_sr, "get_active_llm_backend", lambda: fake)
|
||
res = _sr.adjust_for_slot(text, slot_seconds=4.0, target_lang="en", strict=True)
|
||
assert res["text"] == "E" * 57
|
||
assert "error" not in res
|
||
|
||
|
||
def test_tiny_line_skips_llm_expansion(monkeypatch):
|
||
"""A line under 15% of its slot can never honestly fill it — the LLM must
|
||
not even be asked (it could only fabricate), and the line stays short."""
|
||
from services import speech_rate as _sr
|
||
fake = _FakeChatter("A" * 150)
|
||
monkeypatch.setattr(_sr, "get_active_llm_backend", lambda: fake)
|
||
res = _sr.adjust_for_slot("Hi.", slot_seconds=10.0, target_lang="en", strict=True)
|
||
assert res["text"] == "Hi."
|
||
assert res["attempts"] == 0
|
||
assert res.get("error") == "fit-skip-short"
|
||
assert fake.calls == 0
|
||
|
||
|
||
def test_fit_llm_call_pins_low_temperature(monkeypatch):
|
||
"""The fit pass pins temperature=0.2 like the Fast translate path — the
|
||
provider default of 1.0 is part of what made expansions drift."""
|
||
from services import speech_rate as _sr
|
||
fake = _FakeChatter("A" * 14)
|
||
monkeypatch.setattr(_sr, "get_active_llm_backend", lambda: fake)
|
||
_sr.adjust_for_slot("A" * 16, slot_seconds=1.0, target_lang="en", strict=True)
|
||
assert fake.last_temperature == 0.2
|