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,
        )