fix(translate): run Cinematic/Autofit on every engine (incl. default Argos), bound the fit pass, scrub provider errors (#910)

P0 — Cinematic/Autofit silently no-op'd on argos/nllb/openai. Those three
branches returned BEFORE _maybe_cinematic, so only the deep_translator
fall-through reached the refine/fit pass. A user on the DEFAULT Argos engine
who picked Cinematic/Autofit got plain Fast output with a success toast and
no quality_used/cinematic_skipped/rate_ratio. All three now route through
_maybe_cinematic. provider=openai is already an LLM translation, so it skips
the reflect/adapt re-refine (new already_llm flag) but still stamps
rate-ratio badges and runs the Autofit fit pass; the dialect it baked into
its translate prompt is now reported applied.

P1 — the Autofit fit pass ran one blocking adjust_for_slot per segment in the
merge loop, OUTSIDE any budget (a 50-seg dub vs a slow provider spun
~50×timeout unbounded). New speech_rate.adjust_for_slot_many fans it out
concurrently under a wall-clock deadline SHARED with the cinematic refine;
segments still running at the deadline degrade to their literal with
rate_error='fit-budget'. Also set max_retries=0 on the OpenAI clients used
for translate/refine/fit so a 429 + Retry-After can't sleep through the budget.

P2 — glossary auto-extract's no-LLM message now points at Settings → LLM
Providers (was the stale TRANSLATE_BASE_URL/TRANSLATE_API_KEY). Provider error
bodies on the glossary auto-extract, the OpenAI translate-segment path, and the
DeepL/Microsoft translate-segment path are now scrubbed
(core.scrub.scrub_provider_error) — they could echo the API key / a user_id.
DubTab re-polls LLM availability on window focus / visibility so configuring a
provider in Settings lifts the Cinematic gate without a remount. Documented
LLM_DEFAULT_PROVIDER in docs/dubbing/translation-engines.md.

Tests: fail-before/pass-after for argos+cinematic (refine runs), argos+cinematic
no-LLM (cinematic_skipped), argos Fast (rate_ratio stamped), openai+autofit
budget bound, and provider-error scrubbing on the translate + glossary paths.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-02 23:53:11 +05:30
committed by GitHub
co-authored by mergetest Claude Fable 5
parent 75864a597f
commit af6690840e
10 changed files with 559 additions and 87 deletions
+148 -73
View File
@@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse
from schemas.requests import TranslateRequest
from services.model_manager import _cpu_pool, _gpu_pool
from services.translator import cinematic_available, cinematic_refine_many
from services.translator import cinematic_available, cinematic_refine_many, _cinematic_budget
from api.routers.dub_core import _get_job
router = APIRouter()
@@ -302,15 +302,20 @@ async def dub_translate(req: TranslateRequest):
translated = await loop.run_in_executor(_gpu_pool, _translate_nllb)
if os.environ.get("OMNIVOICE_UNLOAD_NLLB", "1") == "1":
_unload_nllb()
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
**_dialect_flags(req, applied=False)}
# Cinematic/Autofit refine + rate-ratio badges must run for NLLB too
# (previously this returned before _maybe_cinematic, so a Cinematic
# pick on NLLB silently produced plain Fast output). Unloading NLLB
# first is fine — the refine LLM is a separate network provider.
return await _maybe_cinematic(translated, req, src_lang, loop)
# OpenAI / Ollama Local LLM Translation
if provider == "openai":
base_url = os.environ.get("TRANSLATE_BASE_URL")
model_name = os.environ.get("TRANSLATE_MODEL", "gpt-3.5-turbo")
from openai import OpenAI
client = OpenAI(base_url=base_url, api_key=api_key or "local")
# max_retries=0: a 429 + long Retry-After must not let one segment's
# SDK call sleep+retry and blow the overall translate wall time.
client = OpenAI(base_url=base_url, api_key=api_key or "local", max_retries=0)
def _build_prompt(src_code: str, tgt_code: str) -> str:
"""Build a system prompt that resists hallucinations on small
@@ -399,14 +404,22 @@ async def dub_translate(req: TranslateRequest):
seg.id, attempt + 1, e,
)
# Both attempts failed — keep source text + flag error so the
# frontend can surface "fallback to literal" warning.
return {"id": seg.id, "text": seg.text, "error": last_err or "llm-failed"}
# frontend can surface "fallback to literal" warning. Scrub the
# provider error: some OpenAI-compatible providers echo the key
# or a user_id in the body, which must not reach the UI verbatim.
from core.scrub import scrub_provider_error
return {"id": seg.id, "text": seg.text,
"error": scrub_provider_error(last_err, api_key) or "llm-failed"}
tasks = [loop.run_in_executor(_cpu_pool, _translate_llm, seg) for seg in req.segments]
translated = await asyncio.gather(*tasks)
translated.sort(key=lambda x: str(x["id"]))
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
**_dialect_flags(req, applied=True)}
# provider="openai" is already an LLM translation — _maybe_cinematic
# skips the reflect/adapt re-refine (already_llm) but still stamps
# rate-ratio badges and runs the bounded Autofit fit pass. Before
# this it returned here, so Cinematic/Autofit on the LLM engine did
# nothing.
return await _maybe_cinematic(translated, req, src_lang, loop, already_llm=True)
# Offline Argos Translate
if provider == "argos" or provider == "libretranslate":
@@ -465,8 +478,11 @@ async def dub_translate(req: TranslateRequest):
return results
translated = await loop.run_in_executor(_cpu_pool, _translate_argos)
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
**_dialect_flags(req, applied=False)}
# Argos is the DEFAULT engine — routing it through _maybe_cinematic is
# the headline fix: a user who picks Cinematic/Autofit on Argos now
# gets the LLM refine + fit pass (and rate-ratio badges in Fast mode)
# instead of silent plain-Fast output.
return await _maybe_cinematic(translated, req, src_lang, loop)
# Legacy / API Deep_Translator logic.
# Preflight the optional `deep_translator` dep once so we fail with a
@@ -540,7 +556,11 @@ async def dub_translate(req: TranslateRequest):
)
time.sleep(0.25 * (attempt + 1))
logger.error("translate %s -> %s gave up (provider=%s): %s", src_arg, seg_lc, provider, last_err)
return {"id": seg.id, "text": seg.text, "error": last_err or "unknown"}
# Scrub before it reaches the UI — DeepL/Microsoft errors can echo
# the API key (same class as the OpenAI user_id leak).
from core.scrub import scrub_provider_error
return {"id": seg.id, "text": seg.text,
"error": scrub_provider_error(last_err, _deepl_key or _msft_key or api_key) or "unknown"}
tasks = [loop.run_in_executor(_cpu_pool, _translate_single, seg) for seg in req.segments]
translated = await asyncio.gather(*tasks)
@@ -554,24 +574,19 @@ async def dub_translate(req: TranslateRequest):
return JSONResponse(status_code=500, content={"error": str(e)})
async def _maybe_cinematic(translated, req, src_lang, loop):
"""If quality=cinematic and a usable LLM is configured, run REFLECT+ADAPT.
Otherwise return Fast-mode shape unchanged.
def _stamp_predicted_rate_ratio(translated, req) -> None:
"""Stamp a predicted ``rate_ratio`` on every row that has a known slot.
No LLM needed — just the per-language CPS table from ``services/speech_rate``.
The UI's ``seg-rate-badge`` reads it (Fast mode included) to show which
segments will compress hard at generation time, so users can edit text or
pick a heavier quality. Mutates ``translated`` in place; never raises.
"""
quality = (getattr(req, "quality", None) or "fast").lower()
# Stamp the predicted rate_ratio on every translated row that has a
# known slot. Works for Fast mode too — no LLM needed; just the CPS
# table from services/speech_rate. The UI's `seg-rate-badge` reads
# this value and shows users which segments will compress hard at
# generation time, so they can edit text or pick Cinematic quality.
try:
from services.speech_rate import rate_ratio as _predict_rate_ratio
slots = {str(s.id): getattr(s, "slot_seconds", None) for s in req.segments}
for row in translated:
seg_ref = next(
(s for s in req.segments if str(s.id) == str(row["id"])),
None,
)
slot = getattr(seg_ref, "slot_seconds", None) if seg_ref else None
slot = slots.get(str(row["id"]))
text = (row.get("text") or "").strip()
if slot and text and not row.get("error"):
row["rate_ratio"] = round(
@@ -580,22 +595,119 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
except Exception as e:
logger.debug("non-LLM rate_ratio prediction skipped: %s", e)
base = {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
"quality_used": "fast", **_dialect_flags(req, applied=False)}
# Autofit is Cinematic + a strict "never exceed the slot" fit pass, so both
# qualities take the LLM refine path below. Fast (and anything else) returns
# the plain translation unchanged.
async def _apply_fit_pass(rows, req, slots_by_id, source_by_id, quality, loop, deadline) -> None:
"""Run the Autofit slot-fit pass over ``rows`` concurrently, in place.
Bounded by ``deadline`` (shared with the cinematic refine) so a slow /
rate-limited LLM can't spin the fit pass per-segment unbounded — the old
behavior, which ran one blocking ``adjust_for_slot`` per segment in the
merge loop, outside any budget. Segments still running at the deadline keep
their current text and get ``rate_error='fit-budget'``. Only rows with a
slot + text + no prior error participate.
"""
strict = (quality == "autofit")
items = []
for row in rows:
seg_id = str(row["id"])
slot = slots_by_id.get(seg_id)
text = row.get("text") or ""
if slot and text and not row.get("error"):
items.append((seg_id, text, float(slot), req.target_lang,
source_by_id.get(seg_id), strict))
if not items:
return
try:
from services.speech_rate import adjust_for_slot_many
fits = await adjust_for_slot_many(
items, executor=_cpu_pool, deadline=deadline, loop=loop,
)
except Exception as e:
logger.warning("rate-fit pass skipped: %s", e)
return
for row in rows:
f = fits.get(str(row["id"]))
if not f:
continue
if f.get("text"):
row["text"] = f["text"]
if f.get("rate_ratio") is not None:
row["rate_ratio"] = f["rate_ratio"]
if f.get("error"):
row["rate_error"] = f["error"]
async def _maybe_cinematic(translated, req, src_lang, loop, *, already_llm=False):
"""Post-process a literal translation into Cinematic/Autofit output.
Runs for EVERY provider now (Argos/NLLB/Google/…/OpenAI). The three
LLM-independent branches (nllb/argos) and the openai branch used to return
*before* reaching this, so a Cinematic/Autofit pick on them — including the
DEFAULT Argos engine — silently produced plain Fast output with a success
toast. Fast mode still returns the plain translation (plus rate-ratio badges).
``already_llm`` (provider="openai"): the translation was itself produced by
an LLM, so the REFLECT+ADAPT *re*-refine is skipped, but the bounded Autofit
fit pass + rate-ratio stamping still run, and the dialect the translate
prompt already baked in is reported as applied.
"""
quality = (getattr(req, "quality", None) or "fast").lower()
_stamp_predicted_rate_ratio(translated, req)
# #280 item 2 — regional dialect hint, guarded against a stale dialect from
# another language. For already_llm the initial translate prompt already
# applied it, so it's reported applied in the Fast-shape base too.
dialect_hint = ""
_dialect = getattr(req, "dialect", None)
if _dialect and str(_dialect).lower().startswith(str(req.target_lang).lower()[:2]):
dialect_hint = dialect_clause(_dialect)
base = {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
"quality_used": "fast",
**_dialect_flags(req, applied=(already_llm and bool(dialect_hint)))}
# Fast (and anything unrecognised) returns the plain translation unchanged.
if quality not in ("cinematic", "autofit"):
return base
source_by_id: dict[str, str] = {str(s.id): s.text for s in req.segments}
slots_by_id = {
str(s.id): getattr(s, "slot_seconds", None)
for s in req.segments
if getattr(s, "slot_seconds", None)
}
# One wall-clock deadline shared by the whole LLM phase (refine + fit), so a
# slow/rate-limited provider can't run either pass unbounded. <=0 disables.
budget = _cinematic_budget()
deadline = (loop.time() + budget) if budget and budget > 0 else None
# provider="openai": already an LLM translation → skip REFLECT+ADAPT, keep
# the rate-ratio badges, still run the bounded fit pass.
if already_llm:
merged = []
for row in translated:
out = {"id": row["id"],
"text": row.get("text", "") or "",
"literal": row.get("text", "") or ""}
if row.get("error"):
out["error"] = row["error"]
if "rate_ratio" in row:
out["rate_ratio"] = row["rate_ratio"]
merged.append(out)
await _apply_fit_pass(merged, req, slots_by_id, source_by_id, quality, loop, deadline)
return {"translated": merged, "target_lang": req.target_lang,
"source_lang": src_lang, "quality_used": quality,
**_dialect_flags(req, applied=bool(dialect_hint))}
# Non-LLM provider → the reflect/adapt refine needs a separately-configured
# LLM (Settings → LLM Providers). Without one, degrade to Fast with a flag.
if not cinematic_available():
logger.warning("%s requested but no LLM configured — returning Fast result.", quality)
base["cinematic_skipped"] = "no-llm-configured"
return base
# Build a map from id → original segment (to fetch source text + direction).
source_by_id: dict[str, str] = {str(s.id): s.text for s in req.segments}
directions: dict[str, str] = {
str(s.id): s.direction
for s in req.segments
@@ -603,7 +715,7 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
}
pairs = []
passthrough_index = {}
for i, row in enumerate(translated):
for row in translated:
seg_id = str(row["id"])
literal = row.get("text", "") or ""
if row.get("error") or not literal.strip():
@@ -614,12 +726,6 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
if not pairs:
return base
# #280 item 2: thread the regional-dialect hint into the reflect/adapt
# prompts. Guard against a stale dialect from another language.
dialect_hint = ""
if req.dialect and str(req.dialect).lower().startswith(str(req.target_lang).lower()[:2]):
dialect_hint = dialect_clause(req.dialect)
refined = await cinematic_refine_many(
pairs,
source_lang=src_lang,
@@ -631,16 +737,6 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
)
refined_by_id = {r["id"]: r for r in refined}
# Phase 4.4 — speech-rate fit pass. Segment boundaries aren't in the
# translate request (by design — translator is boundary-agnostic), so we
# only run it when the caller supplied `slot_seconds` on each segment.
# The frontend populates this for Cinematic calls from the edit view.
slots_by_id = {
str(s.id): getattr(s, "slot_seconds", None)
for s in req.segments
if getattr(s, "slot_seconds", None)
}
merged = []
for row in translated:
seg_id = str(row["id"])
@@ -659,32 +755,11 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
}
if r.get("error"):
out["error"] = r["error"]
# Optional slot-fit pass — only when the caller asked for cinematic
# *and* provided a slot. Runs best-effort; no-LLM or mid-loop failure
# just leaves the cinematic text untouched.
slot = slots_by_id.get(seg_id)
if slot and out["text"]:
try:
from services.speech_rate import adjust_for_slot
fit = await asyncio.to_thread(
adjust_for_slot,
out["text"],
slot_seconds=float(slot),
target_lang=req.target_lang,
source_text=source_by_id.get(seg_id),
strict=(quality == "autofit"),
)
if fit.get("text"):
out["text"] = fit["text"]
out["rate_ratio"] = fit.get("rate_ratio")
if fit.get("error"):
out["rate_error"] = fit["error"]
except Exception as e:
logger.warning("rate-fit skipped for %s: %s", seg_id, e)
merged.append(out)
# Phase 4.4 speech-rate fit pass — now concurrent + bounded (see helper).
await _apply_fit_pass(merged, req, slots_by_id, source_by_id, quality, loop, deadline)
return {
"translated": merged,
"target_lang": req.target_lang,
+13 -3
View File
@@ -196,8 +196,9 @@ def auto_extract(project_id: str, req: AutoExtractRequest):
raise HTTPException(
status_code=503,
detail=(
"Auto-extract needs an LLM. Set TRANSLATE_BASE_URL + TRANSLATE_API_KEY "
"(Ollama works locally: base_url=http://localhost:11434/v1) and try again."
"Auto-extract needs an LLM. Set one up in Settings → LLM Providers "
"(pick a provider, add its key, choose a model, Test) — or use local "
"Ollama / LM Studio for a fully offline setup — then try again."
),
)
@@ -231,9 +232,18 @@ def auto_extract(project_id: str, req: AutoExtractRequest):
body = (res.choices[0].message.content or "").strip()
except Exception as e:
logger.warning("auto-extract LLM call failed: %s", e)
# Scrub the provider error — some OpenAI-compatible providers echo the
# API key or a user_id in the body, which must not reach the UI verbatim.
from core.scrub import scrub_provider_error
from services import llm_providers
_p = llm_providers.active_provider()
_key = llm_providers.resolve_api_key(_p) if _p else None
raise HTTPException(
status_code=502,
detail=f"LLM didn't respond. Check Settings → Logs → Backend for the trace. Error: {e}",
detail=(
"LLM didn't respond. Check Settings → Logs → Backend for the trace. "
f"Error: {scrub_provider_error(e, _key)}"
),
)
# Parse: SOURCE || TARGET || note (lines are allowed to be sloppy — we're forgiving).
+21
View File
@@ -134,3 +134,24 @@ def scrub_text(text: str | None) -> str:
pass
return s
def scrub_provider_error(detail: object, api_key: str | None = None) -> str:
"""UI-safe text for an LLM/translation provider failure.
Some OpenAI-compatible providers echo the caller's key or a stable
``user_id`` back inside their error bodies, and a raw ``str(exc)`` on the
translate / glossary paths would surface that verbatim. This redacts the
exact resolved ``api_key`` first (in the provider-registry case it isn't a
shaped/known-env secret, so ``scrub_text`` alone can miss it) then runs the
generic secret + home-path scrub. Never raises — scrubbing must not mask a
failure with a new one. Mirrors ``settings._scrub_llm_detail`` so every
surface redacts identically.
"""
s = str(detail if detail is not None else "")
try:
if api_key and api_key != "local" and len(api_key) >= _MIN_SECRET_LEN:
s = s.replace(api_key, REDACTED)
except Exception:
pass
return scrub_text(s)
+3 -1
View File
@@ -117,7 +117,9 @@ class OpenAICompatBackend(LLMBackend):
kw = {"api_key": api_key}
if base_url:
kw["base_url"] = base_url
self._client = OpenAI(**kw)
# max_retries=0 so a 429 + Retry-After can't make one chat() sleep
# through the Autofit fit-pass wall-clock budget (speech_rate).
self._client = OpenAI(max_retries=0, **kw)
return self._client
def chat(self, *, system: str, user: str, timeout: Optional[float] = None) -> str:
+75
View File
@@ -171,3 +171,78 @@ def adjust_many(pairs: Iterable[tuple[str, float, str, Optional[str]]]) -> list[
adjust_for_slot(t, slot_seconds=s, target_lang=tl, source_text=src)
for (t, s, tl, src) in pairs
]
async def adjust_for_slot_many(
items: Iterable[tuple],
*,
executor=None,
concurrency: Optional[int] = None,
deadline: Optional[float] = None,
loop=None,
) -> dict:
"""Fan `adjust_for_slot` out across many segments concurrently, bounded by a
shared wall-clock ``deadline``.
``items``: iterable of ``(key, text, slot_seconds, target_lang,
source_text_or_None, strict)``. Returns ``{key: adjust_for_slot_result}``.
Why this exists: the Autofit fit pass used to run one `adjust_for_slot` per
segment *sequentially* and *outside* any budget, so a 50-segment dub against
a slow/rate-limited LLM spun ~50×(per-call timeout) unbounded. Here every
segment runs on the executor under a bounded ``asyncio.Semaphore``, and any
segment still running when the shared ``deadline`` passes degrades to a
no-fit result (input text kept, predicted ``rate_ratio``, ``error`` =
``"fit-budget"``) instead of hanging the translate. ``deadline`` is an
absolute ``loop.time()``; ``None`` disables the bound (run to completion).
"""
import asyncio
import os
loop = loop or asyncio.get_running_loop()
items = list(items)
if not items:
return {}
sem = asyncio.Semaphore(concurrency or int(os.environ.get("OMNIVOICE_LLM_CONCURRENCY", "6")))
async def _one(key, text, slot, tgt, src, strict):
async with sem:
res = await loop.run_in_executor(
executor,
lambda: adjust_for_slot(
text, slot_seconds=slot, target_lang=tgt,
source_text=src, strict=strict,
),
)
return key, res
def _degraded(text, slot, tgt) -> dict:
return {
"text": text,
"rate_ratio": rate_ratio(text, slot, tgt),
"attempts": 0,
"error": "fit-budget",
}
tasks = [asyncio.ensure_future(_one(*it)) for it in items]
if deadline is None:
pairs_out = await asyncio.gather(*tasks)
return dict(pairs_out)
timeout = max(0.0, deadline - loop.time())
done, _pending = await asyncio.wait(tasks, timeout=timeout)
out: dict = {}
for task, it in zip(tasks, items):
key, text, slot, tgt = it[0], it[1], it[2], it[3]
if task in done and not task.cancelled():
try:
k, res = task.result()
out[k] = res
continue
except Exception as e: # noqa: BLE001 — one slow seg must not sink the pass
logger.warning("fit segment %s failed: %s", key, e)
else:
task.cancel() # stop awaiting; the executor thread is abandoned (#730 pattern)
out[key] = _degraded(text, slot, tgt)
return out
+5 -1
View File
@@ -119,7 +119,11 @@ def _llm_client():
kw = {"api_key": api_key}
if base_url:
kw["base_url"] = base_url
return OpenAI(**kw)
# max_retries=0: a rate-limited provider returning 429 + a long Retry-After
# would otherwise let the SDK sleep+retry per call and blow the cinematic
# wall-clock budget from inside a single request. The pass-level budget
# (cinematic_refine_many) and per-call timeout are the only bounds we want.
return OpenAI(max_retries=0, **kw)
def _llm_model() -> str:
+19
View File
@@ -102,6 +102,25 @@ provider via environment variables (e.g. `GROQ_API_KEY`, or the legacy
`TRANSLATE_BASE_URL` / `TRANSLATE_API_KEY` / `TRANSLATE_MODEL`, which map to the
**Custom** provider).
### Pinning the active provider with `LLM_DEFAULT_PROVIDER`
By default the LLM used for Cinematic/Autofit is the one you mark "use for
translation" in **Settings → LLM Providers**. To force a specific provider
regardless of that stored selection — handy for headless/CI/Docker runs or a
shared machine — set the `LLM_DEFAULT_PROVIDER` environment variable to a
provider id before launching the backend:
```
LLM_DEFAULT_PROVIDER=groq # or openai, openrouter, cerebras, ollama, custom, …
```
Resolution order for the active provider is: `LLM_DEFAULT_PROVIDER` (env) →
your saved selection → the first provider that has a key → none. The id must be
one OmniVoice knows (the ids shown in **Settings → LLM Providers**); an unknown
value is ignored and resolution falls through to your saved selection. While
this env var is set it wins over the in-app picker, so if the UI selection
appears to have "no effect," check whether `LLM_DEFAULT_PROVIDER` is exported.
## API keys (online MT engines)
The non-LLM online engines need a key, set as an environment variable before
+21 -9
View File
@@ -111,17 +111,29 @@ export default function DubTab(props) {
const [llmEndpoint, setLlmEndpoint] = useState(null);
useEffect(() => {
let cancelled = false;
import('../api/client').then(({ apiJson }) =>
apiJson('/api/settings/llm-endpoint')
.then((d) => {
if (!cancelled) setLlmEndpoint(d);
})
.catch(() => {
/* backend mid-boot — guard simply stays permissive */
}),
);
const refresh = () =>
import('../api/client').then(({ apiJson }) =>
apiJson('/api/settings/llm-endpoint')
.then((d) => {
if (!cancelled) setLlmEndpoint(d);
})
.catch(() => {
/* backend mid-boot — guard simply stays permissive */
}),
);
refresh();
// Re-poll when the window regains focus / becomes visible — configuring a
// provider in Settings → LLM Providers otherwise wouldn't lift the Cinematic
// gate until this tab remounted (the fetch used to be mount-only, `[]`).
const onVisible = () => {
if (document.visibilityState === 'visible') refresh();
};
window.addEventListener('focus', refresh);
document.addEventListener('visibilitychange', onVisible);
return () => {
cancelled = true;
window.removeEventListener('focus', refresh);
document.removeEventListener('visibilitychange', onVisible);
};
}, []);
const dualSubs = useAppStore((s) => s.dualSubs);
+188
View File
@@ -226,3 +226,191 @@ async def test_empty_translation_preserves_original(monkeypatch):
seg = resp['translated'][0]
assert seg['text'] == 'hi'
assert 'error' in seg
# ── P0: Cinematic/Autofit must run on the non-deep_translator engines ────────
# Before this fix the argos/nllb/openai branches returned BEFORE
# _maybe_cinematic, so picking Cinematic/Autofit on the DEFAULT Argos engine
# silently produced plain Fast output (no quality_used/refine/rate badges).
def _install_fake_argos(monkeypatch):
"""Register a fake `argostranslate` package that translates en→es to
`[es]<text>` with a pre-installed package, so the argos branch runs offline."""
import sys
import types
class _Pkg:
from_code = "en"
to_code = "es"
pkg = types.ModuleType("argostranslate.package")
pkg.get_installed_packages = lambda: [_Pkg()]
pkg.update_package_index = lambda: None
pkg.get_available_packages = lambda: []
pkg.install_from_path = lambda p: None
tr = types.ModuleType("argostranslate.translate")
tr.translate = lambda text, frm, to: f"[{to}]{text}"
root = types.ModuleType("argostranslate")
root.package = pkg
root.translate = tr
monkeypatch.setitem(sys.modules, "argostranslate", root)
monkeypatch.setitem(sys.modules, "argostranslate.package", pkg)
monkeypatch.setitem(sys.modules, "argostranslate.translate", tr)
@pytest.mark.asyncio
async def test_argos_cinematic_refines_with_llm(monkeypatch):
"""DEFAULT engine + Cinematic + a usable LLM → refine actually runs and the
response carries quality_used=='cinematic' plus the literal/critique fields."""
from api.routers import dub_translate
from schemas.requests import TranslateRequest, TranslateSegment
_install_fake_argos(monkeypatch)
async def fake_refine_many(pairs, **kw):
return [
{"id": sid, "text": f"CINE:{lit}", "literal": lit, "critique": "crit"}
for sid, _src, lit in pairs
]
monkeypatch.setattr(dub_translate, "cinematic_available", lambda: True)
monkeypatch.setattr(dub_translate, "cinematic_refine_many", fake_refine_many)
req = TranslateRequest(
segments=[TranslateSegment(id="s1", text="Hello")],
target_lang="es", provider="argos", source_lang="en", quality="cinematic",
)
resp = await dub_translate.dub_translate(req)
assert resp["quality_used"] == "cinematic"
row = resp["translated"][0]
assert row["literal"] == "[es]Hello" # the argos literal is preserved
assert row["text"] == "CINE:[es]Hello" # and it was actually refined
assert row["critique"] == "crit"
@pytest.mark.asyncio
async def test_argos_cinematic_skipped_without_llm(monkeypatch):
"""DEFAULT engine + Cinematic + NO LLM → degrades to Fast with an explicit
cinematic_skipped flag (not a silent success)."""
from api.routers import dub_translate
from schemas.requests import TranslateRequest, TranslateSegment
_install_fake_argos(monkeypatch)
monkeypatch.setattr(dub_translate, "cinematic_available", lambda: False)
req = TranslateRequest(
segments=[TranslateSegment(id="s1", text="Hello")],
target_lang="es", provider="argos", source_lang="en", quality="cinematic",
)
resp = await dub_translate.dub_translate(req)
assert resp["cinematic_skipped"] == "no-llm-configured"
assert resp["quality_used"] == "fast"
assert resp["translated"][0]["text"] == "[es]Hello" # literal kept
@pytest.mark.asyncio
async def test_argos_fast_stamps_rate_ratio(monkeypatch):
"""Fast on the DEFAULT engine still reaches the rate-ratio stamping so the
UI's seg-rate-badge has data (it used to return before _maybe_cinematic)."""
from api.routers import dub_translate
from schemas.requests import TranslateRequest, TranslateSegment
_install_fake_argos(monkeypatch)
req = TranslateRequest(
segments=[TranslateSegment(id="s1", text="Hello", slot_seconds=2.0)],
target_lang="es", provider="argos", source_lang="en", quality="fast",
)
resp = await dub_translate.dub_translate(req)
assert resp["quality_used"] == "fast"
assert "rate_ratio" in resp["translated"][0]
def _install_fake_openai(monkeypatch, *, content="hola mundo", raises=None):
"""Register a fake `openai.OpenAI` whose chat.completions.create returns
`content` (or raises `raises`). Accepts the max_retries kwarg the code adds."""
import sys
import types
class _Completions:
def create(self, **kw):
if raises is not None:
raise raises
msg = type("M", (), {"content": content})
choice = type("C", (), {"message": msg})
return type("R", (), {"choices": [choice]})
class _Chat:
completions = _Completions()
class _FakeClient:
def __init__(self, **kw):
pass
chat = _Chat()
mod = types.ModuleType("openai")
mod.OpenAI = _FakeClient
monkeypatch.setitem(sys.modules, "openai", mod)
# ── P1: the Autofit fit pass must be bounded by the cinematic wall-clock ─────
@pytest.mark.asyncio
async def test_openai_autofit_fit_pass_is_budget_bounded(monkeypatch):
"""A slow fit LLM must not spin one adjust_for_slot per segment unbounded:
the whole translate returns within the budget and unfinished segments
degrade to their literal (fit-budget) instead of hanging."""
import time
from api.routers import dub_translate
from schemas.requests import TranslateRequest, TranslateSegment
from services import speech_rate
_install_fake_openai(monkeypatch, content="hola mundo")
monkeypatch.setenv("OMNIVOICE_CINEMATIC_BUDGET_S", "0.3")
def _slow_fit(text, *, slot_seconds, target_lang, source_text=None, strict=False):
time.sleep(3.0) # far over the 0.3s budget
return {"text": "FIT-SHOULD-NOT-WIN", "rate_ratio": 1.0}
monkeypatch.setattr(speech_rate, "adjust_for_slot", _slow_fit)
req = TranslateRequest(
segments=[
TranslateSegment(id="s1", text="Hello", slot_seconds=1.0),
TranslateSegment(id="s2", text="World", slot_seconds=1.0),
],
target_lang="es", provider="openai", source_lang="en", quality="autofit",
)
t0 = time.time()
resp = await dub_translate.dub_translate(req)
dt = time.time() - t0
assert dt < 2.0, f"fit pass not budget-bounded (took {dt:.1f}s)"
assert resp["quality_used"] == "autofit"
for row in resp["translated"]:
assert row["text"] == "hola mundo" # degraded to literal, not the slow fit
assert row.get("rate_error") == "fit-budget"
# ── P2: provider errors on the translate path must be scrubbed ──────────────
@pytest.mark.asyncio
async def test_openai_segment_error_is_scrubbed(monkeypatch):
"""An OpenAI-compatible provider that echoes a key / home path in its error
body must not leak it into the per-segment error response."""
from api.routers import dub_translate
from schemas.requests import TranslateRequest, TranslateSegment
boom = RuntimeError(
"401 invalid key sk-LEAKLEAKLEAKLEAKLEAK12345 for user at /Users/bob/proj"
)
_install_fake_openai(monkeypatch, raises=boom)
req = TranslateRequest(
segments=[TranslateSegment(id="s1", text="Hello")],
target_lang="es", provider="openai", source_lang="en",
)
resp = await dub_translate.dub_translate(req)
seg = resp["translated"][0]
assert seg["text"] == "Hello" # fell back to source text
assert "sk-LEAKLEAKLEAKLEAKLEAK12345" not in seg["error"]
assert "/Users/bob" not in seg["error"]
assert "***REDACTED***" in seg["error"]
+66
View File
@@ -0,0 +1,66 @@
"""Glossary auto-extract — provider-error scrubbing + no-LLM guidance.
The auto-extract endpoint reuses the translator's LLM client. A provider that
echoes the API key / a user_id / a home path in its error body must not surface
that verbatim in the 502 detail, and the no-LLM 503 must point users at the
current setup surface (Settings → LLM Providers), not the legacy env vars.
"""
import os
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import pytest
from fastapi import HTTPException
def _req(**kw):
# AutoExtractRequest is defined in the glossary router module.
from api.routers.glossary import AutoExtractRequest
return AutoExtractRequest(**kw)
def test_auto_extract_no_llm_points_at_llm_providers(monkeypatch):
from api.routers import glossary
from services import translator
monkeypatch.setattr(translator, "_llm_client", lambda: None)
req = _req(target_lang="es", segments=[{"text": "Hello Marcus"}])
with pytest.raises(HTTPException) as ei:
glossary.auto_extract("proj1", req)
detail = ei.value.detail
assert ei.value.status_code == 503
assert "LLM Providers" in detail
# The stale env-var-only guidance must be gone.
assert "TRANSLATE_BASE_URL" not in detail
assert "TRANSLATE_API_KEY" not in detail
def test_auto_extract_scrubs_provider_error(monkeypatch):
from api.routers import glossary
from services import translator
secret = "sk-LEAKLEAKLEAKLEAKLEAK12345"
home = "/Users/alice/videos"
class _Completions:
def create(self, **kw):
raise RuntimeError(f"401 bad key {secret} user_id=acct_9 at {home}")
class _Chat:
completions = _Completions()
class _Client:
chat = _Chat()
monkeypatch.setattr(translator, "_llm_client", lambda: _Client())
monkeypatch.setattr(translator, "_llm_model", lambda: "m")
monkeypatch.setattr(translator, "_llm_timeout", lambda: 1.0)
req = _req(target_lang="es", segments=[{"text": "Hello Marcus"}])
with pytest.raises(HTTPException) as ei:
glossary.auto_extract("proj1", req)
detail = ei.value.detail
assert ei.value.status_code == 502
assert secret not in detail
assert home not in detail
assert "***REDACTED***" in detail