tts-voice-creator-clone-and.../tests/test_prompt_translation.py
mARTin-B78 04f791e1f5 feat(characters): translate character info to English before image prompts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 12:01:42 +02:00

233 lines
12 KiB
Python

"""Character info is translated to English before image prompts are built.
All LLM calls are mocked; no image generation, no network.
"""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from unittest import mock
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-translation-test-config-"))
import routes.conversation as conv
from core import prompt_translation as pt
TRANSLATIONS = {
"Mensch (oder Zwerg, je nach Kontext 'Amboßzwerg', hier als junger Krieger beschrieben)":
"Human (or dwarf, depending on context 'Anvil dwarf', described here as a young warrior)",
"Klein, dünn, schwach; hat dünne Arme.": "Small, thin, weak; has thin arms.",
"Ein roter Umhang aus Wolle.": "A red cloak made of wool.",
"Zwerg (Amboßzwerg)": "Dwarf (anvil dwarf)",
"Breit gebaut, mit langem rotem Bart.": "Broadly built, with a long red beard.",
"Ein Kettenhemd über einer Wolltunika.": "A chainmail shirt over a wool tunic.",
"Kind, etwa 10 Jahre": "Child, about 10 years old",
"Er trägt einen Hut": "He wears a hat",
}
class FakeChat:
def __init__(self, table=None, raw=None, exc=None):
self.table, self.raw, self.exc, self.calls = table or TRANSLATIONS, raw, exc, []
def __call__(self, messages, max_tokens):
self.calls.append(messages)
if self.exc:
raise self.exc
if self.raw is not None:
return self.raw
batch = json.loads(messages[-1]["content"])
out = {}
for key, value in batch.items():
out[key] = [self.table.get(v, v) for v in value] if isinstance(value, list) else self.table.get(value, value)
return json.dumps(out, ensure_ascii=False)
def run(fields, chat, cache=None, names=None, enabled=True):
return pt.translate_fields(fields, chat=chat, model="m1", cache=cache if cache is not None else pt.MemoryCache(),
names=names, enabled=enabled)
class DetectionTests(unittest.TestCase):
def test_german_and_english(self):
self.assertTrue(pt.needs_translation("hat dünne Arme"))
self.assertTrue(pt.needs_translation("Mensch (oder Zwerg)"))
self.assertFalse(pt.needs_translation("A tall man with a long grey beard, wearing a wool cloak."))
self.assertFalse(pt.needs_translation("Human (or dwarf, depending on context)"))
self.assertFalse(pt.needs_translation(""))
self.assertFalse(pt.needs_translation("42"))
self.assertFalse(pt.needs_translation(None))
class TranslateFieldsTests(unittest.TestCase):
def test_batch_is_one_call_and_cache_hit_miss_invalidate(self):
cache, chat = pt.MemoryCache(), FakeChat()
fields = {"physical": "Klein, dünn, schwach; hat dünne Arme.", "clothing": "Ein roter Umhang aus Wolle.",
"x": "already English text of the thing"}
first = run(fields, chat, cache)
self.assertEqual(len(chat.calls), 1)
self.assertEqual(first.values["physical"], "Small, thin, weak; has thin arms.")
self.assertEqual(first.skipped, ["x"])
second = run(fields, chat, cache)
self.assertEqual(len(chat.calls), 1, "cache hit must not call the LLM")
self.assertEqual(second.cache_hits, 2)
changed = dict(fields, physical="Klein, dünn, schwach; hat dünne Arme.", clothing="Ein roter Umhang aus Wolle.")
chat.table = dict(TRANSLATIONS, **{"Ein grüner Umhang aus Wolle.": "A green cloak made of wool."})
changed["clothing"] = "Ein grüner Umhang aus Wolle."
third = run(changed, chat, cache)
self.assertEqual(len(chat.calls), 2, "changed source text must miss")
self.assertEqual(third.values["clothing"], "A green cloak made of wool.")
self.assertEqual(third.cache_hits, 1)
# a different translator model is a different key
run(fields, chat, cache) and pt.translate_fields(fields, chat=chat, model="other", cache=cache)
self.assertEqual(len(chat.calls), 3)
def test_list_fields(self):
chat = FakeChat(table={"Ein Schwert": "A sword", "Ein Dolch": "A dagger"})
res = run({"inventory": ["Ein Schwert", "Ein Dolch"]}, chat)
self.assertEqual(res.values["inventory"], ["A sword", "A dagger"])
def test_invalid_json_falls_back(self):
res = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, FakeChat(raw="Sorry, no JSON"))
self.assertEqual(res.values["physical"], "Klein, dünn, schwach; hat dünne Arme.")
self.assertTrue(res.used_untranslated)
self.assertTrue(res.error)
def test_validation_failures_fall_back_per_field(self):
raw = json.dumps({"physical": "", "clothing": "I'm sorry, I cannot translate this text."})
res = run({"physical": "Klein, dünn, schwach; hat dünne Arme.", "clothing": "Ein roter Umhang aus Wolle."},
FakeChat(raw=raw))
self.assertEqual(sorted(res.untranslated), ["clothing", "physical"])
self.assertEqual(res.values["clothing"], "Ein roter Umhang aus Wolle.")
def test_echo_and_length_rejected(self):
src = "Klein, dünn, schwach; hat dünne Arme."
self.assertIsNotNone(pt.validate_translation(src, src))
self.assertIsNotNone(pt.validate_translation(src, "x" * 500))
self.assertIsNone(pt.validate_translation(src, "Small, thin, weak; has thin arms."))
def test_names_preserved(self):
src = "Marrad trägt einen roten Umhang."
self.assertIsNone(pt.validate_translation(src, "Marrad wears a red cloak.", ["Marrad"]))
self.assertIn("name not preserved", pt.validate_translation(src, "Marat wears a red cloak.", ["Marrad"]))
chat = FakeChat(table={src: "Marat wears a red cloak."})
res = run({"physical": src}, chat, names=["Marrad"])
self.assertEqual(res.values["physical"], src)
def test_english_only_never_calls_llm(self):
chat = FakeChat()
res = run({"physical": "A thin boy with short hair."}, chat)
self.assertEqual(chat.calls, [])
self.assertFalse(res.used_untranslated)
def test_flag_off_and_llm_down(self):
chat = FakeChat()
off = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, chat, enabled=False)
self.assertEqual(chat.calls, [])
self.assertTrue(off.used_untranslated)
down = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, FakeChat(exc=ConnectionError("down")))
self.assertEqual(down.values["physical"], "Klein, dünn, schwach; hat dünne Arme.")
self.assertTrue(down.used_untranslated)
none = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, None)
self.assertTrue(none.used_untranslated)
def test_env_flag(self):
with mock.patch.dict(os.environ, {"PROMPT_TRANSLATION": "off"}):
self.assertFalse(pt.translation_enabled({}))
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("PROMPT_TRANSLATION", None)
self.assertTrue(pt.translation_enabled({}))
self.assertFalse(pt.translation_enabled({"prompt_translation": False}))
BOY = {
"race_species": "Mensch (oder Zwerg, je nach Kontext 'Amboßzwerg', hier als junger Krieger beschrieben)",
"gender": "male",
"physical": "Klein, dünn, schwach; hat dünne Arme.",
"clothing": "Ein roter Umhang aus Wolle.",
"age_estimate": "Kind, etwa 10 Jahre",
}
def prompts(name, sheet):
portrait = conv._build_character_portrait_prompt(name, "Book", sheet, {}, "cinematic")
turnaround = conv._build_character_sheet_prompt(name, "Book", sheet, {}, "cinematic")
out = conv._build_compact_character_render_prompts(name, "Book", sheet, {}, "cinematic", portrait, turnaround)
return " ".join([portrait, turnaround, *out[:5], *out[5]])
class ConversationWiringTests(unittest.TestCase):
def english(self, sheet, chat=None, **kw):
return conv._english_prompt_inputs("Marrad", sheet, {}, None, {"llm_url": ""}, chat=chat or FakeChat(),
cache=pt.MemoryCache(), **kw)
def test_hat_duenne_arme_regression(self):
sheet_en, _, _, info = self.english(BOY)
self.assertEqual(sheet_en["physical"], "Small, thin, weak; has thin arms.")
self.assertFalse(info["used_untranslated_text"])
self.assertNotIn("hood", prompts("Marrad", sheet_en))
# fallback path with the German text must not turn "hat" into a hood either
self.assertNotIn("hood", conv._period_wording("Klein, dünn, schwach; hat dünne Arme."))
self.assertNotIn("hood", conv._period_wording("hat dünne Arme"))
self.assertIn("hood", conv._period_wording("He wears a hat"))
def test_mixed_species_note_is_not_a_dwarf_with_and_without_translation(self):
self.assertFalse(conv._character_is_dwarf(BOY))
sheet_en, *_ = self.english(BOY)
self.assertEqual(sheet_en["race_species"].split("(")[0].strip(), "Human")
self.assertFalse(conv._character_is_dwarf(sheet_en))
self.assertNotIn("DWARF", prompts("Marrad", sheet_en).upper())
def test_real_dwarf_still_dwarf_both_ways(self):
dwarf = {"race_species": "Zwerg (Amboßzwerg)", "gender": "male", "physical": "Breit gebaut, mit langem rotem Bart.",
"clothing": "Ein Kettenhemd über einer Wolltunika."}
self.assertTrue(conv._character_is_dwarf(dwarf))
sheet_en, *_ = self.english(dwarf)
self.assertEqual(sheet_en["race_species"], "Dwarf (anvil dwarf)")
self.assertTrue(conv._character_is_dwarf(sheet_en))
def test_age_estimate_is_read(self):
self.assertEqual(conv._character_age_category({"age_estimate": "Kind, etwa 10 Jahre"}), "child")
self.assertEqual(conv._character_age_category({"age_estimate": "about 15 years old"}), "teen")
self.assertEqual(conv._character_age_category({"age_estimate": "Mitte 40"}), "adult")
self.assertEqual(conv._character_age_category({"age": "9 Jahre"}), "child")
sheet_en, *_ = self.english(BOY)
self.assertEqual(conv._age_body_lock(sheet_en)[0], "EXACTLY ONE CHILD.")
def test_record_not_modified(self):
original = json.loads(json.dumps(BOY))
self.english(BOY)
self.assertEqual(BOY, original)
def test_llm_down_falls_back_and_still_builds(self):
sheet_en, _, _, info = self.english(BOY, chat=FakeChat(exc=ConnectionError("down")))
self.assertEqual(sheet_en["physical"], BOY["physical"])
self.assertTrue(info["used_untranslated_text"])
self.assertFalse(conv._character_is_dwarf(sheet_en))
self.assertTrue(prompts("Marrad", sheet_en))
def test_flag_off(self):
chat = FakeChat()
with mock.patch.dict(os.environ, {"PROMPT_TRANSLATION": "0"}):
sheet_en, _, _, info = self.english(BOY, chat=chat)
self.assertEqual(chat.calls, [])
self.assertFalse(info["enabled"])
self.assertTrue(info["used_untranslated_text"])
self.assertEqual(sheet_en["physical"], BOY["physical"])
def test_scene_evidence_and_book_profile_translated(self):
table = {"Der Zwerg hob die Axt und rief laut.": "The dwarf raised the axe and shouted loudly.",
"Ein dunkler Wald im Norden": "A dark forest in the north"}
sheet = {"sources": [{"quote": "Der Zwerg hob die Axt und rief laut.", "line_hint": "action"}]}
sheet_en, book_en, _, _ = conv._english_prompt_inputs(
"Kolon", sheet, {"setting": "Ein dunkler Wald im Norden"}, None, {"llm_url": ""},
chat=FakeChat(table=table), cache=pt.MemoryCache())
self.assertEqual(sheet_en["visual_scene"], "The dwarf raised the axe and shouted loudly.")
self.assertEqual(book_en["setting"], "A dark forest in the north")
if __name__ == "__main__":
unittest.main()