233 lines
12 KiB
Python
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()
|