tts-voice-creator-clone-and.../tests/test_prompt_translation.py
mARTin-B78 1f812d2faf fix(characters): merge profiles only on real names, keep other cast names, reasoning and foreign quotes out of a character
Real-library audit (270 records) traced the data damage to:
- identity matching on ANY shared token ("Zwerg", "der Zwerg", "Mann") with
  "longer text wins" merging (Arthag b02 = Kolon's species/relationships), and
- csRosterCanonicalName renaming a sheet to the first cast name among its
  aliases, which saved 16 whole-sheet copies (Zwerg/Kolon = Arthag b03,
  Fremder = Mann, Alchimist = Promos ...).

Fixes:
- static/js/character-identity.js + core/character_records.py: generic labels
  are never identity evidence; csFindMergeKey, csConsolidateAliasDuplicates,
  csRosterCanonicalName, clSameIdentity use it; own name always wins; clUpsert
  writes to the exact-name record before any alias scan; no seed rename onto a
  name that has its own record.
- _charsheets_parse: in cast-target mode drop other cast members' names from
  aliases / first / last name (Kolon: "Sharraz Garthai"), strip reasoning
  leakage ("Warte:", "Der Text sagt ...") from every text field.
- _character_scene_evidence: only a quote naming the character is used,
  otherwise its own mannerisms (16 panels showed another character's scene).
- _character_age_category: Junge/Mädchen/boy/girl, number words, Sommer/
  Winter ages, evidence clause, age-tagged quotes; "15 Jahre älter" is no age.
- scripts/audit_character_records.py (read-only) and
  scripts/clean_character_records.py (dry run by default, --apply backs up).

Tests: tests/test_character_record_hygiene.py, tests/character-identity.test.mjs.
Docs: architecture/character-extraction.md, Manual, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:09:53 +02:00

235 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):
# The quote must name the character: a quote that does not is never
# picked (see tests/test_character_record_hygiene.py).
table = {"Kolon hob die Axt und rief laut.": "Kolon raised the axe and shouted loudly.",
"Ein dunkler Wald im Norden": "A dark forest in the north"}
sheet = {"sources": [{"quote": "Kolon 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"], "Kolon raised the axe and shouted loudly.")
self.assertEqual(book_en["setting"], "A dark forest in the north")
if __name__ == "__main__":
unittest.main()