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:
co-authored by
mergetest
Claude Fable 5
parent
75864a597f
commit
af6690840e
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user