Files
VoiceStudio/tests/test_autofit_838.py
T
437a995a0f fix(translate): Cinematic/Autofit can no longer invent dialogue — divergence guard + pinned temperature (#950)
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>
2026-07-04 22:28:37 +05:30

132 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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