Spaces:
Sleeping
Sleeping
File size: 12,049 Bytes
7d761b6 c3d36fc 7d761b6 7198bd8 7f4c279 c3d36fc 7d761b6 7f4c279 7d761b6 7198bd8 7d761b6 7198bd8 7f4c279 ae768cc 1788966 7d761b6 ae768cc 7198bd8 c3d36fc 7198bd8 c3d36fc 7198bd8 c3d36fc 7198bd8 7f4c279 7d761b6 c3d36fc 7198bd8 c3d36fc 7198bd8 c3d36fc 7198bd8 c3d36fc 7198bd8 c3d36fc 7198bd8 c3d36fc 7198bd8 c3d36fc 7198bd8 7f4c279 7198bd8 7f4c279 c3d36fc ae768cc 7198bd8 ae768cc 7f4c279 ae768cc 7f4c279 ae768cc 7f4c279 7198bd8 c3d36fc 7f4c279 7198bd8 7f4c279 c3d36fc 7f4c279 c3d36fc 7f4c279 c3d36fc 7f4c279 c3d36fc 7f4c279 c3d36fc 7f4c279 c3d36fc 7f4c279 c3d36fc 7f4c279 c3d36fc 7f4c279 7198bd8 c3d36fc 7f4c279 7d761b6 7198bd8 c3d36fc 7198bd8 7d761b6 0f04f89 7198bd8 0f04f89 c3d36fc 0f04f89 7d761b6 7f4c279 c3d36fc 7f4c279 c3d36fc 7198bd8 c3d36fc 7198bd8 c3d36fc 7f4c279 c3d36fc 7f4c279 c3d36fc 7f4c279 7d761b6 ae768cc 7198bd8 ae768cc 7d761b6 ae768cc c3d36fc ae768cc 7f4c279 c3d36fc 7f4c279 7d761b6 c3d36fc 7d761b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | """Translation service: Transcript -> Translation (text only).
Per the EchoScript v1.0 architecture decision, translation is ALWAYS
derived from the canonical Transcript's text, never from the original
audio.
Two interchangeable backends, chosen per-request based on whether an
Anthropic API key is supplied -- never stored:
- "anthropic": uses Claude for translation via the supplied key. No
missing-language-pair failure mode. Key is used only for this call.
- "marian": fully offline, no API key needed. Uses Helsinki-NLP MarianMT
models. Available targets are determined from KNOWN_MARIAN_PAIRS below.
IMPORTANT -- Helsinki-NLP model naming quirks addressed here:
- Some pairs use multilingual group models (e.g. "en-iir" for all
Indo-Iranian languages) rather than a direct bilingual model.
- Those multilingual models require a >>langcode<< token prepended to
every source segment so the model knows which target language to use.
- The repo suffix and the optional prefix token are stored together in
KNOWN_MARIAN_PAIRS so the translation code can apply them correctly.
- Japanese uses repo code "jap" not "ja" for the en->ja direction.
- Korean (en->ko) has no confirmed Helsinki-NLP model; it is omitted
from the Marian pair table so it only appears when an API key exists.
"""
from __future__ import annotations
import os
import re
from functools import lru_cache
from typing import Optional
from models.transcript import Segment, Transcript, Translation
# ---------------------------------------------------------------------------
# Language catalog
# ---------------------------------------------------------------------------
LANGUAGE_NAMES: dict[str, str] = {
"fr": "French",
"en": "English",
"de": "German",
"fa": "Persian",
"es": "Spanish",
"it": "Italian",
"pt": "Portuguese",
"nl": "Dutch",
"ar": "Arabic",
"ru": "Russian",
"tr": "Turkish",
"ja": "Japanese",
"zh": "Chinese",
"ko": "Korean",
}
ANTHROPIC_TARGET_LANGUAGES: dict[str, str] = dict(LANGUAGE_NAMES)
# ---------------------------------------------------------------------------
# Verified Marian pair table
#
# Format: (source_code, target_code) -> (repo_suffix, prefix_token)
#
# repo_suffix: appended to "Helsinki-NLP/opus-mt-" to form the model name.
# prefix_token: prepended to every source segment for multilingual models
# that need a >>langcode<< token to select the target language, or None
# for standard bilingual models.
#
# Verified against the Helsinki-NLP catalog. Key findings:
# - opus-mt-en-fa does NOT exist. Use opus-mt-en-iir (Indo-Iranian
# group) with >>pes<< (Western Persian, ISO 639-3).
# - opus-mt-en-pt does NOT exist. Use opus-mt-en-roa (Romance group)
# with >>por<< (Portuguese, ISO 639-3).
# - opus-mt-en-tr does NOT exist. Use opus-mt-en-trk (Turkic group)
# with >>tur<< (Turkish, ISO 639-3).
# - opus-mt-en-ko has no confirmed model; Korean removed from en->X.
# - opus-mt-en-jap (note: "jap" not "ja") for English->Japanese.
# - fa-en uses direct opus-mt-fa-en (not the group model).
# - pt-en uses opus-mt-roa-en (Romance group; pt-en not on Hub).
# ---------------------------------------------------------------------------
KNOWN_MARIAN_PAIRS: dict[tuple[str, str], tuple[str, Optional[str]]] = {
# English -> X
("en", "fr"): ("en-fr", None),
("en", "de"): ("en-de", None),
("en", "fa"): ("en-iir", ">>pes<<"), # Indo-Iranian group, Western Persian
("en", "es"): ("en-es", None),
("en", "it"): ("en-it", None),
("en", "pt"): ("en-roa", ">>por<<"), # Romance group, Portuguese
("en", "nl"): ("en-nl", None),
("en", "ar"): ("en-ar", None),
("en", "ru"): ("en-ru", None),
("en", "tr"): ("en-trk", ">>tur<<"), # Turkic group, Turkish
("en", "zh"): ("en-zh", None),
("en", "ja"): ("en-jap", None), # Note: "jap" not "ja"
# en->ko omitted: no confirmed Helsinki-NLP model
# X -> English
("fr", "en"): ("fr-en", None),
("de", "en"): ("de-en", None),
("fa", "en"): ("fa-en", None),
("es", "en"): ("es-en", None),
("it", "en"): ("it-en", None),
("pt", "en"): ("roa-en", None), # Romance group -> English
("nl", "en"): ("nl-en", None),
("ar", "en"): ("ar-en", None),
("ru", "en"): ("ru-en", None),
("tr", "en"): ("tr-en", None),
("zh", "en"): ("zh-en", None),
("ja", "en"): ("ja-en", None),
("ko", "en"): ("ko-en", None),
# Selected direct non-English pairs (avoids double pivot hop)
("fr", "de"): ("fr-de", None),
("de", "fr"): ("de-fr", None),
("fr", "es"): ("fr-es", None),
("es", "fr"): ("es-fr", None),
("de", "es"): ("de-es", None),
("es", "de"): ("es-de", None),
}
def _marian_entry(src: str, tgt: str) -> Optional[tuple[str, Optional[str]]]:
return KNOWN_MARIAN_PAIRS.get((src, tgt))
def _marian_path(src: str, tgt: str) -> Optional[list[tuple[str, Optional[str]]]]:
"""Return the list of (repo_suffix, prefix_token) steps for src->tgt.
Returns a 1-step list for a direct (or multilingual-model) pair, a
2-step list for an English pivot, or None if no path is known.
"""
if src == tgt:
return None
entry = _marian_entry(src, tgt)
if entry:
return [entry]
if src != "en" and tgt != "en":
hop1 = _marian_entry(src, "en")
hop2 = _marian_entry("en", tgt)
if hop1 and hop2:
return [hop1, hop2]
return None
def available_marian_targets(source_language: str) -> dict[str, str]:
"""Every language MarianMT can reach from `source_language`.
Based on the verified KNOWN_MARIAN_PAIRS table. No network calls.
"""
return {
code: name
for code, name in LANGUAGE_NAMES.items()
if code != source_language and _marian_path(source_language, code) is not None
}
# ---------------------------------------------------------------------------
# Anthropic backend
# ---------------------------------------------------------------------------
_ANTHROPIC_MODEL = "claude-haiku-4-5-20251001"
_ANTHROPIC_BATCH_SIZE = 40
_NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[.\)]\s?(.*)$")
class TranslationError(RuntimeError):
pass
def _anthropic_client(api_key: str):
import anthropic
return anthropic.Anthropic(api_key=api_key)
def _translate_batch_via_anthropic(
texts: list[str], source_language: str, target_language: str, api_key: str
) -> list[str]:
source_name = LANGUAGE_NAMES.get(source_language, source_language)
target_name = LANGUAGE_NAMES.get(target_language, target_language)
numbered_input = "\n".join(f"{i + 1}. {text}" for i, text in enumerate(texts))
try:
response = _anthropic_client(api_key).messages.create(
model=_ANTHROPIC_MODEL,
max_tokens=4096,
system=(
f"You translate transcript lines from {source_name} to {target_name}. "
"You will be given a numbered list of lines, one sentence or fragment "
"per line. Reply with the same numbers, translated, one per line, in "
"the same order. Keep the same number of lines as the input. "
"Output only the numbered translated lines, no preamble or commentary."
),
messages=[{"role": "user", "content": numbered_input}],
)
except Exception as exc:
raise TranslationError(
f"Anthropic request failed for '{source_language}'->'{target_language}': {exc}"
) from exc
raw = "".join(
b.text for b in response.content if getattr(b, "type", None) == "text"
)
parsed: dict[int, str] = {}
for line in raw.splitlines():
m = _NUMBERED_LINE_RE.match(line)
if m:
parsed[int(m.group(1))] = m.group(2).strip()
if len(parsed) != len(texts) or any((i + 1) not in parsed for i in range(len(texts))):
raise TranslationError(
f"Anthropic response line count mismatch for "
f"'{source_language}'->'{target_language}' "
f"(expected {len(texts)}, got {len(parsed)})."
)
return [parsed[i + 1] for i in range(len(texts))]
def _translate_segments_via_anthropic(
segments: list[Segment], src: str, tgt: str, api_key: str
) -> list[Segment]:
non_empty = [(i, seg) for i, seg in enumerate(segments) if seg.text]
by_index: dict[int, str] = {}
for start in range(0, len(non_empty), _ANTHROPIC_BATCH_SIZE):
chunk = non_empty[start: start + _ANTHROPIC_BATCH_SIZE]
translated = _translate_batch_via_anthropic(
[s.text for _, s in chunk], src, tgt, api_key
)
for (i, _), text in zip(chunk, translated):
by_index[i] = text
return [
Segment(index=seg.index, start=seg.start, end=seg.end,
text=by_index.get(i, seg.text))
for i, seg in enumerate(segments)
]
# ---------------------------------------------------------------------------
# Marian (offline) backend
# ---------------------------------------------------------------------------
@lru_cache(maxsize=None)
def _load_marian_engine(repo_suffix: str):
"""Load and cache a MarianMT model+tokenizer by repo suffix."""
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
model_name = f"Helsinki-NLP/opus-mt-{repo_suffix}"
try:
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
except Exception as exc:
raise TranslationError(
f"Failed to load MarianMT model '{model_name}': {exc}"
) from exc
return tokenizer, model
def _run_marian(tokenizer, model, text: str, prefix: Optional[str]) -> str:
"""Translate one text segment, optionally prepending a language token."""
source = f"{prefix} {text}" if prefix else text
inputs = tokenizer(source, return_tensors="pt", truncation=True)
generated = model.generate(**inputs, max_new_tokens=512)
return tokenizer.decode(generated[0], skip_special_tokens=True).strip()
def _translate_segments_via_marian(
segments: list[Segment], src: str, tgt: str
) -> list[Segment]:
steps = _marian_path(src, tgt)
if not steps:
raise TranslationError(
f"No Marian translation path known for '{src}'->'{tgt}'."
)
engines = [(_load_marian_engine(suffix), prefix) for suffix, prefix in steps]
result = []
for seg in segments:
if not seg.text:
result.append(seg)
continue
text = seg.text
for (tokenizer, model), prefix in engines:
text = _run_marian(tokenizer, model, text, prefix)
result.append(Segment(index=seg.index, start=seg.start, end=seg.end, text=text))
return result
# ---------------------------------------------------------------------------
# Public service
# ---------------------------------------------------------------------------
class TranslationService:
def translate(
self,
transcript: Transcript,
target_language: str,
api_key: Optional[str] = None,
) -> Translation:
if target_language == transcript.language:
return Translation(
source_language=transcript.language,
target_language=target_language,
segments=list(transcript.segments),
)
effective_key = (api_key or "").strip() or os.environ.get("ANTHROPIC_API_KEY")
if effective_key:
translated = _translate_segments_via_anthropic(
transcript.segments, transcript.language, target_language, effective_key
)
else:
translated = _translate_segments_via_marian(
transcript.segments, transcript.language, target_language
)
return Translation(
source_language=transcript.language,
target_language=target_language,
segments=translated,
)
|