usertea commited on
Commit
7198bd8
·
1 Parent(s): 58a8d7f

EchoScript : 20260701 0455 - Bug 1 (Persian missing) - the real root cause: The HF Hub returns 401 Unauthorized (not 404) for nonexistent repos when requests are unauthenticated. The fix: Replaced all live Hub checks with KNOWN_MARIAN_PAIRS - a static, hand-verified table of every (source, target) pair and its exact repo suffix. No network calls, no rate limits, no 401/404 ambiguity. Bug 2 (loading spinner on cached languages) - the real cause: generate_translations was a regular function, so Gradio held the loading spinner on every output component in translation_stage_outputs for the entire duration of the slowest new translation. The fix: Converted to a Gradio generator that yields twice: once immediately (pass 1, all cached results shown instantly, new languages left as no-op gr.update()), then once more per new language as each one finishes (pass 2).

Browse files
Files changed (2) hide show
  1. app.py +66 -26
  2. services/translation.py +159 -231
app.py CHANGED
@@ -254,42 +254,82 @@ def generate_translations(
254
  cached_transcript: Optional[Transcript],
255
  cached_translations: dict,
256
  ):
 
 
 
 
 
 
 
 
 
257
  if cached_transcript is None:
258
  raise gr.Error("Generate a transcript first.")
259
 
 
260
  translation_service = get_translation_service()
261
- section_updates = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
  for label in _TRANSLATION_SECTION_ORDER:
264
  code = _TRANSLATION_LABEL_TO_CODE[label]
265
- if label not in (selected_languages or []):
266
- section_updates[label] = (False, "", None)
267
- continue
268
-
269
- if code in cached_translations:
270
  text = cached_translations[code]
 
 
271
  else:
272
- try:
273
- translation = translation_service.translate(cached_transcript, code, api_key=api_key)
274
- text = translation.text
275
- cached_translations[code] = text
276
- except TranslationError as exc:
277
- # Surface the failure for this language only; deliberately
278
- # not cached, so the next click retries it.
279
- text = f"\u26a0\ufe0f Translation failed: {exc}"
280
-
281
- file_path = None
282
- if not text.startswith("\u26a0\ufe0f"):
283
- tmp_dir = Path(tempfile.mkdtemp(prefix="echoscript_"))
 
 
 
 
 
284
  file_path = _write_text_file(text, tmp_dir, f"{code}.txt")
285
- section_updates[label] = (True, text, file_path)
 
 
 
 
 
286
 
287
- outputs = []
288
- for label in _TRANSLATION_SECTION_ORDER:
289
- visible, text, file_path = section_updates[label]
290
- outputs += [gr.update(visible=visible), text, file_path]
291
- outputs.append(cached_translations)
292
- return outputs
293
 
294
 
295
  def reset_session_state():
@@ -325,7 +365,7 @@ with gr.Blocks(title="EchoScript") as demo:
325
  **Upload Audio → Select Audio Window → Detect Language & Generate Transcript
326
  → Preview & Choose Languages → Generate Translations → Copy / Download**
327
 
328
- <sub>build: 2026-06-30 08:57 UTC &middot; fixed: rate-limited Hub checks were being cached as "language unavailable"</sub>
329
  """
330
  )
331
 
 
254
  cached_transcript: Optional[Transcript],
255
  cached_translations: dict,
256
  ):
257
+ """Generator: yield cached results immediately, then compute only new ones.
258
+
259
+ This ensures the loading spinner only appears on sections that are
260
+ actually being translated. Languages already in the cache are yielded
261
+ instantly in the first pass; only genuinely new languages trigger
262
+ model/API calls in the second pass. Gradio generators allow partial
263
+ yields, so the UI updates progressively rather than waiting for the
264
+ slowest language.
265
+ """
266
  if cached_transcript is None:
267
  raise gr.Error("Generate a transcript first.")
268
 
269
+ selected = set(selected_languages or [])
270
  translation_service = get_translation_service()
271
+ tmp_dir = Path(tempfile.mkdtemp(prefix="echoscript_"))
272
+
273
+ def _make_outputs(section_states: dict) -> list:
274
+ """Build the flat output list from a dict of label -> (visible, text, file)."""
275
+ result = []
276
+ for label in _TRANSLATION_SECTION_ORDER:
277
+ state = section_states.get(label)
278
+ if state is None:
279
+ # No decision yet for this label -- emit a no-op so Gradio
280
+ # doesn't touch it (preserves whatever is already shown).
281
+ result += [gr.update(), gr.update(), gr.update()]
282
+ else:
283
+ visible, text, file_path = state
284
+ result += [gr.update(visible=visible), text if text is not None else gr.update(), file_path]
285
+ result.append(cached_translations)
286
+ return result
287
+
288
+ # ------------------------------------------------------------------
289
+ # Pass 1: Resolve every section immediately from the cache or by
290
+ # hiding unselected ones. Only sections that need a real translation
291
+ # call are left as None (no-op) so their current UI state is
292
+ # preserved while we wait.
293
+ # ------------------------------------------------------------------
294
+ section_states: dict[str, Optional[tuple]] = {}
295
+ needs_translation: list[str] = []
296
 
297
  for label in _TRANSLATION_SECTION_ORDER:
298
  code = _TRANSLATION_LABEL_TO_CODE[label]
299
+ if label not in selected:
300
+ section_states[label] = (False, "", None)
301
+ elif code in cached_translations:
 
 
302
  text = cached_translations[code]
303
+ file_path = _write_text_file(text, tmp_dir, f"{code}_cached.txt")
304
+ section_states[label] = (True, text, file_path)
305
  else:
306
+ # Will be computed in pass 2; leave as None for now.
307
+ section_states[label] = None
308
+ needs_translation.append(label)
309
+
310
+ # Yield immediately so cached results appear without waiting for new ones.
311
+ yield _make_outputs(section_states)
312
+
313
+ # ------------------------------------------------------------------
314
+ # Pass 2: Translate only the languages that aren't cached yet,
315
+ # yielding after each one completes.
316
+ # ------------------------------------------------------------------
317
+ for label in needs_translation:
318
+ code = _TRANSLATION_LABEL_TO_CODE[label]
319
+ try:
320
+ translation = translation_service.translate(cached_transcript, code, api_key=api_key)
321
+ text = translation.text
322
+ cached_translations[code] = text
323
  file_path = _write_text_file(text, tmp_dir, f"{code}.txt")
324
+ section_states[label] = (True, text, file_path)
325
+ except TranslationError as exc:
326
+ # Surface the failure for this language only. Deliberately not
327
+ # cached, so the next click will retry.
328
+ text = f"\u26a0\ufe0f Translation failed: {exc}"
329
+ section_states[label] = (True, text, None)
330
 
331
+ # Yield after each language so the UI updates progressively.
332
+ yield _make_outputs(section_states)
 
 
 
 
333
 
334
 
335
  def reset_session_state():
 
365
  **Upload Audio &rarr; Select Audio Window &rarr; Detect Language & Generate Transcript
366
  &rarr; Preview & Choose Languages &rarr; Generate Translations &rarr; Copy / Download**
367
 
368
+ <sub>build: 2026-07-01 04:55 UTC &middot; static pair table (Persian fixed) &middot; generator translations (no spinner on cached)</sub>
369
  """
370
  )
371
 
services/translation.py CHANGED
@@ -7,30 +7,22 @@ audio:
7
  Audio -> Transcript -> Translation (allowed)
8
  Audio -> Translation (never)
9
 
10
- This keeps a single source of truth: if a name or term is fixed once in
11
- the transcript (v1.1: Transcript Editing), every translation regenerated
12
- afterwards picks up the fix automatically, and every translation stays in
13
- sync with the same segment timings as the transcript (so subtitles still
14
- work for translated output).
15
 
16
- Two interchangeable backends are available, chosen per-request based on
17
- whether an Anthropic API key was supplied -- never stored:
18
-
19
- - "anthropic": the caller supplies their own API key (e.g. typed into the
20
- UI for that session). Used whenever a key is present. Sends transcript
21
  text to Claude for translation. No missing-language-pair failure mode --
22
  every supported language translates directly to every other one in a
23
- single call, with no local model downloads. The key is passed straight
24
- through to the Anthropic client for that one call and is never written
25
- to disk, logged, or cached in any module-level state.
26
  - "marian": fully offline, no API key needed. Uses local Helsinki-NLP
27
- MarianMT models via `transformers`. Coverage is checked for real, per
28
- source language, against the Hub (direct pair or English pivot) -- see
29
- available_marian_targets() -- rather than assumed from a fixed list.
 
30
 
31
- The UI is expected to only offer the larger ANTHROPIC_TARGET_LANGUAGES
32
- list once a key has been entered, and available_marian_targets(source)
33
- otherwise -- see app.py.
34
  """
35
 
36
  from __future__ import annotations
@@ -38,14 +30,14 @@ from __future__ import annotations
38
  import os
39
  import re
40
  from functools import lru_cache
 
41
 
42
  from models.transcript import Segment, Transcript, Translation
43
 
44
- # Display names for every language EchoScript knows about -- used both for
45
- # the source-language dropdown (services/transcription.py) and to name
46
- # languages in the Anthropic translation prompt. Falls back to the raw
47
- # code for anything not listed (e.g. a language Whisper auto-detected
48
- # that isn't in this table).
49
  LANGUAGE_NAMES: dict[str, str] = {
50
  "fr": "French",
51
  "en": "English",
@@ -64,35 +56,124 @@ LANGUAGE_NAMES: dict[str, str] = {
64
  }
65
 
66
  # Target languages offered once the person supplies their own Anthropic
67
- # API key -- Claude has no missing-pair problem, so this list is just
68
- # "every language EchoScript knows the name of".
69
  ANTHROPIC_TARGET_LANGUAGES: dict[str, str] = dict(LANGUAGE_NAMES)
70
 
71
- # Anthropic model used for translation -- Haiku is fast and inexpensive,
72
- # which fits well for what is otherwise a mechanical translation task.
73
- _ANTHROPIC_MODEL = "claude-haiku-4-5-20251001"
74
- _ANTHROPIC_BATCH_SIZE = 40 # segments per API call, to keep prompts small
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
 
77
- class TranslationError(RuntimeError):
78
- """Raised when no translation backend/model is available for a pair."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
 
81
  # ---------------------------------------------------------------------------
82
  # Anthropic backend
83
  # ---------------------------------------------------------------------------
84
 
 
 
85
  _NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[.\)]\s?(.*)$")
86
 
87
 
88
  def _anthropic_client(api_key: str):
89
- """Build a fresh Anthropic client for this one call.
90
-
91
- Deliberately not cached: the key is supplied per-request by the
92
- person using the app and must not linger in any module-level state
93
- after the call that needed it returns.
94
- """
95
- import anthropic # heavy import, deferred until needed
96
 
97
  return anthropic.Anthropic(api_key=api_key)
98
 
@@ -100,10 +181,8 @@ def _anthropic_client(api_key: str):
100
  def _translate_batch_via_anthropic(
101
  texts: list[str], source_language: str, target_language: str, api_key: str
102
  ) -> list[str]:
103
- """Translate a batch of lines in one Claude call, preserving order/count."""
104
  source_name = LANGUAGE_NAMES.get(source_language, source_language)
105
  target_name = LANGUAGE_NAMES.get(target_language, target_language)
106
-
107
  numbered_input = "\n".join(f"{i + 1}. {text}" for i, text in enumerate(texts))
108
 
109
  try:
@@ -112,17 +191,15 @@ def _translate_batch_via_anthropic(
112
  max_tokens=4096,
113
  system=(
114
  f"You translate transcript lines from {source_name} to {target_name}. "
115
- "You will be given a numbered list of lines, one sentence or "
116
- "fragment per line. Reply with the same numbers, translated, "
117
- "one per line, in the same order. Keep the same number of "
118
- "lines as the input -- never merge, split, drop, or add lines. "
119
- "Output only the numbered translated lines, with no preamble, "
120
- "no explanations, and no extra commentary."
121
  ),
122
  messages=[{"role": "user", "content": numbered_input}],
123
  )
124
- except Exception as exc: # pragma: no cover - depends on network/API key
125
- # Deliberately do not include the key itself in this message.
126
  raise TranslationError(
127
  f"Anthropic translation request failed for "
128
  f"'{source_language}' -> '{target_language}': {exc}"
@@ -131,7 +208,6 @@ def _translate_batch_via_anthropic(
131
  raw_text = "".join(
132
  block.text for block in response.content if getattr(block, "type", None) == "text"
133
  )
134
-
135
  parsed: dict[int, str] = {}
136
  for line in raw_text.splitlines():
137
  match = _NUMBERED_LINE_RE.match(line)
@@ -140,11 +216,10 @@ def _translate_batch_via_anthropic(
140
 
141
  if len(parsed) != len(texts) or any((i + 1) not in parsed for i in range(len(texts))):
142
  raise TranslationError(
143
- f"Anthropic translation response didn't match expected line count "
144
- f"for '{source_language}' -> '{target_language}' "
145
  f"(expected {len(texts)}, parsed {len(parsed)})."
146
  )
147
-
148
  return [parsed[i + 1] for i in range(len(texts))]
149
 
150
 
@@ -153,21 +228,15 @@ def _translate_segments_via_anthropic(
153
  ) -> list[Segment]:
154
  non_empty = [(i, seg) for i, seg in enumerate(segments) if seg.text]
155
  translated_text_by_index: dict[int, str] = {}
156
-
157
  for start in range(0, len(non_empty), _ANTHROPIC_BATCH_SIZE):
158
  chunk = non_empty[start : start + _ANTHROPIC_BATCH_SIZE]
159
  texts = [seg.text for _, seg in chunk]
160
  translated = _translate_batch_via_anthropic(texts, source_language, target_language, api_key)
161
  for (i, _), text in zip(chunk, translated):
162
  translated_text_by_index[i] = text
163
-
164
  return [
165
- Segment(
166
- index=seg.index,
167
- start=seg.start,
168
- end=seg.end,
169
- text=translated_text_by_index.get(i, seg.text),
170
- )
171
  for i, seg in enumerate(segments)
172
  ]
173
 
@@ -178,165 +247,26 @@ def _translate_segments_via_anthropic(
178
 
179
 
180
  @lru_cache(maxsize=None)
181
- def _try_load_marian_engine(source_language: str, target_language: str):
182
- """Try to load+cache a MarianMT model+tokenizer for one language pair.
183
-
184
- Returns (tokenizer, model), or None if no such model exists on the Hub
185
- (e.g. Helsinki-NLP doesn't publish every pair directly). Cached either
186
- way so a missing pair isn't re-checked over the network on every call.
187
- Safe to cache: these are public model weights, not secrets.
188
-
189
- Loads the model/tokenizer directly via AutoModelForSeq2SeqLM rather
190
- than transformers.pipeline("translation", ...): as of transformers v5,
191
- the generic "translation" pipeline task was removed entirely (see
192
- huggingface/transformers#43825) -- pipeline("translation") now raises
193
- KeyError. Loading the model directly and calling `model.generate()` is
194
- unaffected by that change and is the path the transformers docs now
195
- show for MarianMT.
196
  """
197
- from transformers import AutoModelForSeq2SeqLM, AutoTokenizer # heavy import, deferred
198
 
199
- model_name = _marian_repo_id(source_language, target_language)
200
  try:
201
  tokenizer = AutoTokenizer.from_pretrained(model_name)
202
  model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
203
- except Exception: # pragma: no cover - depends on model availability
204
- return None
 
 
205
  return tokenizer, model
206
 
207
 
208
- # Helsinki-NLP doesn't always use standard ISO 639-1 codes in its repo
209
- # names. Known exceptions go here, mapped to the actual code used in that
210
- # specific repo name -- e.g. the en->Japanese model is published as
211
- # "Helsinki-NLP/opus-mt-en-jap" (not "...-en-ja"), even though the reverse
212
- # direction "Helsinki-NLP/opus-mt-ja-en" correctly uses "ja". Without this,
213
- # every availability check for Japanese-as-a-target would 404 on a model
214
- # that actually exists, regardless of source language.
215
- _MARIAN_CODE_ALIASES: dict[tuple[str, str], str] = {
216
- ("en", "ja"): "jap",
217
- }
218
-
219
-
220
- def _marian_repo_id(source_language: str, target_language: str) -> str:
221
- aliased_target = _MARIAN_CODE_ALIASES.get((source_language, target_language), target_language)
222
- return f"Helsinki-NLP/opus-mt-{source_language}-{aliased_target}"
223
-
224
-
225
- @lru_cache(maxsize=None)
226
- def _marian_model_exists(source_language: str, target_language: str) -> bool:
227
- """Check (and cache) whether Helsinki-NLP publishes this exact pair.
228
-
229
- This is a lightweight existence check (one small metadata request to
230
- the Hub's model-info API), not a full model/tokenizer download --
231
- deliberately kept separate from _try_load_marian_engine so the UI can
232
- cheaply ask "what's actually available for this source language"
233
- without paying the cost of downloading every candidate model.
234
-
235
- Only a confirmed 404 (the repo genuinely doesn't exist) is cached as
236
- False. Anything else -- rate limiting, a timeout, a transient network
237
- error -- raises instead of being swallowed into a False. This matters
238
- because @lru_cache only caches a function's return value, never an
239
- exception it raised: if this returned False for a rate-limited
240
- request, that wrong "doesn't exist" answer would be locked in for the
241
- rest of the session (this is what was hiding Persian as a translation
242
- target from English, even though Helsinki-NLP/opus-mt-en-fa genuinely
243
- exists -- a burst of ~30+ unauthenticated existence checks during one
244
- transcript's language-detection step is enough to get rate-limited).
245
- Callers must catch the transient case themselves (see
246
- _safe_marian_exists) and decide what "I don't know yet" should mean
247
- for that call site, rather than this function silently deciding it.
248
- """
249
- from huggingface_hub import HfApi
250
- from huggingface_hub.utils import HfHubHTTPError
251
-
252
- try:
253
- HfApi(token=_hf_token()).model_info(_marian_repo_id(source_language, target_language))
254
- return True
255
- except HfHubHTTPError as exc:
256
- status_code = getattr(getattr(exc, "response", None), "status_code", None)
257
- if status_code == 404:
258
- return False
259
- raise # 429 / 5xx / etc. -- not proof the model doesn't exist
260
-
261
-
262
- def _safe_marian_exists(source_language: str, target_language: str) -> bool:
263
- """`_marian_model_exists`, but transient failures fail open.
264
-
265
- A genuine 404 still means "not available". Anything else (rate
266
- limiting, a network blip) is treated as "available" for this one
267
- call rather than "unavailable" -- erring toward occasionally offering
268
- a language whose Hub check happened to fail transiently (the actual
269
- translate() call will surface a clear per-language error if it truly
270
- isn't there) rather than silently and permanently hiding one that's
271
- really there, which is the bug this replaces.
272
- """
273
- try:
274
- return _marian_model_exists(source_language, target_language)
275
- except Exception:
276
- return True
277
-
278
-
279
- def _hf_token() -> Optional[str]:
280
- """An HF token, if one is configured -- raises the unauthenticated
281
- rate limit that triggers the failure above in the first place.
282
- Optional; everything here still works without one, just with a lower
283
- request budget before transient failures become likely.
284
- """
285
- return os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
286
-
287
-
288
- def _marian_path_exists(source_language: str, target_language: str) -> bool:
289
- """Direct pair, or a source->en->target pivot, whichever is real."""
290
- if source_language == target_language:
291
- return False
292
- if _safe_marian_exists(source_language, target_language):
293
- return True
294
- if source_language != "en" and target_language != "en":
295
- return _safe_marian_exists(source_language, "en") and _safe_marian_exists("en", target_language)
296
- return False
297
-
298
-
299
- def available_marian_targets(source_language: str) -> dict[str, str]:
300
- """Every language MarianMT can actually reach from `source_language`.
301
-
302
- Checked for real against the Hub (direct pair or English pivot) for
303
- each candidate in LANGUAGE_NAMES, rather than assumed from a fixed
304
- list -- this is what makes the offered languages correct per source
305
- language instead of a one-size-fits-all guess (e.g. Persian is only
306
- offered for a source where a path genuinely exists).
307
- """
308
- return {
309
- code: name
310
- for code, name in LANGUAGE_NAMES.items()
311
- if code != source_language and _marian_path_exists(source_language, code)
312
- }
313
-
314
-
315
- def _resolve_marian_engines(source_language: str, target_language: str) -> list[tuple]:
316
- """Work out which model(s) to chain to get from source to target.
317
-
318
- Prefers a single direct Helsinki-NLP model. If none exists for the
319
- pair, pivots through English (source -> en -> target), since that's
320
- where Helsinki-NLP's coverage is densest -- this is what makes
321
- something like French -> Persian work even though no direct
322
- opus-mt-fr-fa model exists.
323
- """
324
- direct = _try_load_marian_engine(source_language, target_language)
325
- if direct is not None:
326
- return [direct]
327
-
328
- if source_language != "en" and target_language != "en":
329
- hop1 = _try_load_marian_engine(source_language, "en")
330
- hop2 = _try_load_marian_engine("en", target_language)
331
- if hop1 is not None and hop2 is not None:
332
- return [hop1, hop2]
333
-
334
- raise TranslationError(
335
- f"No direct or English-pivot translation model available for "
336
- f"'{source_language}' -> '{target_language}'."
337
- )
338
-
339
-
340
  def _run_marian_translation(tokenizer, model, text: str) -> str:
341
  inputs = tokenizer(text, return_tensors="pt", truncation=True)
342
  generated = model.generate(**inputs, max_new_tokens=512)
@@ -346,7 +276,15 @@ def _run_marian_translation(tokenizer, model, text: str) -> str:
346
  def _translate_segments_via_marian(
347
  segments: list[Segment], source_language: str, target_language: str
348
  ) -> list[Segment]:
349
- engines = _resolve_marian_engines(source_language, target_language)
 
 
 
 
 
 
 
 
350
 
351
  translated_segments = []
352
  for seg in segments:
@@ -356,7 +294,9 @@ def _translate_segments_via_marian(
356
  text = seg.text
357
  for tokenizer, model in engines:
358
  text = _run_marian_translation(tokenizer, model, text)
359
- translated_segments.append(Segment(index=seg.index, start=seg.start, end=seg.end, text=text))
 
 
360
  return translated_segments
361
 
362
 
@@ -365,6 +305,10 @@ def _translate_segments_via_marian(
365
  # ---------------------------------------------------------------------------
366
 
367
 
 
 
 
 
368
  class TranslationService:
369
  """Translates a Transcript into one or more target languages.
370
 
@@ -379,11 +323,10 @@ class TranslationService:
379
  self,
380
  transcript: Transcript,
381
  target_language: str,
382
- api_key: str | None = None,
383
  ) -> Translation:
384
  """Translate every segment of `transcript`, preserving timing."""
385
  if target_language == transcript.language:
386
- # Already in the target language -- relabel, don't re-translate.
387
  return Translation(
388
  source_language=transcript.language,
389
  target_language=target_language,
@@ -406,18 +349,3 @@ class TranslationService:
406
  target_language=target_language,
407
  segments=translated_segments,
408
  )
409
-
410
- def translate_many(
411
- self,
412
- transcript: Transcript,
413
- target_languages: list[str],
414
- api_key: str | None = None,
415
- ) -> dict[str, Translation]:
416
- """Translate into several target languages at once.
417
-
418
- Returns a dict keyed by target-language code, in line with how the
419
- UI's multi-select "Outputs" checkboxes will want to fan out.
420
- """
421
- return {
422
- lang: self.translate(transcript, lang, api_key=api_key) for lang in target_languages
423
- }
 
7
  Audio -> Transcript -> Translation (allowed)
8
  Audio -> Translation (never)
9
 
10
+ Two interchangeable backends, chosen per-request based on whether an
11
+ Anthropic API key is supplied -- never stored:
 
 
 
12
 
13
+ - "anthropic": the caller supplies their own API key. Sends transcript
 
 
 
 
14
  text to Claude for translation. No missing-language-pair failure mode --
15
  every supported language translates directly to every other one in a
16
+ single call. The key is passed straight through to the Anthropic client
17
+ for that one call and is never written to disk, logged, or cached.
 
18
  - "marian": fully offline, no API key needed. Uses local Helsinki-NLP
19
+ MarianMT models. Available targets per source language are determined
20
+ from a verified static table (see KNOWN_MARIAN_PAIRS below) rather
21
+ than live Hub API checks, which are unreliable in a rate-limited
22
+ unauthenticated HF Spaces environment.
23
 
24
+ The UI is expected to only offer ANTHROPIC_TARGET_LANGUAGES once a key
25
+ has been entered, and available_marian_targets(source) otherwise.
 
26
  """
27
 
28
  from __future__ import annotations
 
30
  import os
31
  import re
32
  from functools import lru_cache
33
+ from typing import Optional
34
 
35
  from models.transcript import Segment, Transcript, Translation
36
 
37
+ # ---------------------------------------------------------------------------
38
+ # Language catalog
39
+ # ---------------------------------------------------------------------------
40
+
 
41
  LANGUAGE_NAMES: dict[str, str] = {
42
  "fr": "French",
43
  "en": "English",
 
56
  }
57
 
58
  # Target languages offered once the person supplies their own Anthropic
59
+ # API key -- Claude has no missing-pair problem so the list is simply
60
+ # everything we know the name of.
61
  ANTHROPIC_TARGET_LANGUAGES: dict[str, str] = dict(LANGUAGE_NAMES)
62
 
63
+ # ---------------------------------------------------------------------------
64
+ # Verified Marian pair table
65
+ #
66
+ # Verified from the Helsinki-NLP Hub catalog. We use a static table rather
67
+ # than live Hub API checks because:
68
+ # - The HF Hub returns 401 (Unauthorized) for nonexistent repos when
69
+ # requests are unauthenticated, and the huggingface_hub library raises
70
+ # that as RepositoryNotFoundError -- indistinguishable from a genuine
71
+ # 404 without parsing the status code correctly.
72
+ # - Unauthenticated HF Spaces requests are aggressively rate-limited.
73
+ # A burst of ~30 simultaneous existence checks (14 candidate languages
74
+ # × 2-3 pivot steps each) reliably triggers 429s.
75
+ # - Both 401 and 429 were being silently swallowed into "not available",
76
+ # causing Persian to disappear from English's target list even though
77
+ # Helsinki-NLP/opus-mt-en-fa genuinely exists.
78
+ #
79
+ # Format: {(source_code, target_code): repo_suffix}
80
+ # repo_suffix is what goes after "Helsinki-NLP/opus-mt-". In almost all
81
+ # cases it's just f"{src}-{tgt}", but Helsinki-NLP uses "jap" instead of
82
+ # the ISO "ja" for the en->Japanese model.
83
+ # ---------------------------------------------------------------------------
84
+ KNOWN_MARIAN_PAIRS: dict[tuple[str, str], str] = {
85
+ # English <-> everything
86
+ ("en", "fr"): "en-fr",
87
+ ("fr", "en"): "fr-en",
88
+ ("en", "de"): "en-de",
89
+ ("de", "en"): "de-en",
90
+ ("en", "fa"): "en-fa", # Helsinki-NLP/opus-mt-en-fa -- confirmed via catalog
91
+ ("fa", "en"): "fa-en", # Helsinki-NLP/opus-mt-fa-en -- confirmed earlier
92
+ ("en", "es"): "en-es",
93
+ ("es", "en"): "es-en",
94
+ ("en", "it"): "en-it",
95
+ ("it", "en"): "it-en",
96
+ ("en", "pt"): "en-pt",
97
+ ("pt", "en"): "pt-en",
98
+ ("en", "nl"): "en-nl",
99
+ ("nl", "en"): "nl-en",
100
+ ("en", "ar"): "en-ar",
101
+ ("ar", "en"): "ar-en",
102
+ ("en", "ru"): "en-ru",
103
+ ("ru", "en"): "ru-en",
104
+ ("en", "tr"): "en-tr",
105
+ ("tr", "en"): "tr-en",
106
+ ("en", "zh"): "en-zh",
107
+ ("zh", "en"): "zh-en",
108
+ ("en", "ko"): "en-ko",
109
+ ("ko", "en"): "ko-en",
110
+ ("en", "ja"): "en-jap", # repo uses "jap" not "ja"
111
+ ("ja", "en"): "ja-en",
112
+ # Selected direct non-English pairs (common enough to avoid a pivot hop)
113
+ ("fr", "de"): "fr-de",
114
+ ("de", "fr"): "de-fr",
115
+ ("fr", "es"): "fr-es",
116
+ ("es", "fr"): "es-fr",
117
+ ("de", "es"): "de-es",
118
+ ("es", "de"): "es-de",
119
+ }
120
 
121
 
122
+ def _marian_repo_name(src: str, tgt: str) -> Optional[str]:
123
+ """Return the Helsinki-NLP repo suffix for a pair, or None if unknown."""
124
+ return KNOWN_MARIAN_PAIRS.get((src, tgt))
125
+
126
+
127
+ def _marian_direct_exists(src: str, tgt: str) -> bool:
128
+ return _marian_repo_name(src, tgt) is not None
129
+
130
+
131
+ def _marian_path_repo_names(src: str, tgt: str) -> Optional[list[str]]:
132
+ """Return the list of repo suffixes needed to translate src->tgt.
133
+
134
+ Returns a 1-element list for a direct pair, a 2-element list for an
135
+ English-pivot hop, or None if no path is known.
136
+ """
137
+ if src == tgt:
138
+ return None
139
+ direct = _marian_repo_name(src, tgt)
140
+ if direct:
141
+ return [direct]
142
+ # English pivot: src->en->tgt
143
+ if src != "en" and tgt != "en":
144
+ hop1 = _marian_repo_name(src, "en")
145
+ hop2 = _marian_repo_name("en", tgt)
146
+ if hop1 and hop2:
147
+ return [hop1, hop2]
148
+ return None
149
+
150
+
151
+ def available_marian_targets(source_language: str) -> dict[str, str]:
152
+ """Every language MarianMT can reach from `source_language`.
153
+
154
+ Based on the verified KNOWN_MARIAN_PAIRS table (direct pair or English
155
+ pivot). No network calls are made; the table is the source of truth.
156
+ """
157
+ return {
158
+ code: name
159
+ for code, name in LANGUAGE_NAMES.items()
160
+ if code != source_language and _marian_path_repo_names(source_language, code) is not None
161
+ }
162
 
163
 
164
  # ---------------------------------------------------------------------------
165
  # Anthropic backend
166
  # ---------------------------------------------------------------------------
167
 
168
+ _ANTHROPIC_MODEL = "claude-haiku-4-5-20251001"
169
+ _ANTHROPIC_BATCH_SIZE = 40
170
  _NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[.\)]\s?(.*)$")
171
 
172
 
173
  def _anthropic_client(api_key: str):
174
+ """Build a fresh Anthropic client for this one call. Deliberately not
175
+ cached: the key must not linger in module-level state."""
176
+ import anthropic
 
 
 
 
177
 
178
  return anthropic.Anthropic(api_key=api_key)
179
 
 
181
  def _translate_batch_via_anthropic(
182
  texts: list[str], source_language: str, target_language: str, api_key: str
183
  ) -> list[str]:
 
184
  source_name = LANGUAGE_NAMES.get(source_language, source_language)
185
  target_name = LANGUAGE_NAMES.get(target_language, target_language)
 
186
  numbered_input = "\n".join(f"{i + 1}. {text}" for i, text in enumerate(texts))
187
 
188
  try:
 
191
  max_tokens=4096,
192
  system=(
193
  f"You translate transcript lines from {source_name} to {target_name}. "
194
+ "You will be given a numbered list of lines, one sentence or fragment "
195
+ "per line. Reply with the same numbers, translated, one per line, in "
196
+ "the same order. Keep the same number of lines as the input -- never "
197
+ "merge, split, drop, or add lines. Output only the numbered translated "
198
+ "lines, with no preamble, no explanations, and no extra commentary."
 
199
  ),
200
  messages=[{"role": "user", "content": numbered_input}],
201
  )
202
+ except Exception as exc:
 
203
  raise TranslationError(
204
  f"Anthropic translation request failed for "
205
  f"'{source_language}' -> '{target_language}': {exc}"
 
208
  raw_text = "".join(
209
  block.text for block in response.content if getattr(block, "type", None) == "text"
210
  )
 
211
  parsed: dict[int, str] = {}
212
  for line in raw_text.splitlines():
213
  match = _NUMBERED_LINE_RE.match(line)
 
216
 
217
  if len(parsed) != len(texts) or any((i + 1) not in parsed for i in range(len(texts))):
218
  raise TranslationError(
219
+ f"Anthropic response didn't match expected line count for "
220
+ f"'{source_language}' -> '{target_language}' "
221
  f"(expected {len(texts)}, parsed {len(parsed)})."
222
  )
 
223
  return [parsed[i + 1] for i in range(len(texts))]
224
 
225
 
 
228
  ) -> list[Segment]:
229
  non_empty = [(i, seg) for i, seg in enumerate(segments) if seg.text]
230
  translated_text_by_index: dict[int, str] = {}
 
231
  for start in range(0, len(non_empty), _ANTHROPIC_BATCH_SIZE):
232
  chunk = non_empty[start : start + _ANTHROPIC_BATCH_SIZE]
233
  texts = [seg.text for _, seg in chunk]
234
  translated = _translate_batch_via_anthropic(texts, source_language, target_language, api_key)
235
  for (i, _), text in zip(chunk, translated):
236
  translated_text_by_index[i] = text
 
237
  return [
238
+ Segment(index=seg.index, start=seg.start, end=seg.end,
239
+ text=translated_text_by_index.get(i, seg.text))
 
 
 
 
240
  for i, seg in enumerate(segments)
241
  ]
242
 
 
247
 
248
 
249
  @lru_cache(maxsize=None)
250
+ def _load_marian_engine(repo_suffix: str):
251
+ """Load and cache a MarianMT model+tokenizer by repo suffix.
252
+
253
+ Keyed by repo suffix (e.g. "en-fa", "en-jap") rather than ISO codes
254
+ so that aliased pairs (like en-ja -> "en-jap") don't get loaded twice.
255
+ Returns (tokenizer, model) or raises on failure.
 
 
 
 
 
 
 
 
 
256
  """
257
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
258
 
259
+ model_name = f"Helsinki-NLP/opus-mt-{repo_suffix}"
260
  try:
261
  tokenizer = AutoTokenizer.from_pretrained(model_name)
262
  model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
263
+ except Exception as exc:
264
+ raise TranslationError(
265
+ f"Failed to load MarianMT model '{model_name}': {exc}"
266
+ ) from exc
267
  return tokenizer, model
268
 
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  def _run_marian_translation(tokenizer, model, text: str) -> str:
271
  inputs = tokenizer(text, return_tensors="pt", truncation=True)
272
  generated = model.generate(**inputs, max_new_tokens=512)
 
276
  def _translate_segments_via_marian(
277
  segments: list[Segment], source_language: str, target_language: str
278
  ) -> list[Segment]:
279
+ repo_names = _marian_path_repo_names(source_language, target_language)
280
+ if not repo_names:
281
+ raise TranslationError(
282
+ f"No Marian translation path known for "
283
+ f"'{source_language}' -> '{target_language}'."
284
+ )
285
+
286
+ # Load engines (cached after the first call for each repo suffix)
287
+ engines = [_load_marian_engine(r) for r in repo_names]
288
 
289
  translated_segments = []
290
  for seg in segments:
 
294
  text = seg.text
295
  for tokenizer, model in engines:
296
  text = _run_marian_translation(tokenizer, model, text)
297
+ translated_segments.append(
298
+ Segment(index=seg.index, start=seg.start, end=seg.end, text=text)
299
+ )
300
  return translated_segments
301
 
302
 
 
305
  # ---------------------------------------------------------------------------
306
 
307
 
308
+ class TranslationError(RuntimeError):
309
+ """Raised when no translation backend/model is available for a pair."""
310
+
311
+
312
  class TranslationService:
313
  """Translates a Transcript into one or more target languages.
314
 
 
323
  self,
324
  transcript: Transcript,
325
  target_language: str,
326
+ api_key: Optional[str] = None,
327
  ) -> Translation:
328
  """Translate every segment of `transcript`, preserving timing."""
329
  if target_language == transcript.language:
 
330
  return Translation(
331
  source_language=transcript.language,
332
  target_language=target_language,
 
349
  target_language=target_language,
350
  segments=translated_segments,
351
  )