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

What changed:

Browse files

services/translation.py

TranslationService.translate()/translate_many() now take an api_key parameter, checked fresh on every call — no instance state holds it.
Key present → routes to the Claude backend, using ANTHROPIC_TARGET_LANGUAGES (the full 11-language list: French, English, German, Persian, Spanish, Italian, Portuguese, Dutch, Arabic, Russian, Turkish).
Key absent → routes to MarianMT (MARIAN_TARGET_LANGUAGES: English, German, Persian, Spanish — the set where direct/English-pivot coverage is solid).
_anthropic_client(api_key) builds a fresh client per call and is not cached — the key lives only for the duration of that one translation call, never persisted in module state, never logged in error messages, never written anywhere.

app.py

New password-masked "Anthropic API Key (optional)" field with inline copy explaining the trade-off.
The Outputs checkbox list updates live as the key field changes (.change() handler): typing a key expands the list to all 11 languages while preserving any selections already made; clearing the key shrinks it back to the 4 Marian-safe ones and silently drops anything no longer valid (verified: German stays selected, Italian gets dropped).
All 11 translation tabs are pre-built (hidden by default) so toggling the key never adds/removes components — only visibility changes.
Each selected translation now fails independently — if one language errors out (bad key, rate limit, model gap), its tab shows ⚠️ Translation failed: ... instead of aborting the whole "Generate Outputs" click and losing transcript + the translations that did succeed.

I tested all of this with mocked services: choice-switching in both directions, the key flowing through to the translation call untouched, and the per-language failure isolation — all confirmed working as intended.
One judgment call worth flagging: I kept a quiet fallback to a server-side ANTHROPIC_API_KEY environment variable if the UI field is left blank — useful if you're self-hosting this for yourself and don't want to paste a key every session, without affecting the "never saved" guarantee for anyone else's typed-in key. If you'd rather not have that fallback at all (stricter "no key anywhere unless the user typed it" semantics), I can remove it — it's a one-line change in TranslationService.translate().

Files changed (3) hide show
  1. app.py +84 -15
  2. services/__init__.py +4 -2
  3. services/translation.py +70 -42
app.py CHANGED
@@ -8,6 +8,18 @@ Implements the frozen v1.0 workflow:
8
  Services are instantiated lazily (on first use) rather than at import time,
9
  so the app can start up without needing model weights on disk yet, and so
10
  this module stays import-safe in environments without network access.
 
 
 
 
 
 
 
 
 
 
 
 
11
  """
12
 
13
  from __future__ import annotations
@@ -22,10 +34,20 @@ from models.transcript import Transcript
22
  from services.audio import AudioError, extract_window, resolve_window, validate_extension
23
  from services.subtitles import generate_srt, generate_vtt
24
  from services.transcription import SUPPORTED_LANGUAGES, TranscriptionService
25
- from services.translation import SUPPORTED_TARGET_LANGUAGES, TranslationService
 
 
 
 
 
26
 
27
  # ---------------------------------------------------------------------------
28
  # Lazy service singletons
 
 
 
 
 
29
  # ---------------------------------------------------------------------------
30
 
31
  _transcription_service: Optional[TranscriptionService] = None
@@ -59,15 +81,23 @@ def get_translation_service() -> TranslationService:
59
  _NAME_TO_CODE = {name: code for code, name in SUPPORTED_LANGUAGES.items()}
60
  SOURCE_LANGUAGE_CHOICES = ["Auto Detect"] + list(SUPPORTED_LANGUAGES.values())
61
 
62
- # "Outputs" checkboxes: display label -> ISO 639-1 code for translations.
 
 
 
 
 
 
 
 
63
  _TRANSLATION_LABEL_TO_CODE = {
64
- f"{name} Translation": code for code, name in SUPPORTED_TARGET_LANGUAGES.items()
65
  }
66
- OUTPUT_CHOICES = ["Transcript"] + list(_TRANSLATION_LABEL_TO_CODE.keys())
67
- DEFAULT_OUTPUTS = ["Transcript", "English Translation"]
68
 
69
- # Order in which translation tabs are laid out (fixed; visibility toggles).
70
- _TRANSLATION_TAB_ORDER = ["English Translation", "German Translation", "Persian Translation", "Spanish Translation"]
 
 
71
 
72
 
73
  def _format_duration(seconds: float) -> str:
@@ -83,6 +113,20 @@ def _write_text_file(text: str, tmp_dir: Path, filename: str) -> str:
83
  return str(path)
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  # ---------------------------------------------------------------------------
87
  # Main processing callback
88
  # ---------------------------------------------------------------------------
@@ -93,6 +137,7 @@ def process_audio(
93
  end_value: str,
94
  source_language_label: str,
95
  selected_outputs: list[str],
 
96
  ):
97
  if not audio_path:
98
  raise gr.Error("Please upload an audio file first.")
@@ -143,14 +188,21 @@ def process_audio(
143
  translation_service = get_translation_service()
144
  translation_updates = {} # label -> (text, file_path)
145
  for label in _TRANSLATION_TAB_ORDER:
146
- if label in selected_outputs:
147
- code = _TRANSLATION_LABEL_TO_CODE[label]
148
- translation = translation_service.translate(transcript, code)
 
 
 
 
149
  text = translation.text
150
  file_path = _write_text_file(text, tmp_dir, f"{code}.txt")
151
- translation_updates[label] = (text, file_path)
152
- else:
153
- translation_updates[label] = (None, None)
 
 
 
154
 
155
  # --- Subtitles (always derived from the transcript, the canonical
156
  # source of truth -- never regenerated from audio) ---
@@ -206,12 +258,29 @@ with gr.Blocks(title="EchoScript") as demo:
206
  label="Source Language",
207
  )
208
 
 
 
 
 
 
 
 
 
 
 
 
209
  outputs_input = gr.CheckboxGroup(
210
- choices=OUTPUT_CHOICES,
211
  value=DEFAULT_OUTPUTS,
212
  label="Outputs",
213
  )
214
 
 
 
 
 
 
 
215
  process_button = gr.Button("Generate Outputs", variant="primary")
216
 
217
  with gr.Column(scale=2):
@@ -263,7 +332,7 @@ with gr.Blocks(title="EchoScript") as demo:
263
 
264
  process_button.click(
265
  fn=process_audio,
266
- inputs=[audio_input, start_input, end_input, language_input, outputs_input],
267
  outputs=click_outputs,
268
  )
269
 
 
8
  Services are instantiated lazily (on first use) rather than at import time,
9
  so the app can start up without needing model weights on disk yet, and so
10
  this module stays import-safe in environments without network access.
11
+
12
+ Translation languages on offer depend on whether the person supplies their
13
+ own Anthropic API key for that session:
14
+
15
+ - No key: translation runs on the local, offline MarianMT backend, so only
16
+ the languages it can reliably reach are offered (services.translation.
17
+ MARIAN_TARGET_LANGUAGES).
18
+ - Key supplied: translation runs through Claude, which has no missing-
19
+ language-pair problem, so the full language list is offered (services.
20
+ translation.ANTHROPIC_TARGET_LANGUAGES). The key is used only for the
21
+ request(s) made during this session and is never written to disk,
22
+ logged, or cached anywhere server-side.
23
  """
24
 
25
  from __future__ import annotations
 
34
  from services.audio import AudioError, extract_window, resolve_window, validate_extension
35
  from services.subtitles import generate_srt, generate_vtt
36
  from services.transcription import SUPPORTED_LANGUAGES, TranscriptionService
37
+ from services.translation import (
38
+ ANTHROPIC_TARGET_LANGUAGES,
39
+ MARIAN_TARGET_LANGUAGES,
40
+ TranslationError,
41
+ TranslationService,
42
+ )
43
 
44
  # ---------------------------------------------------------------------------
45
  # Lazy service singletons
46
+ #
47
+ # Safe to share across requests/users: TranscriptionService holds no
48
+ # per-request state, and TranslationService resolves its backend (and
49
+ # takes the API key, if any) fresh on every translate() call rather than
50
+ # storing it -- see services/translation.py.
51
  # ---------------------------------------------------------------------------
52
 
53
  _transcription_service: Optional[TranscriptionService] = None
 
81
  _NAME_TO_CODE = {name: code for code, name in SUPPORTED_LANGUAGES.items()}
82
  SOURCE_LANGUAGE_CHOICES = ["Auto Detect"] + list(SUPPORTED_LANGUAGES.values())
83
 
84
+ # "Outputs" checkbox labels, for each of the two language sets. The
85
+ # Anthropic set is a superset of the Marian one, so switching a key in/out
86
+ # only ever adds or removes options -- it never renames existing ones.
87
+ _MARIAN_OUTPUT_CHOICES = ["Transcript"] + [f"{name} Translation" for name in MARIAN_TARGET_LANGUAGES.values()]
88
+ _ANTHROPIC_OUTPUT_CHOICES = ["Transcript"] + [f"{name} Translation" for name in ANTHROPIC_TARGET_LANGUAGES.values()]
89
+ DEFAULT_OUTPUTS = ["Transcript", "English Translation"]
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
+ # Tabs are pre-built for every language in the full superset (hidden by
98
+ # default) so that toggling the API key never needs to add/remove
99
+ # components -- only which ones are visible changes.
100
+ _TRANSLATION_TAB_ORDER = list(_TRANSLATION_LABEL_TO_CODE.keys())
101
 
102
 
103
  def _format_duration(seconds: float) -> str:
 
113
  return str(path)
114
 
115
 
116
+ def _update_output_choices(api_key: str, current_value: list[str]):
117
+ """Swap the Outputs checkbox list when the API key field changes.
118
+
119
+ With a key: offer the full Anthropic language list. Without: restrict
120
+ to what the offline MarianMT backend can reliably translate. Any
121
+ currently-selected language that's no longer offered is dropped from
122
+ the selection (this only happens when a key is removed).
123
+ """
124
+ has_key = bool((api_key or "").strip())
125
+ choices = _ANTHROPIC_OUTPUT_CHOICES if has_key else _MARIAN_OUTPUT_CHOICES
126
+ filtered_value = [v for v in (current_value or []) if v in choices] or ["Transcript"]
127
+ return gr.update(choices=choices, value=filtered_value)
128
+
129
+
130
  # ---------------------------------------------------------------------------
131
  # Main processing callback
132
  # ---------------------------------------------------------------------------
 
137
  end_value: str,
138
  source_language_label: str,
139
  selected_outputs: list[str],
140
+ api_key: str,
141
  ):
142
  if not audio_path:
143
  raise gr.Error("Please upload an audio file first.")
 
188
  translation_service = get_translation_service()
189
  translation_updates = {} # label -> (text, file_path)
190
  for label in _TRANSLATION_TAB_ORDER:
191
+ if label not in selected_outputs:
192
+ translation_updates[label] = (None, None)
193
+ continue
194
+
195
+ code = _TRANSLATION_LABEL_TO_CODE[label]
196
+ try:
197
+ translation = translation_service.translate(transcript, code, api_key=api_key)
198
  text = translation.text
199
  file_path = _write_text_file(text, tmp_dir, f"{code}.txt")
200
+ except TranslationError as exc:
201
+ # Surface the failure in that tab rather than aborting every
202
+ # other output that already succeeded.
203
+ text = f"\u26a0\ufe0f Translation failed: {exc}"
204
+ file_path = None
205
+ translation_updates[label] = (text, file_path)
206
 
207
  # --- Subtitles (always derived from the transcript, the canonical
208
  # source of truth -- never regenerated from audio) ---
 
258
  label="Source Language",
259
  )
260
 
261
+ api_key_input = gr.Textbox(
262
+ label="Anthropic API Key (optional)",
263
+ type="password",
264
+ placeholder="sk-ant-...",
265
+ info=(
266
+ "Provide your own key to translate into many more languages via Claude. "
267
+ "Without one, translation uses local offline models (English, German, "
268
+ "Persian, Spanish only). Used for this session only -- never stored."
269
+ ),
270
+ )
271
+
272
  outputs_input = gr.CheckboxGroup(
273
+ choices=_MARIAN_OUTPUT_CHOICES,
274
  value=DEFAULT_OUTPUTS,
275
  label="Outputs",
276
  )
277
 
278
+ api_key_input.change(
279
+ fn=_update_output_choices,
280
+ inputs=[api_key_input, outputs_input],
281
+ outputs=[outputs_input],
282
+ )
283
+
284
  process_button = gr.Button("Generate Outputs", variant="primary")
285
 
286
  with gr.Column(scale=2):
 
332
 
333
  process_button.click(
334
  fn=process_audio,
335
+ inputs=[audio_input, start_input, end_input, language_input, outputs_input, api_key_input],
336
  outputs=click_outputs,
337
  )
338
 
services/__init__.py CHANGED
@@ -2,7 +2,8 @@ from services.audio import AudioError, extract_window, resolve_window, validate_
2
  from services.subtitles import generate_srt, generate_vtt
3
  from services.transcription import SUPPORTED_LANGUAGES, TranscriptionService
4
  from services.translation import (
5
- SUPPORTED_TARGET_LANGUAGES,
 
6
  TranslationError,
7
  TranslationService,
8
  )
@@ -16,7 +17,8 @@ __all__ = [
16
  "generate_vtt",
17
  "SUPPORTED_LANGUAGES",
18
  "TranscriptionService",
19
- "SUPPORTED_TARGET_LANGUAGES",
 
20
  "TranslationError",
21
  "TranslationService",
22
  ]
 
2
  from services.subtitles import generate_srt, generate_vtt
3
  from services.transcription import SUPPORTED_LANGUAGES, TranscriptionService
4
  from services.translation import (
5
+ ANTHROPIC_TARGET_LANGUAGES,
6
+ MARIAN_TARGET_LANGUAGES,
7
  TranslationError,
8
  TranslationService,
9
  )
 
17
  "generate_vtt",
18
  "SUPPORTED_LANGUAGES",
19
  "TranscriptionService",
20
+ "ANTHROPIC_TARGET_LANGUAGES",
21
+ "MARIAN_TARGET_LANGUAGES",
22
  "TranslationError",
23
  "TranslationService",
24
  ]
services/translation.py CHANGED
@@ -13,22 +13,23 @@ 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
@@ -39,25 +40,43 @@ 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",
56
  "de": "German",
57
  "fa": "Persian",
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"
@@ -68,13 +87,6 @@ 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
  # ---------------------------------------------------------------------------
@@ -82,15 +94,20 @@ def _resolve_backend() -> str:
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)
@@ -99,7 +116,7 @@ def _translate_batch_via_anthropic(
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=(
@@ -114,6 +131,7 @@ def _translate_batch_via_anthropic(
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}"
@@ -140,7 +158,7 @@ def _translate_batch_via_anthropic(
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] = {}
@@ -148,7 +166,7 @@ def _translate_segments_via_anthropic(
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
 
@@ -175,6 +193,7 @@ def _try_load_marian_engine(source_language: str, target_language: str):
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,
@@ -251,15 +270,19 @@ def _translate_segments_via_marian(
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."""
264
  if target_language == transcript.language:
265
  # Already in the target language -- relabel, don't re-translate.
@@ -269,9 +292,11 @@ class TranslationService:
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(
@@ -288,10 +313,13 @@ class TranslationService:
288
  self,
289
  transcript: Transcript,
290
  target_languages: list[str],
 
291
  ) -> dict[str, Translation]:
292
  """Translate into several target languages at once.
293
 
294
  Returns a dict keyed by target-language code, in line with how the
295
  UI's multi-select "Outputs" checkboxes will want to fan out.
296
  """
297
- return {lang: self.translate(transcript, lang) for lang in target_languages}
 
 
 
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, chosen per-request based on
17
+ whether an Anthropic API key was supplied -- never stored:
18
 
19
+ - "anthropic": the caller supplies their own API key (e.g. typed into the
20
+ UI for that session). Used whenever a key is present. Sends transcript
21
+ text to Claude for translation. No missing-language-pair failure mode --
22
  every supported language translates directly to every other one in a
23
+ single call, with no local model downloads. The key is passed straight
24
+ through to the Anthropic client for that one call and is never written
25
+ to disk, logged, or cached in any module-level state.
26
  - "marian": fully offline, no API key needed. Uses local Helsinki-NLP
27
+ MarianMT models via `transformers`, restricted to the small set of
28
+ languages where direct or English-pivoted coverage is reliable.
29
+
30
+ The UI is expected to only offer the larger ANTHROPIC_TARGET_LANGUAGES
31
+ list once a key has been entered, and MARIAN_TARGET_LANGUAGES otherwise --
32
+ see app.py.
 
 
33
  """
34
 
35
  from __future__ import annotations
 
40
 
41
  from models.transcript import Segment, Transcript, Translation
42
 
43
+ # Display names for every language EchoScript knows about -- used both for
44
+ # the source-language dropdown (services/transcription.py) and to name
45
+ # languages in the Anthropic translation prompt. Falls back to the raw
46
+ # code for anything not listed (e.g. a language Whisper auto-detected
47
+ # that isn't in this table).
48
  LANGUAGE_NAMES: dict[str, str] = {
49
  "fr": "French",
50
  "en": "English",
51
  "de": "German",
52
  "fa": "Persian",
53
  "es": "Spanish",
54
+ "it": "Italian",
55
+ "pt": "Portuguese",
56
+ "nl": "Dutch",
57
+ "ar": "Arabic",
58
+ "ru": "Russian",
59
+ "tr": "Turkish",
60
  }
61
 
62
+ # Target languages offered in the "Outputs" checkboxes when NO Anthropic
63
+ # API key is supplied -- restricted to the set MarianMT can reliably
64
+ # reach (directly, or via an English pivot; see _resolve_marian_engines).
65
+ MARIAN_TARGET_LANGUAGES: dict[str, str] = {
66
  "en": "English",
67
  "de": "German",
68
  "fa": "Persian",
69
  "es": "Spanish",
70
  }
71
 
72
+ # Target languages offered once the person supplies their own Anthropic
73
+ # API key -- Claude has no missing-pair problem, so this list is just
74
+ # "every language EchoScript knows the name of".
75
+ ANTHROPIC_TARGET_LANGUAGES: dict[str, str] = dict(LANGUAGE_NAMES)
76
+
77
+ # Backwards-compatible alias (kept in case other modules import this name).
78
+ SUPPORTED_TARGET_LANGUAGES = MARIAN_TARGET_LANGUAGES
79
+
80
  # Anthropic model used for translation -- Haiku is fast and inexpensive,
81
  # which fits well for what is otherwise a mechanical translation task.
82
  _ANTHROPIC_MODEL = "claude-haiku-4-5-20251001"
 
87
  """Raised when no translation backend/model is available for a pair."""
88
 
89
 
 
 
 
 
 
 
 
90
  # ---------------------------------------------------------------------------
91
  # Anthropic backend
92
  # ---------------------------------------------------------------------------
 
94
  _NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[.\)]\s?(.*)$")
95
 
96
 
97
+ def _anthropic_client(api_key: str):
98
+ """Build a fresh Anthropic client for this one call.
99
+
100
+ Deliberately not cached: the key is supplied per-request by the
101
+ person using the app and must not linger in any module-level state
102
+ after the call that needed it returns.
103
+ """
104
  import anthropic # heavy import, deferred until needed
105
 
106
+ return anthropic.Anthropic(api_key=api_key)
107
 
108
 
109
  def _translate_batch_via_anthropic(
110
+ texts: list[str], source_language: str, target_language: str, api_key: str
111
  ) -> list[str]:
112
  """Translate a batch of lines in one Claude call, preserving order/count."""
113
  source_name = LANGUAGE_NAMES.get(source_language, source_language)
 
116
  numbered_input = "\n".join(f"{i + 1}. {text}" for i, text in enumerate(texts))
117
 
118
  try:
119
+ response = _anthropic_client(api_key).messages.create(
120
  model=_ANTHROPIC_MODEL,
121
  max_tokens=4096,
122
  system=(
 
131
  messages=[{"role": "user", "content": numbered_input}],
132
  )
133
  except Exception as exc: # pragma: no cover - depends on network/API key
134
+ # Deliberately do not include the key itself in this message.
135
  raise TranslationError(
136
  f"Anthropic translation request failed for "
137
  f"'{source_language}' -> '{target_language}': {exc}"
 
158
 
159
 
160
  def _translate_segments_via_anthropic(
161
+ segments: list[Segment], source_language: str, target_language: str, api_key: str
162
  ) -> list[Segment]:
163
  non_empty = [(i, seg) for i, seg in enumerate(segments) if seg.text]
164
  translated_text_by_index: dict[int, str] = {}
 
166
  for start in range(0, len(non_empty), _ANTHROPIC_BATCH_SIZE):
167
  chunk = non_empty[start : start + _ANTHROPIC_BATCH_SIZE]
168
  texts = [seg.text for _, seg in chunk]
169
+ translated = _translate_batch_via_anthropic(texts, source_language, target_language, api_key)
170
  for (i, _), text in zip(chunk, translated):
171
  translated_text_by_index[i] = text
172
 
 
193
  Returns (tokenizer, model), or None if no such model exists on the Hub
194
  (e.g. Helsinki-NLP doesn't publish every pair directly). Cached either
195
  way so a missing pair isn't re-checked over the network on every call.
196
+ Safe to cache: these are public model weights, not secrets.
197
 
198
  Loads the model/tokenizer directly via AutoModelForSeq2SeqLM rather
199
  than transformers.pipeline("translation", ...): as of transformers v5,
 
270
  class TranslationService:
271
  """Translates a Transcript into one or more target languages.
272
 
273
+ The backend is resolved per call from `api_key`, not stored on the
274
+ instance: pass an Anthropic API key to use Claude for that call, or
275
+ omit it to use the offline MarianMT backend. This means a single
276
+ long-lived TranslationService is safe to share across requests/users --
277
+ nothing about any particular key sticks to it.
278
  """
279
 
280
+ def translate(
281
+ self,
282
+ transcript: Transcript,
283
+ target_language: str,
284
+ api_key: str | None = None,
285
+ ) -> Translation:
286
  """Translate every segment of `transcript`, preserving timing."""
287
  if target_language == transcript.language:
288
  # Already in the target language -- relabel, don't re-translate.
 
292
  segments=list(transcript.segments),
293
  )
294
 
295
+ effective_key = (api_key or "").strip() or os.environ.get("ANTHROPIC_API_KEY")
296
+
297
+ if effective_key:
298
  translated_segments = _translate_segments_via_anthropic(
299
+ transcript.segments, transcript.language, target_language, effective_key
300
  )
301
  else:
302
  translated_segments = _translate_segments_via_marian(
 
313
  self,
314
  transcript: Transcript,
315
  target_languages: list[str],
316
+ api_key: str | None = None,
317
  ) -> dict[str, Translation]:
318
  """Translate into several target languages at once.
319
 
320
  Returns a dict keyed by target-language code, in line with how the
321
  UI's multi-select "Outputs" checkboxes will want to fan out.
322
  """
323
+ return {
324
+ lang: self.translate(transcript, lang, api_key=api_key) for lang in target_languages
325
+ }