Spaces:
Sleeping
Sleeping
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- app.py +24 -19
- 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 |
-
|
| 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 [
|
| 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.
|
| 291 |
-
#
|
| 292 |
-
#
|
| 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 |
-
#
|
| 307 |
-
section_states[label] = None
|
| 308 |
needs_translation.append(label)
|
| 309 |
|
| 310 |
-
# Yield immediately so cached results appear
|
| 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-
|
| 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 → Select Audio Window → Detect Language & Generate Transcript
|
| 358 |
→ Preview & Choose Languages → Generate Translations → Copy / Download**
|
| 359 |
|
| 360 |
+
<sub>build: 2026-07-02 01:21 UTC · fixed fa/pt/tr model names · short labels · 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":
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
| 67 |
-
#
|
| 68 |
-
#
|
| 69 |
-
#
|
| 70 |
-
#
|
| 71 |
-
#
|
| 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 |
-
#
|
| 80 |
-
#
|
| 81 |
-
#
|
| 82 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
# ---------------------------------------------------------------------------
|
| 84 |
-
KNOWN_MARIAN_PAIRS: dict[tuple[str, str], str] = {
|
| 85 |
-
# English
|
| 86 |
-
("en", "fr"): "en-fr",
|
| 87 |
-
("
|
| 88 |
-
("en", "
|
| 89 |
-
("
|
| 90 |
-
("en", "
|
| 91 |
-
("
|
| 92 |
-
("en", "
|
| 93 |
-
("
|
| 94 |
-
("en", "
|
| 95 |
-
("
|
| 96 |
-
("en", "
|
| 97 |
-
("
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
("
|
| 102 |
-
("
|
| 103 |
-
("
|
| 104 |
-
("
|
| 105 |
-
("
|
| 106 |
-
("
|
| 107 |
-
("
|
| 108 |
-
("
|
| 109 |
-
("
|
| 110 |
-
("
|
| 111 |
-
("
|
| 112 |
-
|
| 113 |
-
("
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
("
|
| 117 |
-
("de", "
|
| 118 |
-
("
|
|
|
|
|
|
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
|
| 122 |
-
def
|
| 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
|
| 128 |
-
|
| 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-
|
| 135 |
-
|
| 136 |
"""
|
| 137 |
if src == tgt:
|
| 138 |
return None
|
| 139 |
-
|
| 140 |
-
if
|
| 141 |
-
return [
|
| 142 |
-
# English pivot: src->en->tgt
|
| 143 |
if src != "en" and tgt != "en":
|
| 144 |
-
hop1 =
|
| 145 |
-
hop2 =
|
| 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
|
| 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
|
| 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
|
| 197 |
-
"
|
| 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
|
| 205 |
-
f"'{source_language}' -> '{target_language}': {exc}"
|
| 206 |
) from exc
|
| 207 |
|
| 208 |
-
|
| 209 |
-
|
| 210 |
)
|
| 211 |
parsed: dict[int, str] = {}
|
| 212 |
-
for line in
|
| 213 |
-
|
| 214 |
-
if
|
| 215 |
-
parsed[int(
|
| 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
|
| 220 |
-
f"'{source_language}'
|
| 221 |
-
f"(expected {len(texts)},
|
| 222 |
)
|
| 223 |
return [parsed[i + 1] for i in range(len(texts))]
|
| 224 |
|
| 225 |
|
| 226 |
def _translate_segments_via_anthropic(
|
| 227 |
-
segments: list[Segment],
|
| 228 |
) -> list[Segment]:
|
| 229 |
non_empty = [(i, seg) for i, seg in enumerate(segments) if seg.text]
|
| 230 |
-
|
| 231 |
for start in range(0, len(non_empty), _ANTHROPIC_BATCH_SIZE):
|
| 232 |
-
chunk = non_empty[start
|
| 233 |
-
|
| 234 |
-
|
|
|
|
| 235 |
for (i, _), text in zip(chunk, translated):
|
| 236 |
-
|
| 237 |
return [
|
| 238 |
Segment(index=seg.index, start=seg.start, end=seg.end,
|
| 239 |
-
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
|
| 271 |
-
|
|
|
|
|
|
|
| 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],
|
| 278 |
) -> list[Segment]:
|
| 279 |
-
|
| 280 |
-
if not
|
| 281 |
raise TranslationError(
|
| 282 |
-
f"No Marian translation path known for "
|
| 283 |
-
f"'{source_language}' -> '{target_language}'."
|
| 284 |
)
|
| 285 |
-
|
| 286 |
-
|
| 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 |
-
|
| 293 |
continue
|
| 294 |
text = seg.text
|
| 295 |
-
for tokenizer, model in engines:
|
| 296 |
-
text =
|
| 297 |
-
|
| 298 |
-
|
| 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 |
-
|
| 340 |
transcript.segments, transcript.language, target_language, effective_key
|
| 341 |
)
|
| 342 |
else:
|
| 343 |
-
|
| 344 |
transcript.segments, transcript.language, target_language
|
| 345 |
)
|
| 346 |
-
|
| 347 |
return Translation(
|
| 348 |
source_language=transcript.language,
|
| 349 |
target_language=target_language,
|
| 350 |
-
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 |
)
|