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()