replace static translation files with LLM-based i18n
Instead of maintaining a YAML file per language, German (de.yaml) is the single source of truth. For any other language, the label strings are translated on demand via an LLM call and cached in memory — no static files to maintain, any language the parent writes in is served automatically. - Add src/notifications/i18n.py: get_strings(), _translate() via litellm.completion, in-memory cache, clear_cache() for tests - Passthrough keys (reg_fee_amount, deposit_amount) are never sent to the LLM so currency amounts are guaranteed to be unchanged - Falls back to German silently if the LLM call fails - Remove src/notifications/i18n/en.yaml (no longer needed) - Remove load_strings() from context.py (moved to i18n.py) - Add model parameter to AdminNotifier (defaults to claude-haiku) - Add TestGetStrings suite covering: no LLM for German, LLM called for others, caching, fallback, passthrough key preservation - Add autouse reset_translation_cache fixture to isolate tests https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
"""Pure functions for building email context dicts from registration data."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ..models.registration import RegistrationData
|
||||
|
||||
@@ -17,22 +14,6 @@ QR_STREET = "Huebwisstrase 5"
|
||||
QR_PCODE = "8117"
|
||||
QR_CITY = "Fällanden"
|
||||
|
||||
_I18N_DIR = Path(__file__).parent / "i18n"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# i18n
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_strings(language: str) -> dict:
|
||||
"""Load the label/string table for *language* (falls back to German)."""
|
||||
locale_file = _I18N_DIR / f"{language}.yaml"
|
||||
if not locale_file.exists():
|
||||
locale_file = _I18N_DIR / "de.yaml"
|
||||
with locale_file.open(encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting helpers (pure functions, no side-effects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""i18n support for parent confirmation emails.
|
||||
|
||||
German is the canonical source language (de.yaml). For any other language
|
||||
the German labels are translated on-demand via an LLM call and cached
|
||||
in-process for the lifetime of the server — no static translation files to
|
||||
maintain, any language the parent writes in is supported automatically.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import litellm
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_I18N_DIR = Path(__file__).parent / "i18n"
|
||||
|
||||
# In-memory translation cache keyed by language code.
|
||||
_cache: dict[str, dict] = {}
|
||||
|
||||
# Pure data values that must never be sent to the LLM for translation.
|
||||
_PASSTHROUGH_KEYS = {"reg_fee_amount", "deposit_amount"}
|
||||
|
||||
_SYSTEM_PROMPT = """\
|
||||
You are a translation assistant for a Swiss playgroup registration system.
|
||||
Translate the following JSON label strings from German into {language}.
|
||||
|
||||
Rules:
|
||||
- Return ONLY a valid JSON object with the exact same keys and structure.
|
||||
- Preserve all {{placeholder}} variables exactly as-is (e.g. {{name}}).
|
||||
- Preserve all HTML tags and entities exactly (e.g. <strong>, ).
|
||||
- Keep proper nouns untranslated: "Spielgruppe Pumuckl", "Familienverein Fällanden".
|
||||
- Do not include any explanation or text outside the JSON."""
|
||||
|
||||
|
||||
def get_strings(language: str, model: str) -> dict:
|
||||
"""Return the label string table for *language*.
|
||||
|
||||
For German, loads directly from de.yaml (no LLM call).
|
||||
For all other languages, translates the German labels via LLM and caches
|
||||
the result in memory. Falls back to German if the LLM call fails.
|
||||
"""
|
||||
if language == "de":
|
||||
return _load_german()
|
||||
|
||||
if language in _cache:
|
||||
return _cache[language]
|
||||
|
||||
german = _load_german()
|
||||
translated = _translate(german, language, model)
|
||||
_cache[language] = translated
|
||||
return translated
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Evict all cached translations (intended for use in tests)."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def _load_german() -> dict:
|
||||
with (_I18N_DIR / "de.yaml").open(encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh)
|
||||
|
||||
|
||||
def _translate(german: dict, language: str, model: str) -> dict:
|
||||
"""Translate the German label dict into *language* via LLM.
|
||||
|
||||
Returns the German dict unchanged if the LLM call fails or returns
|
||||
malformed JSON.
|
||||
"""
|
||||
passthrough = {k: german[k] for k in _PASSTHROUGH_KEYS if k in german}
|
||||
to_translate = {k: v for k, v in german.items() if k not in _PASSTHROUGH_KEYS}
|
||||
|
||||
system = _SYSTEM_PROMPT.format(language=language)
|
||||
payload = json.dumps(to_translate, ensure_ascii=False, indent=2)
|
||||
|
||||
try:
|
||||
response = litellm.completion(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": payload},
|
||||
],
|
||||
max_tokens=2048,
|
||||
)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
|
||||
# Strip markdown code fences that some models add
|
||||
if raw.startswith("```"):
|
||||
raw = raw[raw.index("\n") + 1 :]
|
||||
raw = raw[: raw.rfind("```")]
|
||||
|
||||
translated: dict = json.loads(raw)
|
||||
translated.update(passthrough)
|
||||
return translated
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to translate email labels into %s — falling back to German", language
|
||||
)
|
||||
return german
|
||||
@@ -1,57 +0,0 @@
|
||||
subject: "Registration Confirmation – Spielgruppe Pumuckl"
|
||||
greeting: "Dear {name}"
|
||||
intro: >-
|
||||
Your registration with Spielgruppe Pumuckl has been received.
|
||||
Here is a summary:
|
||||
|
||||
child_section: "Child Details"
|
||||
child_name: "Name"
|
||||
child_dob: "Date of birth"
|
||||
child_needs: "Special needs"
|
||||
|
||||
booking_section: "Playgroup Booking"
|
||||
booking_type: "Type"
|
||||
booking_days: "Days"
|
||||
|
||||
fees_section: "Fees"
|
||||
monthly_fee: "Monthly subscription"
|
||||
reg_fee: "Registration fee (one-time, first year)"
|
||||
reg_fee_amount: "CHF 80.00"
|
||||
deposit: "Cleaning deposit – indoor (refundable)"
|
||||
deposit_amount: "CHF 50.00"
|
||||
|
||||
parent_section: "Your Contact Details"
|
||||
parent_name: "Name"
|
||||
parent_address: "Address"
|
||||
parent_phone: "Phone"
|
||||
parent_email: "Email"
|
||||
|
||||
emergency_section: "Emergency Contact"
|
||||
emergency_name: "Name"
|
||||
emergency_phone: "Phone"
|
||||
|
||||
payment_section: "Payment Details"
|
||||
payment_intro: >-
|
||||
Please transfer the registration fee of <strong>CHF 80.00</strong> to the account below.
|
||||
You can scan the QR code with your banking app:
|
||||
payment_intro_text: "Please transfer the registration fee of CHF 80.00 to the following account:"
|
||||
iban_label: "IBAN"
|
||||
payee_label: "Payee"
|
||||
amount_label: "Amount"
|
||||
|
||||
closing: |
|
||||
If you have any questions, we are happy to help. We look forward to welcoming your child!
|
||||
|
||||
Kind regards
|
||||
Spielgruppe Pumuckl
|
||||
|
||||
none: "None"
|
||||
|
||||
days:
|
||||
monday: "Monday"
|
||||
wednesday: "Wednesday"
|
||||
thursday: "Thursday"
|
||||
|
||||
types:
|
||||
indoor: "Indoor Playgroup"
|
||||
outdoor: "Forest Playgroup"
|
||||
@@ -23,8 +23,8 @@ from .context import (
|
||||
build_admin_update_context,
|
||||
build_parent_context,
|
||||
format_types,
|
||||
load_strings,
|
||||
)
|
||||
from .i18n import get_strings
|
||||
from .renderer import render_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,6 +51,7 @@ class AdminNotifier:
|
||||
indoor_email: str = "",
|
||||
outdoor_email: str = "",
|
||||
cc_emails: list[str] | None = None,
|
||||
model: str = "anthropic/claude-haiku-4-5-20251001",
|
||||
) -> None:
|
||||
self._smtp_host = smtp_host
|
||||
self._smtp_port = smtp_port
|
||||
@@ -61,6 +62,7 @@ class AdminNotifier:
|
||||
self._indoor_email = indoor_email
|
||||
self._outdoor_email = outdoor_email
|
||||
self._cc_emails: list[str] = cc_emails or []
|
||||
self._model = model
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -140,7 +142,7 @@ class AdminNotifier:
|
||||
logger.warning("No parent email in registration — confirmation not sent.")
|
||||
return
|
||||
|
||||
strings = load_strings(language)
|
||||
strings = get_strings(language, self._model)
|
||||
|
||||
try:
|
||||
qr_png = self._generate_qr_bill_png()
|
||||
|
||||
+96
-27
@@ -1,6 +1,7 @@
|
||||
"""Tests for AdminNotifier and notification helper functions."""
|
||||
|
||||
import email
|
||||
import json
|
||||
from email.header import decode_header
|
||||
|
||||
import pytest
|
||||
@@ -10,8 +11,9 @@ from src.notifications.context import (
|
||||
calculate_age,
|
||||
calculate_monthly_fee,
|
||||
format_types,
|
||||
load_strings,
|
||||
build_parent_context,
|
||||
)
|
||||
from src.notifications.i18n import get_strings, clear_cache
|
||||
from src.notifications.renderer import render_template
|
||||
from src.models.registration import RegistrationData, Booking, BookingDay
|
||||
|
||||
@@ -27,6 +29,14 @@ def _decoded_subject(msg_str: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_translation_cache():
|
||||
"""Clear the in-memory translation cache before every test."""
|
||||
clear_cache()
|
||||
yield
|
||||
clear_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier():
|
||||
return AdminNotifier(
|
||||
@@ -42,6 +52,18 @@ def notifier():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier_no_smtp():
|
||||
"""Notifier in dev mode (no SMTP host)."""
|
||||
return AdminNotifier(
|
||||
smtp_host="",
|
||||
smtp_port=587,
|
||||
username="",
|
||||
password="",
|
||||
from_email="agent@example.com",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_types
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -160,20 +182,68 @@ class TestSend:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notify_parent — parent confirmation email
|
||||
# get_strings — i18n / LLM translation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier_no_smtp():
|
||||
"""Notifier in dev mode (no SMTP host)."""
|
||||
return AdminNotifier(
|
||||
smtp_host="",
|
||||
smtp_port=587,
|
||||
username="",
|
||||
password="",
|
||||
from_email="agent@example.com",
|
||||
)
|
||||
class TestGetStrings:
|
||||
def test_german_loads_from_yaml_without_llm(self, mocker):
|
||||
"""German must never trigger an LLM call."""
|
||||
mock_litellm = mocker.patch("litellm.completion")
|
||||
strings = get_strings("de", "some-model")
|
||||
mock_litellm.assert_not_called()
|
||||
assert strings["subject"] == "Anmeldebestätigung – Spielgruppe Pumuckl"
|
||||
|
||||
def test_other_language_calls_llm(self, mocker):
|
||||
"""Non-German languages should call litellm.completion."""
|
||||
german = get_strings("de", "some-model")
|
||||
translated = {**german, "subject": "Registration Confirmation – Spielgruppe Pumuckl"}
|
||||
mock_litellm = mocker.patch("litellm.completion")
|
||||
mock_litellm.return_value.choices[0].message.content = json.dumps(translated)
|
||||
|
||||
result = get_strings("en", "some-model")
|
||||
|
||||
mock_litellm.assert_called_once()
|
||||
assert result["subject"] == "Registration Confirmation – Spielgruppe Pumuckl"
|
||||
|
||||
def test_result_is_cached(self, mocker):
|
||||
"""The LLM is only called once per language per process lifetime."""
|
||||
german = get_strings("de", "some-model")
|
||||
mock_litellm = mocker.patch("litellm.completion")
|
||||
mock_litellm.return_value.choices[0].message.content = json.dumps(german)
|
||||
|
||||
get_strings("fr", "some-model")
|
||||
get_strings("fr", "some-model")
|
||||
|
||||
assert mock_litellm.call_count == 1
|
||||
|
||||
def test_llm_failure_falls_back_to_german(self, mocker):
|
||||
"""If the LLM raises, the German strings are returned silently."""
|
||||
mocker.patch("litellm.completion", side_effect=RuntimeError("network error"))
|
||||
|
||||
result = get_strings("it", "some-model")
|
||||
|
||||
assert result["subject"] == "Anmeldebestätigung – Spielgruppe Pumuckl"
|
||||
|
||||
def test_passthrough_keys_not_altered(self, mocker):
|
||||
"""reg_fee_amount and deposit_amount must survive translation unchanged."""
|
||||
german = get_strings("de", "some-model")
|
||||
# Return translation that omits passthrough keys (as the LLM would)
|
||||
without_passthrough = {k: v for k, v in german.items()
|
||||
if k not in {"reg_fee_amount", "deposit_amount"}}
|
||||
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
|
||||
json.dumps(without_passthrough)
|
||||
)
|
||||
|
||||
result = get_strings("en", "some-model")
|
||||
|
||||
assert result["reg_fee_amount"] == "CHF 80.00"
|
||||
assert result["deposit_amount"] == "CHF 50.00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notify_parent — parent confirmation email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNotifyParent:
|
||||
@@ -190,7 +260,7 @@ class TestNotifyParent:
|
||||
assert "anna.muster@example.com" in recipients
|
||||
|
||||
def test_notify_parent_german_subject(self, notifier, complete_registration, mocker):
|
||||
"""German language produces a German subject line."""
|
||||
"""German language produces a German subject line without any LLM call."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
@@ -204,7 +274,13 @@ class TestNotifyParent:
|
||||
assert "Anmeldebestätigung" in _decoded_subject(captured["msg"])
|
||||
|
||||
def test_notify_parent_english_subject(self, notifier, complete_registration, mocker):
|
||||
"""English language produces an English subject line."""
|
||||
"""English language produces an English subject line via LLM translation."""
|
||||
german = get_strings("de", "some-model")
|
||||
english = {**german, "subject": "Registration Confirmation – Spielgruppe Pumuckl"}
|
||||
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
|
||||
json.dumps(english)
|
||||
)
|
||||
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
@@ -220,7 +296,9 @@ class TestNotifyParent:
|
||||
def test_notify_parent_unknown_language_falls_back_to_de(
|
||||
self, notifier, complete_registration, mocker
|
||||
):
|
||||
"""Unsupported language codes fall back to German."""
|
||||
"""When the LLM call fails, the email is sent in German."""
|
||||
mocker.patch("litellm.completion", side_effect=RuntimeError("timeout"))
|
||||
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
@@ -243,18 +321,9 @@ class TestNotifyParent:
|
||||
|
||||
mock_smtp_cls.assert_not_called()
|
||||
|
||||
def test_notify_parent_text_body_contains_iban(self, complete_registration):
|
||||
"""Plain-text body includes the IBAN so payment is possible without the QR image."""
|
||||
strings = load_strings("de")
|
||||
from src.notifications.context import build_parent_context
|
||||
ctx = build_parent_context(complete_registration, strings, has_qr=False)
|
||||
text = render_template("parent_confirmation.txt.j2", ctx)
|
||||
assert "CH14" in text
|
||||
|
||||
def test_notify_parent_text_body_english_contains_iban(self, complete_registration):
|
||||
"""English plain-text body also includes the IBAN."""
|
||||
strings = load_strings("en")
|
||||
from src.notifications.context import build_parent_context
|
||||
def test_text_body_contains_iban(self, complete_registration):
|
||||
"""Rendered plain-text body includes the IBAN regardless of language."""
|
||||
strings = get_strings("de", "some-model")
|
||||
ctx = build_parent_context(complete_registration, strings, has_qr=False)
|
||||
text = render_template("parent_confirmation.txt.j2", ctx)
|
||||
assert "CH14" in text
|
||||
|
||||
Reference in New Issue
Block a user