usertea commited on
Commit
7f4c279
·
1 Parent(s): 0f04f89

EchoScript : made it automatic, not exclusive: TranslationService picks anthropic when ANTHROPIC_API_KEY is set, otherwise falls back to marian (fully offline/free) - overridable via ECHOSCRIPT_TRANSLATION_BACKEND=anthropic|marian. So EchoScript still works with zero API key, but gets better translation quality and zero missing-pair errors the moment you set one.

Browse files
Files changed (2) hide show
  1. requirements.txt +1 -0
  2. services/translation.py +216 -33
requirements.txt CHANGED
@@ -4,3 +4,4 @@ transformers>=4.40
4
  sentencepiece>=0.2
5
  sacremoses>=0.1
6
  torch>=2.0
 
 
4
  sentencepiece>=0.2
5
  sacremoses>=0.1
6
  torch>=2.0
7
+ anthropic>=0.40
services/translation.py CHANGED
@@ -12,14 +12,44 @@ 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
 
17
  from __future__ import annotations
18
 
 
 
19
  from functools import lru_cache
20
 
21
  from models.transcript import Segment, Transcript, Translation
22
 
 
 
 
 
 
 
 
 
 
 
 
23
  # Target languages exposed as the "Outputs" checkboxes in the UI.
24
  SUPPORTED_TARGET_LANGUAGES: dict[str, str] = {
25
  "en": "English",
@@ -28,27 +58,131 @@ SUPPORTED_TARGET_LANGUAGES: dict[str, str] = {
28
  "es": "Spanish",
29
  }
30
 
 
 
 
 
 
31
 
32
  class TranslationError(RuntimeError):
33
- """Raised when no translation model/engine is available for a pair."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
 
36
  @lru_cache(maxsize=None)
37
- def _load_engine(source_language: str, target_language: str):
38
- """Lazily load and cache a MarianMT model+tokenizer for one language pair.
39
-
40
- Cached so repeated translations within a session don't reload a model
41
- from disk every time. Swapping the translation backend later (a
42
- different model, a hosted API, an offline engine like Argos) only
43
- requires changing this one function.
44
-
45
- Note: this loads the model/tokenizer directly via AutoModelForSeq2SeqLM
46
- rather than transformers.pipeline("translation", ...). As of
47
- transformers v5, the generic "translation" pipeline task was removed
48
- entirely (see huggingface/transformers#43825) -- pipeline("translation")
49
- now raises KeyError. Loading the model directly and calling
50
- `model.generate()` is unaffected by that change and is the path the
51
- transformers docs now show for MarianMT.
52
  """
53
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer # heavy import, deferred
54
 
@@ -56,22 +190,74 @@ def _load_engine(source_language: str, target_language: str):
56
  try:
57
  tokenizer = AutoTokenizer.from_pretrained(model_name)
58
  model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
59
- except Exception as exc: # pragma: no cover - depends on model availability
60
- raise TranslationError(
61
- f"No translation model available for "
62
- f"'{source_language}' -> '{target_language}': {exc}"
63
- ) from exc
64
  return tokenizer, model
65
 
66
 
67
- def _run_translation(tokenizer, model, text: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  inputs = tokenizer(text, return_tensors="pt", truncation=True)
69
  generated = model.generate(**inputs, max_new_tokens=512)
70
  return tokenizer.decode(generated[0], skip_special_tokens=True).strip()
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  class TranslationService:
74
- """Translates a Transcript into one or more target languages."""
 
 
 
 
 
 
 
 
75
 
76
  def translate(self, transcript: Transcript, target_language: str) -> Translation:
77
  """Translate every segment of `transcript`, preserving timing."""
@@ -83,16 +269,13 @@ class TranslationService:
83
  segments=list(transcript.segments),
84
  )
85
 
86
- tokenizer, model = _load_engine(transcript.language, target_language)
87
-
88
- translated_segments = []
89
- for seg in transcript.segments:
90
- if not seg.text:
91
- translated_segments.append(seg)
92
- continue
93
- result_text = _run_translation(tokenizer, model, seg.text)
94
- translated_segments.append(
95
- Segment(index=seg.index, start=seg.start, end=seg.end, text=result_text)
96
  )
97
 
98
  return Translation(
 
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:
17
+
18
+ - "anthropic": sends transcript text to the Claude API for translation.
19
+ Used automatically when an ANTHROPIC_API_KEY is configured (the same
20
+ BYOK pattern Archon uses). No missing-language-pair failure mode --
21
+ every supported language translates directly to every other one in a
22
+ single call, with no local model downloads.
23
+ - "marian": fully offline, no API key needed. Uses local Helsinki-NLP
24
+ MarianMT models via `transformers`. Helsinki-NLP doesn't publish a
25
+ direct model for every language pair (Persian in particular only has
26
+ reliable direct models paired with English), so this backend pivots
27
+ through English when no direct model exists for a pair.
28
+
29
+ Set ECHOSCRIPT_TRANSLATION_BACKEND=anthropic|marian to force one; if
30
+ unset, "anthropic" is used when ANTHROPIC_API_KEY is present, otherwise
31
+ "marian".
32
  """
33
 
34
  from __future__ import annotations
35
 
36
+ import os
37
+ import re
38
  from functools import lru_cache
39
 
40
  from models.transcript import Segment, Transcript, Translation
41
 
42
+ # Display names for every language EchoScript can act as a source or
43
+ # target -- used both for the UI's language dropdown/checkboxes (via
44
+ # services/transcription.py) and for prompting the Anthropic backend.
45
+ LANGUAGE_NAMES: dict[str, str] = {
46
+ "fr": "French",
47
+ "en": "English",
48
+ "de": "German",
49
+ "fa": "Persian",
50
+ "es": "Spanish",
51
+ }
52
+
53
  # Target languages exposed as the "Outputs" checkboxes in the UI.
54
  SUPPORTED_TARGET_LANGUAGES: dict[str, str] = {
55
  "en": "English",
 
58
  "es": "Spanish",
59
  }
60
 
61
+ # Anthropic model used for translation -- Haiku is fast and inexpensive,
62
+ # which fits well for what is otherwise a mechanical translation task.
63
+ _ANTHROPIC_MODEL = "claude-haiku-4-5-20251001"
64
+ _ANTHROPIC_BATCH_SIZE = 40 # segments per API call, to keep prompts small
65
+
66
 
67
  class TranslationError(RuntimeError):
68
+ """Raised when no translation backend/model is available for a pair."""
69
+
70
+
71
+ def _resolve_backend() -> str:
72
+ forced = os.environ.get("ECHOSCRIPT_TRANSLATION_BACKEND", "").strip().lower()
73
+ if forced in ("anthropic", "marian"):
74
+ return forced
75
+ return "anthropic" if os.environ.get("ANTHROPIC_API_KEY") else "marian"
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Anthropic backend
80
+ # ---------------------------------------------------------------------------
81
+
82
+ _NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[.\)]\s?(.*)$")
83
+
84
+
85
+ @lru_cache(maxsize=1)
86
+ def _anthropic_client():
87
+ import anthropic # heavy import, deferred until needed
88
+
89
+ return anthropic.Anthropic()
90
+
91
+
92
+ def _translate_batch_via_anthropic(
93
+ texts: list[str], source_language: str, target_language: str
94
+ ) -> list[str]:
95
+ """Translate a batch of lines in one Claude call, preserving order/count."""
96
+ source_name = LANGUAGE_NAMES.get(source_language, source_language)
97
+ target_name = LANGUAGE_NAMES.get(target_language, target_language)
98
+
99
+ numbered_input = "\n".join(f"{i + 1}. {text}" for i, text in enumerate(texts))
100
+
101
+ try:
102
+ response = _anthropic_client().messages.create(
103
+ model=_ANTHROPIC_MODEL,
104
+ max_tokens=4096,
105
+ system=(
106
+ f"You translate transcript lines from {source_name} to {target_name}. "
107
+ "You will be given a numbered list of lines, one sentence or "
108
+ "fragment per line. Reply with the same numbers, translated, "
109
+ "one per line, in the same order. Keep the same number of "
110
+ "lines as the input -- never merge, split, drop, or add lines. "
111
+ "Output only the numbered translated lines, with no preamble, "
112
+ "no explanations, and no extra commentary."
113
+ ),
114
+ messages=[{"role": "user", "content": numbered_input}],
115
+ )
116
+ except Exception as exc: # pragma: no cover - depends on network/API key
117
+ raise TranslationError(
118
+ f"Anthropic translation request failed for "
119
+ f"'{source_language}' -> '{target_language}': {exc}"
120
+ ) from exc
121
+
122
+ raw_text = "".join(
123
+ block.text for block in response.content if getattr(block, "type", None) == "text"
124
+ )
125
+
126
+ parsed: dict[int, str] = {}
127
+ for line in raw_text.splitlines():
128
+ match = _NUMBERED_LINE_RE.match(line)
129
+ if match:
130
+ parsed[int(match.group(1))] = match.group(2).strip()
131
+
132
+ if len(parsed) != len(texts) or any((i + 1) not in parsed for i in range(len(texts))):
133
+ raise TranslationError(
134
+ f"Anthropic translation response didn't match expected line count "
135
+ f"for '{source_language}' -> '{target_language}' "
136
+ f"(expected {len(texts)}, parsed {len(parsed)})."
137
+ )
138
+
139
+ return [parsed[i + 1] for i in range(len(texts))]
140
+
141
+
142
+ def _translate_segments_via_anthropic(
143
+ segments: list[Segment], source_language: str, target_language: str
144
+ ) -> list[Segment]:
145
+ non_empty = [(i, seg) for i, seg in enumerate(segments) if seg.text]
146
+ translated_text_by_index: dict[int, str] = {}
147
+
148
+ for start in range(0, len(non_empty), _ANTHROPIC_BATCH_SIZE):
149
+ chunk = non_empty[start : start + _ANTHROPIC_BATCH_SIZE]
150
+ texts = [seg.text for _, seg in chunk]
151
+ translated = _translate_batch_via_anthropic(texts, source_language, target_language)
152
+ for (i, _), text in zip(chunk, translated):
153
+ translated_text_by_index[i] = text
154
+
155
+ return [
156
+ Segment(
157
+ index=seg.index,
158
+ start=seg.start,
159
+ end=seg.end,
160
+ text=translated_text_by_index.get(i, seg.text),
161
+ )
162
+ for i, seg in enumerate(segments)
163
+ ]
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Marian (offline) backend
168
+ # ---------------------------------------------------------------------------
169
 
170
 
171
  @lru_cache(maxsize=None)
172
+ def _try_load_marian_engine(source_language: str, target_language: str):
173
+ """Try to load+cache a MarianMT model+tokenizer for one language pair.
174
+
175
+ Returns (tokenizer, model), or None if no such model exists on the Hub
176
+ (e.g. Helsinki-NLP doesn't publish every pair directly). Cached either
177
+ way so a missing pair isn't re-checked over the network on every call.
178
+
179
+ Loads the model/tokenizer directly via AutoModelForSeq2SeqLM rather
180
+ than transformers.pipeline("translation", ...): as of transformers v5,
181
+ the generic "translation" pipeline task was removed entirely (see
182
+ huggingface/transformers#43825) -- pipeline("translation") now raises
183
+ KeyError. Loading the model directly and calling `model.generate()` is
184
+ unaffected by that change and is the path the transformers docs now
185
+ show for MarianMT.
 
186
  """
187
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer # heavy import, deferred
188
 
 
190
  try:
191
  tokenizer = AutoTokenizer.from_pretrained(model_name)
192
  model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
193
+ except Exception: # pragma: no cover - depends on model availability
194
+ return None
 
 
 
195
  return tokenizer, model
196
 
197
 
198
+ def _resolve_marian_engines(source_language: str, target_language: str) -> list[tuple]:
199
+ """Work out which model(s) to chain to get from source to target.
200
+
201
+ Prefers a single direct Helsinki-NLP model. If none exists for the
202
+ pair, pivots through English (source -> en -> target), since that's
203
+ where Helsinki-NLP's coverage is densest -- this is what makes
204
+ something like French -> Persian work even though no direct
205
+ opus-mt-fr-fa model exists.
206
+ """
207
+ direct = _try_load_marian_engine(source_language, target_language)
208
+ if direct is not None:
209
+ return [direct]
210
+
211
+ if source_language != "en" and target_language != "en":
212
+ hop1 = _try_load_marian_engine(source_language, "en")
213
+ hop2 = _try_load_marian_engine("en", target_language)
214
+ if hop1 is not None and hop2 is not None:
215
+ return [hop1, hop2]
216
+
217
+ raise TranslationError(
218
+ f"No direct or English-pivot translation model available for "
219
+ f"'{source_language}' -> '{target_language}'."
220
+ )
221
+
222
+
223
+ def _run_marian_translation(tokenizer, model, text: str) -> str:
224
  inputs = tokenizer(text, return_tensors="pt", truncation=True)
225
  generated = model.generate(**inputs, max_new_tokens=512)
226
  return tokenizer.decode(generated[0], skip_special_tokens=True).strip()
227
 
228
 
229
+ def _translate_segments_via_marian(
230
+ segments: list[Segment], source_language: str, target_language: str
231
+ ) -> list[Segment]:
232
+ engines = _resolve_marian_engines(source_language, target_language)
233
+
234
+ translated_segments = []
235
+ for seg in segments:
236
+ if not seg.text:
237
+ translated_segments.append(seg)
238
+ continue
239
+ text = seg.text
240
+ for tokenizer, model in engines:
241
+ text = _run_marian_translation(tokenizer, model, text)
242
+ translated_segments.append(Segment(index=seg.index, start=seg.start, end=seg.end, text=text))
243
+ return translated_segments
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Public service
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
  class TranslationService:
252
+ """Translates a Transcript into one or more target languages.
253
+
254
+ Backend ("anthropic" or "marian") is resolved once at construction
255
+ time -- pass `backend` explicitly to override the
256
+ ECHOSCRIPT_TRANSLATION_BACKEND / ANTHROPIC_API_KEY auto-detection.
257
+ """
258
+
259
+ def __init__(self, backend: str | None = None) -> None:
260
+ self.backend = backend or _resolve_backend()
261
 
262
  def translate(self, transcript: Transcript, target_language: str) -> Translation:
263
  """Translate every segment of `transcript`, preserving timing."""
 
269
  segments=list(transcript.segments),
270
  )
271
 
272
+ if self.backend == "anthropic":
273
+ translated_segments = _translate_segments_via_anthropic(
274
+ transcript.segments, transcript.language, target_language
275
+ )
276
+ else:
277
+ translated_segments = _translate_segments_via_marian(
278
+ transcript.segments, transcript.language, target_language
 
 
 
279
  )
280
 
281
  return Translation(