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:
+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