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

EchoScript : Fixes : Why Persian/Portuguese/Turkish were failing , Korean removed from Marian targets , Multiple clicks , UI improvements

Browse files
Files changed (2) hide show
  1. app.py +24 -19
  2. services/translation.py +126 -161
app.py CHANGED
@@ -90,8 +90,9 @@ SOURCE_LANGUAGE_CHOICES = ["Auto Detect"] + list(SUPPORTED_LANGUAGES.values())
90
 
91
  # Label -> ISO 639-1 code, built from the full (Anthropic) superset so it
92
  # resolves correctly regardless of which list is currently offered.
 
93
  _TRANSLATION_LABEL_TO_CODE = {
94
- f"{name} Translation": code for code, name in ANTHROPIC_TARGET_LANGUAGES.items()
95
  }
96
 
97
  # Sections are pre-built for every language in the full superset (hidden by
@@ -101,21 +102,12 @@ _TRANSLATION_SECTION_ORDER = list(_TRANSLATION_LABEL_TO_CODE.keys())
101
 
102
 
103
  def _compute_translation_choices(has_key: bool, exclude_code: Optional[str]) -> list[str]:
104
- """The translate-to picker, cascaded from key presence + source language.
105
-
106
- `exclude_code` is the actual transcript language at this point (this
107
- picker only exists once a Transcript does), never a pre-detection
108
- guess. Without a key, the Marian options are checked for real against
109
- the Hub for this specific source language -- not a fixed guess -- so
110
- a source with no path to a given language (direct or English-pivot)
111
- simply won't offer it, and one with good coverage offers everything
112
- that's actually reachable (which can be more than four languages).
113
- """
114
  if has_key:
115
  pool = ANTHROPIC_TARGET_LANGUAGES
116
  else:
117
  pool = available_marian_targets(exclude_code) if exclude_code else {}
118
- return [f"{name} Translation" for code, name in pool.items() if code != exclude_code]
119
 
120
 
121
  def _on_api_key_change(api_key: str, cached_transcript: Optional[Transcript], current_value: list[str]):
@@ -287,9 +279,9 @@ def generate_translations(
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] = []
@@ -303,11 +295,11 @@ def generate_translations(
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
  # ------------------------------------------------------------------
@@ -365,7 +357,7 @@ with gr.Blocks(title="EchoScript") as demo:
365
  **Upload Audio → Select Audio Window → Detect Language & Generate Transcript
366
  → Preview & Choose Languages → Generate Translations → 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
 
@@ -428,6 +420,9 @@ with gr.Blocks(title="EchoScript") as demo:
428
  )
429
  with gr.Group(visible=False) as translations_picker_group:
430
  gr.Markdown("Translate to:")
 
 
 
431
  translate_choices_input = gr.CheckboxGroup(choices=[], value=[], label=None)
432
  generate_translations_button = gr.Button("Generate Translations", variant="primary")
433
 
@@ -526,6 +521,16 @@ with gr.Blocks(title="EchoScript") as demo:
526
  outputs=[translate_choices_input],
527
  )
528
 
 
 
 
 
 
 
 
 
 
 
529
  reset_button.click(fn=reset_session_state, outputs=transcript_stage_outputs)
530
 
531
  if __name__ == "__main__":
 
90
 
91
  # Label -> ISO 639-1 code, built from the full (Anthropic) superset so it
92
  # resolves correctly regardless of which list is currently offered.
93
+ # Labels are just language names (e.g. "French", not "French Translation").
94
  _TRANSLATION_LABEL_TO_CODE = {
95
+ name: code for code, name in ANTHROPIC_TARGET_LANGUAGES.items()
96
  }
97
 
98
  # Sections are pre-built for every language in the full superset (hidden by
 
102
 
103
 
104
  def _compute_translation_choices(has_key: bool, exclude_code: Optional[str]) -> list[str]:
105
+ """The translate-to picker, cascaded from key presence + source language."""
 
 
 
 
 
 
 
 
 
106
  if has_key:
107
  pool = ANTHROPIC_TARGET_LANGUAGES
108
  else:
109
  pool = available_marian_targets(exclude_code) if exclude_code else {}
110
+ return [name for code, name in pool.items() if code != exclude_code]
111
 
112
 
113
  def _on_api_key_change(api_key: str, cached_transcript: Optional[Transcript], current_value: list[str]):
 
279
 
280
  # ------------------------------------------------------------------
281
  # Pass 1: Resolve every section immediately from the cache or by
282
+ # hiding unselected ones. Sections that need a real translation show
283
+ # a "⏳ Translating..." placeholder so the user sees all boxes right
284
+ # away rather than having to wait for each one to appear.
285
  # ------------------------------------------------------------------
286
  section_states: dict[str, Optional[tuple]] = {}
287
  needs_translation: list[str] = []
 
295
  file_path = _write_text_file(text, tmp_dir, f"{code}_cached.txt")
296
  section_states[label] = (True, text, file_path)
297
  else:
298
+ # Show the box immediately with a placeholder; fill it in pass 2.
299
+ section_states[label] = (True, "⏳ Translating...", None)
300
  needs_translation.append(label)
301
 
302
+ # Yield immediately so cached/placeholder results appear at once.
303
  yield _make_outputs(section_states)
304
 
305
  # ------------------------------------------------------------------
 
357
  **Upload Audio &rarr; Select Audio Window &rarr; Detect Language & Generate Transcript
358
  &rarr; Preview & Choose Languages &rarr; Generate Translations &rarr; Copy / Download**
359
 
360
+ <sub>build: 2026-07-02 01:21 UTC &middot; fixed fa/pt/tr model names &middot; short labels &middot; Select All</sub>
361
  """
362
  )
363
 
 
420
  )
421
  with gr.Group(visible=False) as translations_picker_group:
422
  gr.Markdown("Translate to:")
423
+ with gr.Row():
424
+ select_all_btn = gr.Button("Select All", size="sm")
425
+ select_none_btn = gr.Button("Deselect All", size="sm")
426
  translate_choices_input = gr.CheckboxGroup(choices=[], value=[], label=None)
427
  generate_translations_button = gr.Button("Generate Translations", variant="primary")
428
 
 
521
  outputs=[translate_choices_input],
522
  )
523
 
524
+ select_all_btn.click(
525
+ fn=lambda choices: gr.update(value=choices),
526
+ inputs=[translate_choices_input],
527
+ outputs=[translate_choices_input],
528
+ )
529
+ select_none_btn.click(
530
+ fn=lambda: gr.update(value=[]),
531
+ outputs=[translate_choices_input],
532
+ )
533
+
534
  reset_button.click(fn=reset_session_state, outputs=transcript_stage_outputs)
535
 
536
  if __name__ == "__main__":
services/translation.py CHANGED
@@ -2,27 +2,26 @@
2
 
3
  Per the EchoScript v1.0 architecture decision, translation is ALWAYS
4
  derived from the canonical Transcript's text, never from the original
5
- audio:
6
-
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
@@ -55,94 +54,89 @@ LANGUAGE_NAMES: dict[str, str] = {
55
  "ko": "Korean",
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
@@ -151,13 +145,12 @@ def _marian_path_repo_names(src: str, tgt: str) -> Optional[list[str]]:
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
 
@@ -170,11 +163,12 @@ _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
 
180
 
@@ -184,7 +178,6 @@ def _translate_batch_via_anthropic(
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:
189
  response = _anthropic_client(api_key).messages.create(
190
  model=_ANTHROPIC_MODEL,
@@ -193,50 +186,49 @@ def _translate_batch_via_anthropic(
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}"
206
  ) from exc
207
 
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)
214
- if match:
215
- parsed[int(match.group(1))] = match.group(2).strip()
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
 
226
  def _translate_segments_via_anthropic(
227
- segments: list[Segment], source_language: str, target_language: str, api_key: str
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
 
@@ -248,14 +240,8 @@ def _translate_segments_via_anthropic(
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)
@@ -267,37 +253,33 @@ def _load_marian_engine(repo_suffix: str):
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)
273
  return tokenizer.decode(generated[0], skip_special_tokens=True).strip()
274
 
275
 
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:
291
  if not seg.text:
292
- translated_segments.append(seg)
293
  continue
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
 
303
  # ---------------------------------------------------------------------------
@@ -305,47 +287,30 @@ def _translate_segments_via_marian(
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
-
315
- The backend is resolved per call from `api_key`, not stored on the
316
- instance: pass an Anthropic API key to use Claude for that call, or
317
- omit it to use the offline MarianMT backend. This means a single
318
- long-lived TranslationService is safe to share across requests/users --
319
- nothing about any particular key sticks to it.
320
- """
321
-
322
  def translate(
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,
333
  segments=list(transcript.segments),
334
  )
335
-
336
  effective_key = (api_key or "").strip() or os.environ.get("ANTHROPIC_API_KEY")
337
-
338
  if effective_key:
339
- translated_segments = _translate_segments_via_anthropic(
340
  transcript.segments, transcript.language, target_language, effective_key
341
  )
342
  else:
343
- translated_segments = _translate_segments_via_marian(
344
  transcript.segments, transcript.language, target_language
345
  )
346
-
347
  return Translation(
348
  source_language=transcript.language,
349
  target_language=target_language,
350
- segments=translated_segments,
351
  )
 
2
 
3
  Per the EchoScript v1.0 architecture decision, translation is ALWAYS
4
  derived from the canonical Transcript's text, never from the original
5
+ audio.
 
 
 
6
 
7
  Two interchangeable backends, chosen per-request based on whether an
8
  Anthropic API key is supplied -- never stored:
9
 
10
+ - "anthropic": uses Claude for translation via the supplied key. No
11
+ missing-language-pair failure mode. Key is used only for this call.
12
+ - "marian": fully offline, no API key needed. Uses Helsinki-NLP MarianMT
13
+ models. Available targets are determined from KNOWN_MARIAN_PAIRS below.
14
+
15
+ IMPORTANT -- Helsinki-NLP model naming quirks addressed here:
16
+ - Some pairs use multilingual group models (e.g. "en-iir" for all
17
+ Indo-Iranian languages) rather than a direct bilingual model.
18
+ - Those multilingual models require a >>langcode<< token prepended to
19
+ every source segment so the model knows which target language to use.
20
+ - The repo suffix and the optional prefix token are stored together in
21
+ KNOWN_MARIAN_PAIRS so the translation code can apply them correctly.
22
+ - Japanese uses repo code "jap" not "ja" for the en->ja direction.
23
+ - Korean (en->ko) has no confirmed Helsinki-NLP model; it is omitted
24
+ from the Marian pair table so it only appears when an API key exists.
25
  """
26
 
27
  from __future__ import annotations
 
54
  "ko": "Korean",
55
  }
56
 
 
 
 
57
  ANTHROPIC_TARGET_LANGUAGES: dict[str, str] = dict(LANGUAGE_NAMES)
58
 
59
  # ---------------------------------------------------------------------------
60
  # Verified Marian pair table
61
  #
62
+ # Format: (source_code, target_code) -> (repo_suffix, prefix_token)
63
+ #
64
+ # repo_suffix: appended to "Helsinki-NLP/opus-mt-" to form the model name.
65
+ # prefix_token: prepended to every source segment for multilingual models
66
+ # that need a >>langcode<< token to select the target language, or None
67
+ # for standard bilingual models.
 
 
 
 
 
 
68
  #
69
+ # Verified against the Helsinki-NLP catalog. Key findings:
70
+ # - opus-mt-en-fa does NOT exist. Use opus-mt-en-iir (Indo-Iranian
71
+ # group) with >>pes<< (Western Persian, ISO 639-3).
72
+ # - opus-mt-en-pt does NOT exist. Use opus-mt-en-roa (Romance group)
73
+ # with >>por<< (Portuguese, ISO 639-3).
74
+ # - opus-mt-en-tr does NOT exist. Use opus-mt-en-trk (Turkic group)
75
+ # with >>tur<< (Turkish, ISO 639-3).
76
+ # - opus-mt-en-ko has no confirmed model; Korean removed from en->X.
77
+ # - opus-mt-en-jap (note: "jap" not "ja") for English->Japanese.
78
+ # - fa-en uses direct opus-mt-fa-en (not the group model).
79
+ # - pt-en uses opus-mt-roa-en (Romance group; pt-en not on Hub).
80
  # ---------------------------------------------------------------------------
81
+ KNOWN_MARIAN_PAIRS: dict[tuple[str, str], tuple[str, Optional[str]]] = {
82
+ # English -> X
83
+ ("en", "fr"): ("en-fr", None),
84
+ ("en", "de"): ("en-de", None),
85
+ ("en", "fa"): ("en-iir", ">>pes<<"), # Indo-Iranian group, Western Persian
86
+ ("en", "es"): ("en-es", None),
87
+ ("en", "it"): ("en-it", None),
88
+ ("en", "pt"): ("en-roa", ">>por<<"), # Romance group, Portuguese
89
+ ("en", "nl"): ("en-nl", None),
90
+ ("en", "ar"): ("en-ar", None),
91
+ ("en", "ru"): ("en-ru", None),
92
+ ("en", "tr"): ("en-trk", ">>tur<<"), # Turkic group, Turkish
93
+ ("en", "zh"): ("en-zh", None),
94
+ ("en", "ja"): ("en-jap", None), # Note: "jap" not "ja"
95
+ # en->ko omitted: no confirmed Helsinki-NLP model
96
+
97
+ # X -> English
98
+ ("fr", "en"): ("fr-en", None),
99
+ ("de", "en"): ("de-en", None),
100
+ ("fa", "en"): ("fa-en", None),
101
+ ("es", "en"): ("es-en", None),
102
+ ("it", "en"): ("it-en", None),
103
+ ("pt", "en"): ("roa-en", None), # Romance group -> English
104
+ ("nl", "en"): ("nl-en", None),
105
+ ("ar", "en"): ("ar-en", None),
106
+ ("ru", "en"): ("ru-en", None),
107
+ ("tr", "en"): ("tr-en", None),
108
+ ("zh", "en"): ("zh-en", None),
109
+ ("ja", "en"): ("ja-en", None),
110
+ ("ko", "en"): ("ko-en", None),
111
+
112
+ # Selected direct non-English pairs (avoids double pivot hop)
113
+ ("fr", "de"): ("fr-de", None),
114
+ ("de", "fr"): ("de-fr", None),
115
+ ("fr", "es"): ("fr-es", None),
116
+ ("es", "fr"): ("es-fr", None),
117
+ ("de", "es"): ("de-es", None),
118
+ ("es", "de"): ("es-de", None),
119
  }
120
 
121
 
122
+ def _marian_entry(src: str, tgt: str) -> Optional[tuple[str, Optional[str]]]:
 
123
  return KNOWN_MARIAN_PAIRS.get((src, tgt))
124
 
125
 
126
+ def _marian_path(src: str, tgt: str) -> Optional[list[tuple[str, Optional[str]]]]:
127
+ """Return the list of (repo_suffix, prefix_token) steps for src->tgt.
 
 
 
 
128
 
129
+ Returns a 1-step list for a direct (or multilingual-model) pair, a
130
+ 2-step list for an English pivot, or None if no path is known.
131
  """
132
  if src == tgt:
133
  return None
134
+ entry = _marian_entry(src, tgt)
135
+ if entry:
136
+ return [entry]
 
137
  if src != "en" and tgt != "en":
138
+ hop1 = _marian_entry(src, "en")
139
+ hop2 = _marian_entry("en", tgt)
140
  if hop1 and hop2:
141
  return [hop1, hop2]
142
  return None
 
145
  def available_marian_targets(source_language: str) -> dict[str, str]:
146
  """Every language MarianMT can reach from `source_language`.
147
 
148
+ Based on the verified KNOWN_MARIAN_PAIRS table. No network calls.
 
149
  """
150
  return {
151
  code: name
152
  for code, name in LANGUAGE_NAMES.items()
153
+ if code != source_language and _marian_path(source_language, code) is not None
154
  }
155
 
156
 
 
163
  _NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[.\)]\s?(.*)$")
164
 
165
 
166
+ class TranslationError(RuntimeError):
167
+ pass
168
+
169
+
170
  def _anthropic_client(api_key: str):
 
 
171
  import anthropic
 
172
  return anthropic.Anthropic(api_key=api_key)
173
 
174
 
 
178
  source_name = LANGUAGE_NAMES.get(source_language, source_language)
179
  target_name = LANGUAGE_NAMES.get(target_language, target_language)
180
  numbered_input = "\n".join(f"{i + 1}. {text}" for i, text in enumerate(texts))
 
181
  try:
182
  response = _anthropic_client(api_key).messages.create(
183
  model=_ANTHROPIC_MODEL,
 
186
  f"You translate transcript lines from {source_name} to {target_name}. "
187
  "You will be given a numbered list of lines, one sentence or fragment "
188
  "per line. Reply with the same numbers, translated, one per line, in "
189
+ "the same order. Keep the same number of lines as the input. "
190
+ "Output only the numbered translated lines, no preamble or commentary."
 
191
  ),
192
  messages=[{"role": "user", "content": numbered_input}],
193
  )
194
  except Exception as exc:
195
  raise TranslationError(
196
+ f"Anthropic request failed for '{source_language}'->'{target_language}': {exc}"
 
197
  ) from exc
198
 
199
+ raw = "".join(
200
+ b.text for b in response.content if getattr(b, "type", None) == "text"
201
  )
202
  parsed: dict[int, str] = {}
203
+ for line in raw.splitlines():
204
+ m = _NUMBERED_LINE_RE.match(line)
205
+ if m:
206
+ parsed[int(m.group(1))] = m.group(2).strip()
207
 
208
  if len(parsed) != len(texts) or any((i + 1) not in parsed for i in range(len(texts))):
209
  raise TranslationError(
210
+ f"Anthropic response line count mismatch for "
211
+ f"'{source_language}'->'{target_language}' "
212
+ f"(expected {len(texts)}, got {len(parsed)})."
213
  )
214
  return [parsed[i + 1] for i in range(len(texts))]
215
 
216
 
217
  def _translate_segments_via_anthropic(
218
+ segments: list[Segment], src: str, tgt: str, api_key: str
219
  ) -> list[Segment]:
220
  non_empty = [(i, seg) for i, seg in enumerate(segments) if seg.text]
221
+ by_index: dict[int, str] = {}
222
  for start in range(0, len(non_empty), _ANTHROPIC_BATCH_SIZE):
223
+ chunk = non_empty[start: start + _ANTHROPIC_BATCH_SIZE]
224
+ translated = _translate_batch_via_anthropic(
225
+ [s.text for _, s in chunk], src, tgt, api_key
226
+ )
227
  for (i, _), text in zip(chunk, translated):
228
+ by_index[i] = text
229
  return [
230
  Segment(index=seg.index, start=seg.start, end=seg.end,
231
+ text=by_index.get(i, seg.text))
232
  for i, seg in enumerate(segments)
233
  ]
234
 
 
240
 
241
  @lru_cache(maxsize=None)
242
  def _load_marian_engine(repo_suffix: str):
243
+ """Load and cache a MarianMT model+tokenizer by repo suffix."""
 
 
 
 
 
244
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
 
245
  model_name = f"Helsinki-NLP/opus-mt-{repo_suffix}"
246
  try:
247
  tokenizer = AutoTokenizer.from_pretrained(model_name)
 
253
  return tokenizer, model
254
 
255
 
256
+ def _run_marian(tokenizer, model, text: str, prefix: Optional[str]) -> str:
257
+ """Translate one text segment, optionally prepending a language token."""
258
+ source = f"{prefix} {text}" if prefix else text
259
+ inputs = tokenizer(source, return_tensors="pt", truncation=True)
260
  generated = model.generate(**inputs, max_new_tokens=512)
261
  return tokenizer.decode(generated[0], skip_special_tokens=True).strip()
262
 
263
 
264
  def _translate_segments_via_marian(
265
+ segments: list[Segment], src: str, tgt: str
266
  ) -> list[Segment]:
267
+ steps = _marian_path(src, tgt)
268
+ if not steps:
269
  raise TranslationError(
270
+ f"No Marian translation path known for '{src}'->'{tgt}'."
 
271
  )
272
+ engines = [(_load_marian_engine(suffix), prefix) for suffix, prefix in steps]
273
+ result = []
 
 
 
274
  for seg in segments:
275
  if not seg.text:
276
+ result.append(seg)
277
  continue
278
  text = seg.text
279
+ for (tokenizer, model), prefix in engines:
280
+ text = _run_marian(tokenizer, model, text, prefix)
281
+ result.append(Segment(index=seg.index, start=seg.start, end=seg.end, text=text))
282
+ return result
 
 
283
 
284
 
285
  # ---------------------------------------------------------------------------
 
287
  # ---------------------------------------------------------------------------
288
 
289
 
 
 
 
 
290
  class TranslationService:
 
 
 
 
 
 
 
 
 
291
  def translate(
292
  self,
293
  transcript: Transcript,
294
  target_language: str,
295
  api_key: Optional[str] = None,
296
  ) -> Translation:
 
297
  if target_language == transcript.language:
298
  return Translation(
299
  source_language=transcript.language,
300
  target_language=target_language,
301
  segments=list(transcript.segments),
302
  )
 
303
  effective_key = (api_key or "").strip() or os.environ.get("ANTHROPIC_API_KEY")
 
304
  if effective_key:
305
+ translated = _translate_segments_via_anthropic(
306
  transcript.segments, transcript.language, target_language, effective_key
307
  )
308
  else:
309
+ translated = _translate_segments_via_marian(
310
  transcript.segments, transcript.language, target_language
311
  )
 
312
  return Translation(
313
  source_language=transcript.language,
314
  target_language=target_language,
315
+ segments=translated,
316
  )