Backend: - tiers.py: real license validation via Heimdall (self-hosted key or cloud user_id), 30-min cache, BYOK exception for translation - session_store.py: create_session() accepts tier/user_id/window_ms/transcribe_lang/ num_speakers; tier-aware _should_reap() (Free: reap on subscriber leave, Paid: activity TTL); auto-snapshot paid sessions with history on reap; voice-ready probe on session start - services/translator.py: DeepL-backed label translation, CF-managed and BYOK key paths, per-session label cache - models/session.py, scene_event.py, service_event.py: new event types and session fields - api/snapshots.py: GET /sessions/history + POST /session/resume (Paid tier only) - api/dev.py: dev-only /dev/voice/stop|start|restart proxy to cf-orch agent - migrations/002_session_snapshots.sql: session_snapshots table Frontend: - ComposeBar.vue: pre-session settings panel (window, language, speakers, Elcor toggle) with slide transition; passes options to startSession() - stores/settings.ts: persistent user preferences (localStorage), survives page reload - stores/session.ts: voiceReady/voiceError/voiceWarning states; SceneEvent/AccentEvent/ ServiceEvent types; TTL timers for environ/queue badges; startSession() accepts opts - composables/useToneStream.ts: handlers for scene-event, accent-event, service-event SSE - NowPanel.vue: diarization speaker identity badge, acoustic scene badge, privacy risk indicator - HistoryStrip.vue: per-speaker colour chips in history strip - components/DevPanel.vue: dev-only cf-voice stop/restart controls + thread view toggle - App.vue: DevPanel in footer (DEV only), threadView flag switches v0.1.x/v0.2.x preview Tests: - tests/test_pinning.py: 20 tests covering tier-aware reaping, snapshot creation, and translator behaviour - tests/test_tiers.py: 17 tests covering key validation, cloud resolution, caching, require_paid() gate, BYOK exception - All 72 tests passing
113 lines
3.7 KiB
Python
113 lines
3.7 KiB
Python
# app/services/translator.py — DeepL-backed tone label translation
|
|
#
|
|
# Translates short tone labels (e.g. "Neutral", "Stressed", "Apologetic")
|
|
# into the user's target language via the DeepL API.
|
|
#
|
|
# Two API key paths:
|
|
# CF-managed (Paid tier): DEEPL_API_KEY env var (DeepL Pro endpoint)
|
|
# BYOK (any tier): per-session byok_deepl_key from StartRequest (DeepL Free endpoint)
|
|
#
|
|
# Per-session label cache: each unique label is translated once per session.
|
|
# Cache is discarded when the session ends — it lives on the Translator instance,
|
|
# which is created per-session and GC'd with the session.
|
|
#
|
|
# Rate limiting: translation runs only on ToneEvent emit, not on raw audio frames.
|
|
# The SSE generator calls translate() once per event; the cache makes repeat labels free.
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
|
|
import requests
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DEEPL_FREE_URL = "https://api-free.deepl.com/v2/translate"
|
|
_DEEPL_PRO_URL = "https://api.deepl.com/v2/translate"
|
|
|
|
|
|
class Translator:
|
|
"""
|
|
Per-session DeepL translator with label cache.
|
|
|
|
Usage:
|
|
translator = Translator.for_session(session)
|
|
translated = translator.translate("Stressed") # "Stressé" for target_lang="fr"
|
|
translated = translator.translate("Stressed") # cache hit — no API call
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
target_lang: str,
|
|
api_key: str,
|
|
pro: bool = True,
|
|
) -> None:
|
|
self._target_lang = target_lang.upper() # DeepL uses uppercase codes
|
|
self._api_key = api_key
|
|
self._url = _DEEPL_PRO_URL if pro else _DEEPL_FREE_URL
|
|
self._cache: dict[str, str] = {}
|
|
|
|
@classmethod
|
|
def for_session(cls, session) -> "Translator | None":
|
|
"""
|
|
Create a Translator for a session, or return None if translation is disabled.
|
|
|
|
Returns None when:
|
|
- session.target_lang is empty
|
|
- No API key is available (neither BYOK nor CF-managed)
|
|
"""
|
|
target_lang = session.target_lang
|
|
if not target_lang:
|
|
return None
|
|
|
|
byok = session.byok_deepl_key.strip()
|
|
cf_key = settings.deepl_api_key.strip()
|
|
|
|
if byok:
|
|
return cls(target_lang=target_lang, api_key=byok, pro=False)
|
|
if cf_key:
|
|
return cls(target_lang=target_lang, api_key=cf_key, pro=True)
|
|
|
|
logger.warning(
|
|
"[translator] target_lang=%s set but no DeepL key available — skipping translation",
|
|
target_lang,
|
|
)
|
|
return None
|
|
|
|
def translate(self, label: str) -> str:
|
|
"""
|
|
Translate a tone label. Returns the original label on any error.
|
|
|
|
Results are cached per-session so the same label is only translated once.
|
|
"""
|
|
if not label:
|
|
return label
|
|
|
|
if label in self._cache:
|
|
return self._cache[label]
|
|
|
|
translated = self._call_deepl(label)
|
|
self._cache[label] = translated
|
|
return translated
|
|
|
|
def _call_deepl(self, text: str) -> str:
|
|
try:
|
|
resp = requests.post(
|
|
self._url,
|
|
headers={"Authorization": f"DeepL-Auth-Key {self._api_key}"},
|
|
json={
|
|
"text": [text],
|
|
"target_lang": self._target_lang,
|
|
},
|
|
timeout=5,
|
|
)
|
|
if not resp.ok:
|
|
logger.warning("[translator] DeepL returned %s for label %r", resp.status_code, text)
|
|
return text
|
|
data = resp.json()
|
|
return data["translations"][0]["text"]
|
|
except Exception as exc:
|
|
logger.warning("[translator] DeepL call failed for label %r: %s", text, exc)
|
|
return text
|