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:
Claude
2026-02-23 16:08:27 +00:00
parent f1d0ad045c
commit 14fde88c89
5 changed files with 203 additions and 105 deletions
-19
View File
@@ -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)
# ---------------------------------------------------------------------------
+103
View File
@@ -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>, &nbsp;).
- 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
-57
View File
@@ -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&nbsp;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"
+4 -2
View File
@@ -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()
+95 -26
View File
@@ -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,21 +182,69 @@ 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:
def test_notify_parent_calls_send(self, notifier, complete_registration, mocker):
@@ -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