diff --git a/src/notifications/context.py b/src/notifications/context.py index 7a2feaa..aba0cec 100644 --- a/src/notifications/context.py +++ b/src/notifications/context.py @@ -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) # --------------------------------------------------------------------------- diff --git a/src/notifications/i18n.py b/src/notifications/i18n.py new file mode 100644 index 0000000..e900795 --- /dev/null +++ b/src/notifications/i18n.py @@ -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. ,  ). +- 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 diff --git a/src/notifications/i18n/en.yaml b/src/notifications/i18n/en.yaml deleted file mode 100644 index 0f4dbd9..0000000 --- a/src/notifications/i18n/en.yaml +++ /dev/null @@ -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 CHF 80.00 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" diff --git a/src/notifications/notifier.py b/src/notifications/notifier.py index 957c330..6a89c1c 100644 --- a/src/notifications/notifier.py +++ b/src/notifications/notifier.py @@ -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() diff --git a/tests/test_notifier.py b/tests/test_notifier.py index 03c598e..53960a8 100644 --- a/tests/test_notifier.py +++ b/tests/test_notifier.py @@ -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