fix(dub): a rate-limited polish pass no longer skips fitting, fails the UI, or ignores Retry-After (#1135)

* fix(dub): a rate-limited polish pass no longer skips fitting, fails the UI, or ignores Retry-After

Observed live (owner's Bengali dub, 4 segments): every cinematic reflect call
429'd against a free-tier OpenRouter model and the UI declared "4/4 segment(s)
failed" over a translate that succeeded. Root-causing that surfaced a class,
not a message bug:

The cinematic reflect/adapt chain is OPTIONAL polish — on any failure the
segment keeps its literal translation and is fully usable. But every such
degradation (no-llm, reflect/adapt errors, adapt-diverged, wrong-script,
cinematic-budget) was reported under the same "error" key as real translation
failures. Three consumers took that at face value:

  1. useDubWorkflow counted the rows as failed -> the red N/N toast;
  2. _stamp_predicted_rate_ratio and _stamp_duration_plan skipped them ->
     no rate badges, no fits/tight/impossible verdicts;
  3. _apply_fit_pass and the condense pass skipped them -> overlong lines went
     to synthesis unfitted and came out audibly time-compressed at mix. This
     is a direct contributor to "later segments got worse" in rate-limited
     Cinematic dubs.

Split the vocabulary: "error" now means the row has no usable text (base
translation failed); optional-pass fallbacks ride a separate "degraded" key.
Downstream filters keep gating on "error" only, so degraded rows flow through
every fitting pass. The UI shows an amber "translated, polish skipped
(<reason>)" toast and a mild row tooltip instead of a red failure, and editing
a row clears the stale annotation.

And the retry that makes most of this moot: _chat now honors a 429's
Retry-After once (capped at 30s, jittered so the 6-wide segment fan-out does
not re-stampede the same window). OpenRouter's free pool says "Retry-After: 2"
- giving up instantly turned a two-second wait into a whole failed pass.

Tests: producer contract (every cinematic fallback returns degraded, never
error - 5 updated + retained), consumer contract (degraded rows still get
rate-ratio prediction and duration plans; error rows stay excluded), and the
retry (honors small Retry-After with jitter, caps absurd ones, one retry only,
non-429s never retry). Full suite: 2981 backend + 1236 frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): correct PR ref to #1135

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dub): review round — localize the degraded strings, un-suppress the mixed toast, clear stale annotations on edit

Three review findings, all valid:

- Localization parity (Greptile): the two new user-facing keys existed only in
  en.json. Every other key in these namespaces is translated in all 21
  locales, so the fallback-to-English behavior would have been a regression of
  the repo's parity convention. Both keys now translated in all 20 non-en
  locales, inserted beside their siblings.
- Mixed responses suppressed the degraded story (Greptile): when a translate
  returned both real failures and degraded rows, only the red failure toast
  fired. The degraded warning now fires alongside it — real failures don't
  erase what happened to the rows that succeeded plainly.
- Ordinary edits kept stale annotations (CodeRabbit): the restore path cleared
  translate_error/translate_degraded but a normal text edit didn't, so a row
  kept wearing "polish pass skipped" over words the user had just written.
  Editing the text now clears both annotations.

Frontend suite: 1236 passed; i18n probe green across all 21 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-13 16:13:41 +05:30
committed by GitHub
co-authored by Claude Opus 4.8 mergetest
parent 58c6f37252
commit 46141c8e5e
31 changed files with 327 additions and 30 deletions
+2
View File
@@ -14,6 +14,8 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Fixed
- **A rate-limited translation polish pass no longer sabotages the dub — or lies about it.** The Cinematic quality mode runs an optional critique-and-rewrite pass after translating. When that pass hit a rate limit (free-tier LLM endpoints throttle hard), three bad things happened at once: the app reported **"N/N segment(s) failed"** in red over a translate that had actually succeeded; the affected segments were **silently skipped by the speech-rate fit pass and duration planner** — so overlong lines went to synthesis unfitted and came out audibly time-compressed; and the two-second "retry shortly" hint the provider sent was ignored. All three are fixed: a rate-limited call now waits out the provider's own `Retry-After` (bounded, once) and usually just succeeds; a segment that still misses the polish keeps its plain translation, **stays in every downstream fitting pass**, and is reported honestly — "translated, polish skipped" as a warning with the reason, not a failure. Rows that really failed still say so. (#1135)
- **Dubbing kept re-studying the same speaker's voice, hundreds of times per video.** Each dubbed line clones from a clip of its own source audio (that's what makes deliveries match), and lines too short to clone from fall back to a per-speaker sample. But the app's memory for already-studied voices only holds 8 — and a long dub streams *hundreds* of one-shot per-line clips through it, each pushing out the per-speaker samples that every other line needs. Result: the speaker sample was re-studied (~0.4 s, measured) over and over. One-shot clips are now studied without displacing anything, so the per-speaker samples stay warm for the whole dub. Nothing about the audio changes — same clips, same voices, less repeated work. (#1132)
- **Clicking "Install" on an engine right after opening Settings could silently do nothing.** When the Engines page opens, it quietly checks each installable engine for an in-flight install to re-attach to. If you clicked Install while that check was still running, your click's status update was thrown away to keep requests orderly — so no progress panel, no error, no retry, just nothing (the install itself *did* start in the background; the UI simply never showed it). Fast machines usually won the race, which is why this mostly showed up as a once-in-a-while CI test failure. The Install click's update can no longer be dropped — it politely waits out the startup check instead. (#1131)
+9
View File
@@ -1031,6 +1031,15 @@ async def _maybe_cinematic(translated, req, src_lang, loop, *, already_llm=False
"literal": r["literal"],
"critique": r.get("critique", ""),
}
# `degraded` ≠ `error`: a degraded row fell back to its literal text
# (reflect/adapt skipped — rate limit, budget, divergence) but is fully
# usable, so the fit pass, condense pass, and duration planning below
# must still run on it. Marking these `error` used to (a) skip all
# three passes — overlong lines then hit heavy time-compression at mix,
# audibly degrading the dub — and (b) make the UI report "N/N segments
# failed" for a translate that succeeded.
if r.get("degraded"):
out["degraded"] = r["degraded"]
if r.get("error"):
out["error"] = r["error"]
merged.append(out)
+68 -17
View File
@@ -38,6 +38,8 @@ from __future__ import annotations
import asyncio
import logging
import os
import random
import time
from typing import Iterable, Optional
logger = logging.getLogger("omnivoice.translator")
@@ -270,18 +272,62 @@ def _glossary_text(glossary: Iterable[dict] | None) -> str:
)
#: Longest Retry-After we'll honor with an in-place wait. Anything above this
#: means "the provider is down for a while" — fail fast and let the segment
#: degrade to its literal translation instead of stalling the whole dub.
_RETRY_AFTER_CAP_S = 30.0
def _retry_after_seconds(exc) -> float | None:
"""Retry-After from a rate-limit error, or None when this isn't a 429.
Providers frequently 429 with a *tiny* hint (OpenRouter's free pool says
"Retry-After: 2"); giving up instantly on those turned a two-second wait
into a whole failed reflect pass — 6 segments fire concurrently, so one
throttle window used to take out every segment at once. Defensive on
purpose: the exception shape differs across openai-lib versions and
OpenAI-compatible servers, and a parsing surprise must never break the
caller's own error handling.
"""
try:
if getattr(exc, "status_code", None) != 429:
return None
headers = getattr(getattr(exc, "response", None), "headers", None) or {}
raw = headers.get("retry-after") or headers.get("Retry-After")
seconds = float(raw) if raw is not None else 2.0
return max(0.5, min(seconds, _RETRY_AFTER_CAP_S))
except Exception: # noqa: BLE001 — a weird header is not worth a crash
return None
def _chat(client, *, system: str, user: str) -> str:
"""One-shot chat completion. Raises on failure."""
res = client.chat.completions.create(
model=_llm_model(),
timeout=_llm_timeout(),
temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
return (res.choices[0].message.content or "").strip()
"""One-shot chat completion. Raises on failure.
One polite retry on a rate limit: when the provider sends a 429 with a
bounded Retry-After, wait it out once (plus jitter so the 6-wide
concurrent segment fan-out doesn't re-stampede the same window) and try
again. A second 429 propagates — the caller degrades to the literal text.
"""
attempts = 0
while True:
try:
res = client.chat.completions.create(
model=_llm_model(),
timeout=_llm_timeout(),
temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
return (res.choices[0].message.content or "").strip()
except Exception as e: # noqa: BLE001 — re-raised unless a retryable 429
wait = _retry_after_seconds(e)
if wait is None or attempts >= 1:
raise
attempts += 1
logger.info("LLM rate-limited; honoring Retry-After=%.1fs (one retry)", wait)
time.sleep(wait + random.uniform(0.1, 1.0))
# ── Public API ──────────────────────────────────────────────────────────────
@@ -320,7 +366,7 @@ def cinematic_refine_sync(
client = _llm_client()
if client is None:
return {**result_ok, "error": "no-llm"}
return {**result_ok, "degraded": "no-llm"}
glossary_preamble = _glossary_text(glossary)
@@ -357,7 +403,7 @@ def cinematic_refine_sync(
critique = _chat(client, system=_with_preamble(_REFLECT_PROMPT), user=reflect_user)
except Exception as e:
logger.warning("cinematic reflect failed: %s", e)
return {**result_ok, "error": f"reflect: {e}"}
return {**result_ok, "degraded": f"reflect: {e}"}
# Step 3 — adapt
try:
@@ -373,7 +419,7 @@ def cinematic_refine_sync(
"text": literal_text,
"literal": literal_text,
"critique": critique,
"error": f"adapt: {e}",
"degraded": f"adapt: {e}",
}
final = (adapted or "").strip() or literal_text
@@ -396,8 +442,8 @@ def cinematic_refine_sync(
"text": literal_text,
"literal": literal_text,
"critique": critique,
"error": (f"adapt-wrong-script:{target_lang}" if wrong_script
else "adapt-diverged"),
"degraded": (f"adapt-wrong-script:{target_lang}" if wrong_script
else "adapt-diverged"),
}
return {
"text": final,
@@ -478,6 +524,11 @@ async def cinematic_refine_many(
logger.warning("cinematic segment %s failed: %s", sid, e)
else:
task.cancel() # stop awaiting; the executor thread is abandoned (#730 pattern)
# "degraded", not "error": the literal translation is used, so the
# segment is fully usable — downstream passes (speech-rate fit,
# duration planning) must still run on it, and the UI must not count
# it as a failed segment. `error` is reserved for rows with no usable
# text at all (the base translation itself failed).
out.append({"id": sid, "text": lit, "literal": lit, "critique": "",
"error": "cinematic-budget"})
"degraded": "cinematic-budget"})
return out
+7 -5
View File
@@ -352,11 +352,13 @@ function DubSegmentRow({
title={
seg.translate_error
? t('segment.translate_error_title', { error: seg.translate_error })
: overBudget
? t('segment.budget_title', {
pct: Math.round((seg.text.length / seg.text_original.length) * 100),
})
: t('segment.text_title')
: seg.translate_degraded
? t('segment.translate_degraded_title', { reason: seg.translate_degraded })
: overBudget
? t('segment.budget_title', {
pct: Math.round((seg.text.length / seg.text_original.length) * 100),
})
: t('segment.text_title')
}
style={
overBudget
@@ -747,6 +747,7 @@ export default function DubLeftColumn({
...s,
text: s.text_original || s.text,
translate_error: undefined,
translate_degraded: undefined,
})),
)
}
+25 -1
View File
@@ -764,9 +764,15 @@ export default function useDubWorkflow({
});
const translatedMap = {};
const errors = [];
const degraded = [];
(data.translated || []).forEach((t) => {
translatedMap[t.id] = t;
if (t.error) errors.push({ id: t.id, error: t.error });
// Degraded ≠ failed: the segment translated fine but the cinematic
// polish pass was skipped (rate limit, budget, divergent reply) and
// the literal text is in use. Counting these as errors used to show
// "4/4 segment(s) failed" over a translate that succeeded.
else if (t.degraded) degraded.push({ id: t.id, reason: t.degraded });
});
setDubSegments((prev) =>
prev.map((s) => {
@@ -782,6 +788,7 @@ export default function useDubWorkflow({
// instead of destroying the previous language's work.
...(gotText ? { translations: { ...s.translations, [targetLang]: hit.text } } : {}),
translate_error: hit.error || undefined,
translate_degraded: hit.degraded || undefined,
translate_literal: hit.literal || undefined,
translate_critique: hit.critique || undefined,
// Carry over the predicted compression ratio so the per-row
@@ -828,7 +835,24 @@ export default function useDubWorkflow({
}),
{ duration: 6000 },
);
} else {
}
if (degraded.length) {
// Some segments missed the polish pass but translated fine — a
// warning with the honest story, not a red "failed" over a success.
// Fires ALONGSIDE the error toast when a response carries both:
// real failures shouldn't erase the story of the rows that
// succeeded plainly.
const unique = [...new Set(degraded.map((d) => d.reason))];
toast(
t('dub_workflow.translate_degraded', {
count: degraded.length,
totalCount: data.translated.length,
reason: unique[0].slice(0, 120),
}),
{ icon: '⚠️', duration: 8000 },
);
}
if (!errors.length && !degraded.length) {
const qLabel =
data.quality_used === 'cinematic' ? t('dub_workflow.translated_cinematic_suffix') : '';
toast.success(
+9
View File
@@ -65,6 +65,14 @@ export default function useSegmentEditing() {
if (field === 'text' && lang) {
next.translations = { ...s.translations, [lang]: value };
}
if (field === 'text') {
// The user rewrote the line — the machine-translation annotations
// ("translation error", "polish pass skipped") describe text that
// no longer exists. Leaving them makes the row wear a stale badge
// over human-authored words.
next.translate_error = undefined;
next.translate_degraded = undefined;
}
return next;
}),
);
@@ -118,6 +126,7 @@ export default function useSegmentEditing() {
text: restored,
...(lang ? { translations: { ...s.translations, [lang]: restored } } : {}),
translate_error: undefined,
translate_degraded: undefined,
};
}),
);
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "تناسب معدل الكلام: {{ratio}}× بالنسبة إلى الفتحة{{error}}",
"speaker_id": "معرف المتحدث",
"translate_error_title": "خطأ في الترجمة: {{error}}",
"translate_degraded_title": "تمت الترجمة (مباشرة) — تم تخطي خطوة الصقل: {{reason}}",
"budget_title": "النص هو {{pct}}% من النص الأصلي — فكر في سرعة أعلى أو صياغة أقصر",
"text_title": "Ctrl+D للتقسيم عند المؤشر · Ctrl+M للدمج مع التالي",
"orig_label": "أصل",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "تحتاج الجودة السينمائية إلى شهادة LLM - قم بتعيين TRANSLATE_BASE_URL + TRANSLATE_API_KEY (تعمل Ollama محليًا). العودة إلى سريع.",
"dialect_not_applied": "تحتاج اللهجة المحددة إلى LLM لتطبيقها — بدّل المحرك إلى OpenAI/Ollama أو اختر جودة Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} فشل المقطع (المقاطع): {{firstError}}",
"translate_degraded": "تمت ترجمة جميع المقاطع ({{totalCount}}) — استخدم {{count}} منها الترجمة المباشرة لأن خطوة الصقل تم تخطيها ({{reason}})",
"translated_segments": "تمت ترجمة {{count}} مقطع (مقاطع) → {{lang}}",
"translated_cinematic_suffix": "(سينمائي)",
"translation_failed": "فشلت الترجمة: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Sprachgeschwindigkeitsanpassung: {{ratio}}× relativ zum Slot{{error}}",
"speaker_id": "Sprecher-ID",
"translate_error_title": "Übersetzungsfehler: {{error}}",
"translate_degraded_title": "Übersetzt (einfach) — der Feinschliff wurde übersprungen: {{reason}}",
"budget_title": "Der Text besteht zu {{pct}} % aus dem Original erwägen Sie eine höhere Geschwindigkeit oder eine kürzere Formulierung",
"text_title": "Strg+D zum Teilen am Cursor · Strg+M zum Zusammenführen mit dem nächsten",
"orig_label": "orig",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "Für filmische Qualität ist ein LLM erforderlich setzen Sie TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funktioniert lokal). Zurückgreifen auf Fast.",
"dialect_not_applied": "Der gewählte Dialekt benötigt ein LLM — wechsle die Engine auf OpenAI/Ollama oder wähle die Cinematic-Qualität.",
"translate_errors": "{{errorCount}}/{{totalCount}} Segment(e) fehlgeschlagen: {{firstError}}",
"translate_degraded": "Alle {{totalCount}} Segment(e) übersetzt — {{count}} verwenden die einfache Übersetzung, da der Feinschliff übersprungen wurde ({{reason}})",
"translated_segments": "{{count}} Segment(e) → {{lang}} übersetzt",
"translated_cinematic_suffix": "(Filmisch)",
"translation_failed": "Übersetzung fehlgeschlagen: {{message}}",
+2
View File
@@ -1172,6 +1172,7 @@
"sync_label": "Sync: {{pct}}%",
"rate_title": "Speech-rate fit: {{ratio}}× relative to slot{{error}}",
"speaker_id": "Speaker ID",
"translate_degraded_title": "Translated (plain) — the polish pass was skipped: {{reason}}",
"translate_error_title": "Translation error: {{error}}",
"budget_title": "Text is {{pct}}% of original — consider higher speed or shorter phrasing",
"text_title": "Ctrl+D to split at cursor · Ctrl+M to merge with next",
@@ -2356,6 +2357,7 @@
"cinematic_no_llm": "Cinematic quality needs an LLM. Configure one in Settings → Credentials → LLM endpoint (Ollama runs locally, no key needed). Using Fast quality for now.",
"dialect_not_applied": "The selected dialect needs an LLM to apply. Switch the translation engine to OpenAI/Ollama, or configure an LLM in Settings → Credentials → LLM endpoint.",
"translate_errors": "{{errorCount}}/{{totalCount}} segment(s) failed: {{firstError}}",
"translate_degraded": "All {{totalCount}} segment(s) translated — {{count}} used the plain translation because the polish pass was skipped ({{reason}})",
"translated_segments": "Translated {{count}} segment(s) → {{lang}}",
"translated_cinematic_suffix": " (Cinematic)",
"translation_failed": "Translation failed: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Ajuste de la velocidad del habla: {{ratio}}× en relación con la ranura{{error}}",
"speaker_id": "Identificación del orador",
"translate_error_title": "Error de traducción: {{error}}",
"translate_degraded_title": "Traducido (simple) — se omitió el pulido: {{reason}}",
"budget_title": "El texto es {{pct}}% del original; considere una mayor velocidad o una redacción más corta",
"text_title": "Ctrl+D para dividir en el cursor · Ctrl+M para fusionar con el siguiente",
"orig_label": "origen",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "La calidad cinematográfica necesita un LLM: configure TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funciona localmente). Volviendo a Fast.",
"dialect_not_applied": "El dialecto seleccionado necesita un LLM para aplicarse — cambia el motor a OpenAI/Ollama o elige la calidad Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} segmento(s) fallidos: {{firstError}}",
"translate_degraded": "Los {{totalCount}} segmento(s) se tradujeron — {{count}} usan la traducción simple porque se omitió el pulido ({{reason}})",
"translated_segments": "{{count}} segmento(s) traducido(s) → {{lang}}",
"translated_cinematic_suffix": "(Cinemático)",
"translation_failed": "Error de traducción: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Ajustement du débit vocal : {{ratio}}× par rapport à l'emplacement{{error}}",
"speaker_id": "ID du haut-parleur",
"translate_error_title": "Erreur de traduction : {{error}}",
"translate_degraded_title": "Traduit (brut) — la passe de polissage a été ignorée : {{reason}}",
"budget_title": "Le texte représente {{pct}} % de l'original  envisagez une vitesse plus élevée ou une formulation plus courte",
"text_title": "Ctrl+D pour diviser au niveau du curseur · Ctrl+M pour fusionner avec le suivant",
"orig_label": "orig",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "La qualité cinématographique nécessite un LLM — définissez TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama fonctionne localement). Revenir à Fast.",
"dialect_not_applied": "Le dialecte sélectionné nécessite un LLM — passez le moteur sur OpenAI/Ollama ou choisissez la qualité Cinematic.",
"translate_errors": "Échec du ou des segments {{errorCount}}/{{totalCount}} : {{firstError}}",
"translate_degraded": "Les {{totalCount}} segment(s) ont été traduits — {{count}} utilisent la traduction brute car la passe de polissage a été ignorée ({{reason}})",
"translated_segments": "Segment(s) {{count}} traduit(s) → {{lang}}",
"translated_cinematic_suffix": "(Cinématique)",
"translation_failed": "Échec de la traduction : {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "वाक्-दर फिट: {{ratio}}× स्लॉट के सापेक्ष{{error}}",
"speaker_id": "स्पीकर आईडी",
"translate_error_title": "अनुवाद त्रुटि: {{error}}",
"translate_degraded_title": "अनुवादित (सादा) — परिष्करण चरण छोड़ा गया: {{reason}}",
"budget_title": "पाठ मूल का {{pct}}% है - उच्च गति या छोटे वाक्यांश पर विचार करें",
"text_title": "कर्सर पर विभाजित करने के लिए Ctrl+D · अगले के साथ विलय करने के लिए Ctrl+M",
"orig_label": "मूल",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "सिनेमाई गुणवत्ता के लिए एलएलएम की आवश्यकता होती है - सेट TRANSLATE_BASE_URL + TRANSLATE_API_KEY (ओलामा स्थानीय स्तर पर काम करता है)। तेजी से वापस गिरना।",
"dialect_not_applied": "चुनी गई बोली लागू करने के लिए LLM चाहिए — इंजन को OpenAI/Ollama पर बदलें या Cinematic गुणवत्ता चुनें।",
"translate_errors": "{{errorCount}}/{{totalCount}} खंड विफल: {{firstError}}",
"translate_degraded": "सभी {{totalCount}} खंड अनुवादित — {{count}} में सादा अनुवाद उपयोग हुआ क्योंकि परिष्करण चरण छोड़ दिया गया ({{reason}})",
"translated_segments": "अनूदित {{count}} खंड → {{lang}}",
"translated_cinematic_suffix": "(सिनेमाई)",
"translation_failed": "अनुवाद विफल: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Kesesuaian kecepatan bicara: {{ratio}}× relatif terhadap slot{{error}}",
"speaker_id": "ID Pembicara",
"translate_error_title": "Kesalahan terjemahan: {{error}}",
"translate_degraded_title": "Diterjemahkan (biasa) — tahap penyempurnaan dilewati: {{reason}}",
"budget_title": "Teks {{pct}}% dari aslinya — pertimbangkan kecepatan yang lebih tinggi atau frasa yang lebih pendek",
"text_title": "Ctrl+D untuk memisahkan kursor · Ctrl+M untuk menggabungkan dengan yang berikutnya",
"orig_label": "asal",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "Kualitas sinematik memerlukan LLM — setel TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama berfungsi secara lokal). Kembali ke Fast.",
"dialect_not_applied": "Dialek terpilih memerlukan LLM — ganti Engine ke OpenAI/Ollama atau pilih kualitas Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} segmen gagal: {{firstError}}",
"translate_degraded": "Semua {{totalCount}} segmen diterjemahkan — {{count}} memakai terjemahan biasa karena tahap penyempurnaan dilewati ({{reason}})",
"translated_segments": "Diterjemahkan {{count}} segmen → {{lang}}",
"translated_cinematic_suffix": "(Sinematik)",
"translation_failed": "Terjemahan gagal: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Adattamento della velocità della parola: {{ratio}}× relativo allo slot{{error}}",
"speaker_id": "ID dell'oratore",
"translate_error_title": "Errore di traduzione: {{error}}",
"translate_degraded_title": "Tradotto (semplice) — rifinitura saltata: {{reason}}",
"budget_title": "Il testo è il {{pct}}% dell'originale: considera una velocità maggiore o una frase più breve",
"text_title": "Ctrl+D per dividere in corrispondenza del cursore · Ctrl+M per unire con il successivo",
"orig_label": "orig",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "La qualità cinematografica richiede un LLM: imposta TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funziona localmente). Ritornando a Fast.",
"dialect_not_applied": "Il dialetto selezionato richiede un LLM — imposta il motore su OpenAI/Ollama o scegli la qualità Cinematic.",
"translate_errors": "Segmento/i {{errorCount}}/{{totalCount}} non riuscito: {{firstError}}",
"translate_degraded": "Tutti i {{totalCount}} segmenti tradotti — {{count}} usano la traduzione semplice perché la rifinitura è stata saltata ({{reason}})",
"translated_segments": "Segmento/i {{count}} tradotto/i → {{lang}}",
"translated_cinematic_suffix": "(Cinematologico)",
"translation_failed": "Traduzione non riuscita: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "音声速度の適合: {{ratio}}× (スロット{{error}} に対して)",
"speaker_id": "スピーカーID",
"translate_error_title": "翻訳エラー: {{error}}",
"translate_degraded_title": "翻訳済み(通常)— 仕上げ処理はスキップされました: {{reason}}",
"budget_title": "テキストはオリジナルの {{pct}}% です — 高速化または短い表現を検討してください",
"text_title": "Ctrl+D でカーソル位置で分割、Ctrl+M で次のカーソルとマージ",
"orig_label": "元の",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "映画のような品質には LLM が必要です — TRANSLATE_BASE_URL + TRANSLATE_API_KEY を設定します (Ollama はローカルで動作します)。 Fast に戻ります。",
"dialect_not_applied": "選択した方言の適用には LLM が必要です — エンジンを OpenAI/Ollama に切り替えるか、Cinematic 品質を選んでください。",
"translate_errors": "{{errorCount}}/{{totalCount}} セグメントが失敗しました: {{firstError}}",
"translate_degraded": "全 {{totalCount}} セグメントを翻訳しました — {{count}} 件は仕上げ処理がスキップされたため通常の翻訳を使用しています({{reason}}",
"translated_segments": "{{count}} セグメント → {{lang}} を翻訳しました",
"translated_cinematic_suffix": "(映画的)",
"translation_failed": "翻訳に失敗しました: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "음성 속도 맞춤: 슬롯에 상대적인 {{ratio}}×{{error}}",
"speaker_id": "스피커 ID",
"translate_error_title": "번역 오류: {{error}}",
"translate_degraded_title": "번역됨 (기본) — 다듬기 단계를 건너뜀: {{reason}}",
"budget_title": "텍스트가 원본의 {{pct}}%입니다. 더 빠른 속도나 더 짧은 문구를 고려하세요.",
"text_title": "커서에서 분할하려면 Ctrl+D · 다음 항목으로 병합하려면 Ctrl+M",
"orig_label": "원본",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "영화 품질에는 LLM이 필요합니다. TRANSLATE_BASE_URL + TRANSLATE_API_KEY를 설정하세요(Ollama는 로컬에서 작동함). Fast로 다시 돌아갑니다.",
"dialect_not_applied": "선택한 방언을 적용하려면 LLM이 필요합니다 — 엔진을 OpenAI/Ollama로 바꾸거나 Cinematic 품질을 선택하세요.",
"translate_errors": "{{errorCount}}/{{totalCount}} 세그먼트 실패: {{firstError}}",
"translate_degraded": "{{totalCount}}개 세그먼트 모두 번역됨 — {{count}}개는 다듬기 단계가 건너뛰어져 기본 번역을 사용합니다 ({{reason}})",
"translated_segments": "번역된 {{count}} 세그먼트 → {{lang}}",
"translated_cinematic_suffix": "(영화)",
"translation_failed": "번역 실패: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Passing op spraaksnelheid: {{ratio}}× relatief aan slot{{error}}",
"speaker_id": "Luidspreker-ID",
"translate_error_title": "Translation error: {{error}}",
"translate_degraded_title": "Vertaald (gewoon) — polijststap overgeslagen: {{reason}}",
"budget_title": "De tekst is {{pct}}% van het origineel. Overweeg een hogere snelheid of kortere formulering",
"text_title": "Ctrl+D om te splitsen bij de cursor · Ctrl+M om samen te voegen met de volgende",
"orig_label": "oorsprong",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "Filmische kwaliteit heeft een LLM nodig: stel TRANSLATE_BASE_URL + TRANSLATE_API_KEY in (Ollama werkt lokaal). Terugvallend op Snel.",
"dialect_not_applied": "Het gekozen dialect heeft een LLM nodig — zet de engine op OpenAI/Ollama of kies Cinematic-kwaliteit.",
"translate_errors": "{{errorCount}}/{{totalCount}} segment(en) mislukt: {{firstError}}",
"translate_degraded": "Alle {{totalCount}} segment(en) vertaald — {{count}} gebruiken de gewone vertaling omdat de polijststap is overgeslagen ({{reason}})",
"translated_segments": "Vertaald {{count}} segment(en) → {{lang}}",
"translated_cinematic_suffix": "(filmisch)",
"translation_failed": "Vertaling mislukt: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Dopasowanie szybkości mowy: {{ratio}}× względem szczeliny{{error}}",
"speaker_id": "Identyfikator głośnika",
"translate_error_title": "Błąd w tłumaczeniu: {{error}}",
"translate_degraded_title": "Przetłumaczono (zwykłe) — pominięto szlifowanie: {{reason}}",
"budget_title": "Tekst ma {{pct}}% oryginału — rozważ większą prędkość lub krótsze frazowanie",
"text_title": "Ctrl+D, aby podzielić przy kursorze · Ctrl+M, aby połączyć z następnym",
"orig_label": "oryg",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "Jakość kinowa wymaga LLM — ustaw TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama działa lokalnie). Wracając do Fasta.",
"dialect_not_applied": "Wybrany dialekt wymaga LLM — przełącz silnik na OpenAI/Ollama lub wybierz jakość Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} segment(y) nie powiodły się: {{firstError}}",
"translate_degraded": "Przetłumaczono wszystkie {{totalCount}} segmenty — {{count}} używa zwykłego tłumaczenia, bo pominięto etap szlifowania ({{reason}})",
"translated_segments": "Przetłumaczone segmenty {{count}} → {{lang}}",
"translated_cinematic_suffix": "(Kinowy)",
"translation_failed": "Tłumaczenie nie powiodło się: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Ajuste da taxa de fala: {{ratio}}× em relação ao slot{{error}}",
"speaker_id": "ID do palestrante",
"translate_error_title": "Erro de tradução: {{error}}",
"translate_degraded_title": "Traduzido (simples) — o polimento foi ignorado: {{reason}}",
"budget_title": "O texto é {{pct}}% do original considere velocidade mais alta ou fraseado mais curto",
"text_title": "Ctrl+D para dividir no cursor · Ctrl+M para mesclar com o próximo",
"orig_label": "original",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "A qualidade cinematográfica precisa de um LLM - defina TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funciona localmente). Voltando ao Fast.",
"dialect_not_applied": "O dialeto selecionado precisa de um LLM — mude o motor para OpenAI/Ollama ou escolha a qualidade Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} segmento(s) falhou: {{firstError}}",
"translate_degraded": "Todos os {{totalCount}} segmento(s) traduzidos — {{count}} usam a tradução simples porque o polimento foi ignorado ({{reason}})",
"translated_segments": "Segmento(s) {{count}} traduzido(s) → {{lang}}",
"translated_cinematic_suffix": "(Cinemático)",
"translation_failed": "Falha na tradução: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Соответствие скорости речи: {{ratio}}× относительно слота {{error}}",
"speaker_id": "Идентификатор докладчика",
"translate_error_title": "Ошибка перевода: {{error}}",
"translate_degraded_title": "Переведено (просто) — этап доводки пропущен: {{reason}}",
"budget_title": "Текст составляет {{pct}} % от оригинала. Рассмотрите возможность более быстрой или более короткой формулировки.",
"text_title": "Ctrl+D, чтобы разделить курсор · Ctrl+M, чтобы объединить со следующим",
"orig_label": "оригинал",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "Для кинематографического качества требуется LLM — установите TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama работает локально). Возвращаемся к Фасту.",
"dialect_not_applied": "Для выбранного диалекта нужен LLM — переключите движок на OpenAI/Ollama или выберите качество Cinematic.",
"translate_errors": "Сегмент(ы) {{errorCount}}/{{totalCount}} не удалось: {{firstError}}",
"translate_degraded": "Все {{totalCount}} сегмент(ов) переведены — {{count}} используют простой перевод, так как этап доводки был пропущен ({{reason}})",
"translated_segments": "Переведено сегмент(ов) {{count}} → {{lang}}",
"translated_cinematic_suffix": "(Кинематографический)",
"translation_failed": "Перевод не выполнен: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Talhastighetsanpassning: {{ratio}}× i förhållande till plats{{error}}",
"speaker_id": "Högtalar-ID",
"translate_error_title": "Översättningsfel: {{error}}",
"translate_degraded_title": "Översatt (enkel) — putsningssteget hoppades över: {{reason}}",
"budget_title": "Texten är {{pct}}% av originalet — överväg högre hastighet eller kortare frasering",
"text_title": "Ctrl+D för att dela vid markören · Ctrl+M för att slå samman med nästa",
"orig_label": "ursprung",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "Filmkvalitet behöver en LLM — ställ in TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama fungerar lokalt). Faller tillbaka till Fast.",
"dialect_not_applied": "Den valda dialekten kräver en LLM — byt motor till OpenAI/Ollama eller välj Cinematic-kvalitet.",
"translate_errors": "{{errorCount}}/{{totalCount}} segment(er) misslyckades: {{firstError}}",
"translate_degraded": "Alla {{totalCount}} segment översatta — {{count}} använder den enkla översättningen eftersom putsningssteget hoppades över ({{reason}})",
"translated_segments": "Översatta {{count}} segment(er) → {{lang}}",
"translated_cinematic_suffix": "(Cinematic)",
"translation_failed": "Översättning misslyckades: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "อัตราคำพูดพอดี: {{ratio}}× สัมพันธ์กับช่อง{{error}}",
"speaker_id": "รหัสผู้พูด",
"translate_error_title": "ข้อผิดพลาดในการแปล: {{error}}",
"translate_degraded_title": "แปลแล้ว (ตรงตัว) — ข้ามขั้นตอนขัดเกลา: {{reason}}",
"budget_title": "ข้อความมีความยาว {{pct}}% ของต้นฉบับ โปรดพิจารณาการใช้ข้อความที่เร็วขึ้นหรือใช้ถ้อยคำที่สั้นลง",
"text_title": "Ctrl+D เพื่อแยกที่เคอร์เซอร์ · Ctrl+M เพื่อรวมเข้ากับถัดไป",
"orig_label": "ต้นฉบับ",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "คุณภาพระดับภาพยนตร์จำเป็นต้องมี LLM — ตั้งค่า TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama ทำงานในเครื่อง) ถอยกลับไปอย่างรวดเร็ว",
"dialect_not_applied": "สำเนียงที่เลือกต้องใช้ LLM — เปลี่ยนเอนจินเป็น OpenAI/Ollama หรือเลือกคุณภาพ Cinematic",
"translate_errors": "{{errorCount}}/{{totalCount}} เซ็กเมนต์ล้มเหลว: {{firstError}}",
"translate_degraded": "แปลครบทั้ง {{totalCount}} ส่วนแล้ว — {{count}} ส่วนใช้คำแปลแบบตรงตัวเพราะข้ามขั้นตอนขัดเกลา ({{reason}})",
"translated_segments": "แปล {{count}} ส่วน → {{lang}}",
"translated_cinematic_suffix": "(ภาพยนตร์)",
"translation_failed": "การแปลล้มเหลว: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Konuşma hızı uyumu: {{ratio}}× yuvaya göre{{error}}",
"speaker_id": "Hoparlör Kimliği",
"translate_error_title": "Çeviri hatası: {{error}}",
"translate_degraded_title": "Çevrildi (düz) — cilalama adımı atlandı: {{reason}}",
"budget_title": "Metin orijinalin %{{pct}}'si kadardır; daha yüksek hız veya daha kısa ifadeler kullanmayı düşünün",
"text_title": "İmleçte bölmek için Ctrl+D · Sonrakiyle birleştirmek için Ctrl+M",
"orig_label": "köken",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "Sinematik kalitenin bir LLM'ye ihtiyacı vardır - TRANSLATE_BASE_URL + TRANSLATE_API_KEY'i ayarlayın (Ollama yerel olarak çalışır). Hızlı'ya geri dönüyorum.",
"dialect_not_applied": "Seçilen lehçe için LLM gerekir — motoru OpenAI/Ollama yapın veya Cinematic kalitesini seçin.",
"translate_errors": "{{errorCount}}/{{totalCount}} segment(ler)i başarısız oldu: {{firstError}}",
"translate_degraded": "{{totalCount}} segmentin tümü çevrildi — {{count}} tanesi cilalama adımı atlandığı için düz çeviriyi kullanıyor ({{reason}})",
"translated_segments": "{{count}} segment(ler) çevrildi → {{lang}}",
"translated_cinematic_suffix": "(Sinematik)",
"translation_failed": "Çeviri başarısız oldu: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Підгонка швидкості мовлення: {{ratio}}× відносно слота{{error}}",
"speaker_id": "ID спікера",
"translate_error_title": "Помилка перекладу: {{error}}",
"translate_degraded_title": "Перекладено (просто) — етап шліфування пропущено: {{reason}}",
"budget_title": "Текст становить {{pct}}% від оригіналу — подумайте про більшу швидкість або коротші фрази",
"text_title": "Ctrl+D, щоб розділити курсор, Ctrl+M, щоб об’єднати з наступним",
"orig_label": "ориг",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "Для кінематографічної якості потрібен LLM — установіть TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama працює локально). Повертаючись до Fast.",
"dialect_not_applied": "Обраний діалект потребує LLM — перемкніть рушій на OpenAI/Ollama або виберіть якість Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} сегментів не виконано: {{firstError}}",
"translate_degraded": "Усі {{totalCount}} сегменти перекладено — {{count}} використовують простий переклад, оскільки етап шліфування пропущено ({{reason}})",
"translated_segments": "Перекладено {{count}} сегментів → {{lang}}",
"translated_cinematic_suffix": "(кінематографічний)",
"translation_failed": "Помилка перекладу: {{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "Tốc độ nói phù hợp: {{ratio}}× so với vị trí{{error}}",
"speaker_id": "ID người nói",
"translate_error_title": "Lỗi dịch thuật: {{error}}",
"translate_degraded_title": "Đã dịch (thô) — bước trau chuốt bị bỏ qua: {{reason}}",
"budget_title": "Văn bản chiếm {{pct}}% so với bản gốc — hãy cân nhắc tốc độ cao hơn hoặc cụm từ ngắn hơn",
"text_title": "Ctrl+D để phân chia tại con trỏ · Ctrl+M để hợp nhất với phần tiếp theo",
"orig_label": "nguồn gốc",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "Chất lượng điện ảnh cần có LLM — đặt TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama hoạt động cục bộ). Trở lại Nhanh.",
"dialect_not_applied": "Phương ngữ đã chọn cần LLM để áp dụng — chuyển Engine sang OpenAI/Ollama hoặc chọn chất lượng Cinematic.",
"translate_errors": "{{errorCount}}/{{totalCount}} phân đoạn không thành công: {{firstError}}",
"translate_degraded": "Đã dịch toàn bộ {{totalCount}} phân đoạn — {{count}} dùng bản dịch thô vì bước trau chuốt bị bỏ qua ({{reason}})",
"translated_segments": "Đã dịch _V_0__ phân đoạn → {{lang}}",
"translated_cinematic_suffix": "(Điện ảnh)",
"translation_failed": "Dịch không thành công: {{message}}",
+2
View File
@@ -872,6 +872,7 @@
"rate_title": "语速适配:{{ratio}}× relative to slot{{error}}",
"speaker_id": "说话人 ID",
"translate_error_title": "翻译错误:{{error}}",
"translate_degraded_title": "已翻译(直译)— 润色步骤被跳过:{{reason}}",
"budget_title": "文本长度为原文的 {{pct}}% — 考虑加快语速或缩短措辞",
"text_title": "Ctrl+D 在光标处拆分 · Ctrl+M 与下一段合并",
"orig_label": "原文",
@@ -1735,6 +1736,7 @@
"cinematic_no_llm": "电影质量需要 LLM — 设置 TRANSLATE_BASE_URL + TRANSLATE_API_KEYOllama 在本地工作)。回落到快速。",
"dialect_not_applied": "所选方言需要 LLM 才能生效 — 请将引擎切换为 OpenAI/Ollama,或选择 Cinematic 质量。",
"translate_errors": "{{errorCount}}/{{totalCount}} 段失败:{{firstError}}",
"translate_degraded": "全部 {{totalCount}} 个片段已翻译 — 其中 {{count}} 个因润色步骤被跳过而使用直译({{reason}}",
"translated_segments": "翻译的 {{count}} 段 → {{lang}}",
"translated_cinematic_suffix": "(电影)",
"translation_failed": "翻译失败:{{message}}",
+2
View File
@@ -913,6 +913,7 @@
"rate_title": "語速擬合:{{ratio}}× 相對於插槽{{error}}",
"speaker_id": "發言者ID",
"translate_error_title": "翻譯錯誤:{{error}}",
"translate_degraded_title": "已翻譯(直譯)— 潤飾步驟被略過:{{reason}}",
"budget_title": "文本是原文的 {{pct}}% — 考慮更快的速度或更短的措詞",
"text_title": "Ctrl+D 在遊標處拆分 · Ctrl+M 與下一個合併",
"orig_label": "原始",
@@ -1728,6 +1729,7 @@
"cinematic_no_llm": "电影质量需要 LLM — 设置 TRANSLATE_BASE_URL + TRANSLATE_API_KEYOllama 在本地工作)。回落到快速。",
"dialect_not_applied": "所選方言需要 LLM 才能套用 — 請將引擎切換為 OpenAI/Ollama,或選擇 Cinematic 品質。",
"translate_errors": "{{errorCount}}/{{totalCount}} 段失敗:{{firstError}}",
"translate_degraded": "全部 {{totalCount}} 個片段已翻譯 — 其中 {{count}} 個因潤飾步驟被略過而使用直譯({{reason}}",
"translated_segments": "翻譯的 {{count}} 段 → {{lang}}",
"translated_cinematic_suffix": "(電影)",
"translation_failed": "翻譯失敗:{{message}}",
+1 -1
View File
@@ -234,7 +234,7 @@ def test_disabled_translation_reports_cinematic_unavailable(skills, store):
# and the per-segment refine degrades to the literal with the no-llm marker
out = translator.cinematic_refine_sync(
"hello", "hallo", source_lang="en", target_lang="de")
assert out["text"] == "hallo" and out.get("error") == "no-llm"
assert out["text"] == "hallo" and out.get("degraded") == "no-llm"
def test_disabled_refinement_is_pass_through(skills, store, monkeypatch):
@@ -0,0 +1,65 @@
"""Degraded ≠ failed: a skipped polish pass must not disable downstream passes.
The cinematic reflect/adapt chain is optional polish on any failure (rate
limit, budget, divergent reply, no LLM) the segment keeps its literal
translation and is fully usable. Those degradations used to be reported under
the same ``error`` key as real translation failures, which had three
consequences, each pinned here or in test_translator.py:
1. the UI toasted "N/N segment(s) failed" over a translate that succeeded
(frontend counts ``error`` rows);
2. the speech-rate / rate-ratio prediction skipped the row;
3. duration planning and the fit pass skipped the row overlong lines then
hit heavy time-compression at generation, audibly degrading the dub
(observed live: 4/4 reflect 429s no fit pass compressed segments).
``error`` now means "no usable text" (base translation failed); optional-pass
fallbacks ride a separate ``degraded`` key.
"""
from __future__ import annotations
from schemas.requests import TranslateRequest, TranslateSegment
def _req(n=2, slot=3.0):
return TranslateRequest(
segments=[
TranslateSegment(id=str(i), text=f"line {i}", slot_seconds=slot)
for i in range(1, n + 1)
],
target_lang="bn",
)
def test_degraded_rows_still_get_rate_ratio_prediction():
from api.routers.dub_translate import _stamp_predicted_rate_ratio
rows = [
{"id": "1", "text": "একটি অনুবাদিত লাইন", "degraded": "reflect: 429"},
{"id": "2", "text": "another line", "error": "llm-failed"},
]
_stamp_predicted_rate_ratio(rows, _req())
assert "rate_ratio" in rows[0], (
"a degraded row (usable literal text) was excluded from rate-ratio "
"prediction — degraded is being treated as failed again"
)
assert "rate_ratio" not in rows[1] # real failures stay excluded
def test_degraded_rows_still_get_duration_plan():
from api.routers.dub_translate import _stamp_duration_plan
req = TranslateRequest(
segments=[
TranslateSegment(id="1", text="a", slot_seconds=3.0, start=0.0, end=3.0),
TranslateSegment(id="2", text="b", slot_seconds=3.0, start=3.5, end=6.5),
],
target_lang="bn",
)
rows = [
{"id": "1", "text": "একটি অনুবাদিত লাইন", "degraded": "cinematic-budget"},
{"id": "2", "text": "another line", "error": "llm-failed"},
]
_stamp_duration_plan(rows, req)
assert "plan" in rows[0], "degraded row skipped by the duration planner"
assert "plan" not in rows[1]
+98 -6
View File
@@ -46,7 +46,8 @@ def test_cinematic_no_llm_returns_literal_with_marker(monkeypatch):
assert res["text"] == "Hola."
assert res["literal"] == "Hola."
assert res["critique"] == ""
assert res.get("error") == "no-llm"
assert res.get("degraded") == "no-llm"
assert "error" not in res
def test_cinematic_empty_literal_is_passthrough():
@@ -112,7 +113,8 @@ def test_cinematic_reflect_failure_returns_literal(monkeypatch):
)
assert res["text"] == "Hola"
assert res["literal"] == "Hola"
assert "reflect" in res.get("error", "")
assert "reflect" in res.get("degraded", "")
assert "error" not in res
# ── Divergence guard (v0.3.9 field report: hallucinated dub lines) ─────────
@@ -135,7 +137,8 @@ def test_cinematic_adapt_runaway_length_falls_back_to_literal(monkeypatch):
source_lang="en", target_lang="es",
)
assert res["text"] == literal
assert res.get("error") == "adapt-diverged"
assert res.get("degraded") == "adapt-diverged"
assert "error" not in res
assert res["critique"] == "fine but a bit stiff" # UI still sees what happened
@@ -152,7 +155,8 @@ def test_cinematic_adapt_critique_echo_rejected(monkeypatch):
source_lang="en", target_lang="es",
)
assert res["text"] == literal
assert res.get("error") == "adapt-diverged"
assert res.get("degraded") == "adapt-diverged"
assert "error" not in res
def test_cinematic_sane_adaptation_accepted(monkeypatch):
@@ -178,7 +182,8 @@ def test_cinematic_adapt_wrong_script_falls_back_to_literal(monkeypatch):
source_lang="en", target_lang="hi",
)
assert res["text"] == literal
assert res.get("error") == "adapt-wrong-script:hi"
assert res.get("degraded") == "adapt-wrong-script:hi"
assert "error" not in res
def test_chat_pins_low_temperature(monkeypatch):
@@ -241,7 +246,8 @@ def test_cinematic_budget_degrades_slow_segments_to_literal(monkeypatch):
assert [r["id"] for r in out] == ["s1", "s2"] # order + length preserved
for r in out:
assert r["text"] == r["literal"] # degraded to literal
assert r.get("error") == "cinematic-budget"
assert r.get("degraded") == "cinematic-budget"
assert "error" not in r
def test_cinematic_budget_disabled_runs_to_completion(monkeypatch):
@@ -263,3 +269,89 @@ def test_cinematic_budget_disabled_runs_to_completion(monkeypatch):
out = asyncio.run(_run())
assert out[0]["text"] == "R:hola" and "error" not in out[0]
# ── Retry-After honoring (#1133 class: a 2s throttle used to fail the pass) ──
class _RateLimit(Exception):
"""Shaped like an openai APIStatusError: status_code + response.headers."""
def __init__(self, retry_after=None):
super().__init__("429 simulated")
self.status_code = 429
class _Resp:
headers = {"retry-after": retry_after} if retry_after is not None else {}
self.response = _Resp()
def _client_429_then_ok(retry_after="2"):
"""chat.completions.create raises one 429, then succeeds."""
from unittest.mock import MagicMock
client = MagicMock()
calls = {"n": 0}
def create(**kw):
calls["n"] += 1
if calls["n"] == 1:
raise _RateLimit(retry_after)
res = MagicMock()
res.choices[0].message.content = "recovered"
return res
client.chat.completions.create = create
return client, calls
def test_chat_honors_retry_after_once(monkeypatch):
"""A 429 with a small Retry-After gets ONE polite wait + retry, not a hard
fail. OpenRouter's free pool says 'Retry-After: 2' — giving up instantly
turned a two-second wait into a whole failed reflect pass."""
sleeps = []
monkeypatch.setattr(tr.time, "sleep", lambda s: sleeps.append(s))
client, calls = _client_429_then_ok("2")
out = tr._chat(client, system="s", user="u")
assert out == "recovered"
assert calls["n"] == 2
assert len(sleeps) == 1 and 2.0 <= sleeps[0] <= 3.5 # Retry-After + jitter
def test_chat_caps_absurd_retry_after(monkeypatch):
"""A provider demanding a 10-minute wait gets the cap, not a stalled dub."""
sleeps = []
monkeypatch.setattr(tr.time, "sleep", lambda s: sleeps.append(s))
client, _ = _client_429_then_ok("600")
tr._chat(client, system="s", user="u")
assert sleeps and sleeps[0] <= tr._RETRY_AFTER_CAP_S + 1.5
def test_chat_second_429_propagates(monkeypatch):
"""One retry only — a persistent throttle degrades the segment instead of
looping."""
monkeypatch.setattr(tr.time, "sleep", lambda s: None)
from unittest.mock import MagicMock
client = MagicMock()
client.chat.completions.create = MagicMock(side_effect=_RateLimit("1"))
import pytest as _pytest
with _pytest.raises(_RateLimit):
tr._chat(client, system="s", user="u")
assert client.chat.completions.create.call_count == 2
def test_chat_non_429_does_not_retry(monkeypatch):
"""Only rate limits are retryable; real errors propagate immediately."""
slept = []
monkeypatch.setattr(tr.time, "sleep", lambda s: slept.append(s))
from unittest.mock import MagicMock
client = MagicMock()
client.chat.completions.create = MagicMock(side_effect=RuntimeError("boom"))
import pytest as _pytest
with _pytest.raises(RuntimeError):
tr._chat(client, system="s", user="u")
assert client.chat.completions.create.call_count == 1
assert not slept