Spaces:
Sleeping
What changed:
Browse filesservices/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().
- app.py +84 -15
- services/__init__.py +4 -2
- services/translation.py +70 -42
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
_TRANSLATION_LABEL_TO_CODE = {
|
| 64 |
-
f"{name} Translation": code for code, name in
|
| 65 |
}
|
| 66 |
-
OUTPUT_CHOICES = ["Transcript"] + list(_TRANSLATION_LABEL_TO_CODE.keys())
|
| 67 |
-
DEFAULT_OUTPUTS = ["Transcript", "English Translation"]
|
| 68 |
|
| 69 |
-
#
|
| 70 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
text = translation.text
|
| 150 |
file_path = _write_text_file(text, tmp_dir, f"{code}.txt")
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
|
|
@@ -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 |
-
|
|
|
|
| 6 |
TranslationError,
|
| 7 |
TranslationService,
|
| 8 |
)
|
|
@@ -16,7 +17,8 @@ __all__ = [
|
|
| 16 |
"generate_vtt",
|
| 17 |
"SUPPORTED_LANGUAGES",
|
| 18 |
"TranscriptionService",
|
| 19 |
-
"
|
|
|
|
| 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 |
]
|
|
@@ -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":
|
| 19 |
-
|
| 20 |
-
|
| 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`
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 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
|
| 43 |
-
#
|
| 44 |
-
#
|
|
|
|
|
|
|
| 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
|
| 54 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
| 257 |
"""
|
| 258 |
|
| 259 |
-
def
|
| 260 |
-
self
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|