diff --git a/Manual.md b/Manual.md index 9c351ff..a61eab5 100644 --- a/Manual.md +++ b/Manual.md @@ -91,6 +91,12 @@ image check looks at gender, ears and wounds in the story moment, not at modern or text, so look at the lower pictures yourself. Sheets made before 2026-10-02 keep their old lower pictures until they are regenerated. +**Species:** the body shape (for example short dwarf proportions with a full beard) follows +only the species the character's own *Race / Species* field states first. A note in +brackets such as "Mensch (oder Zwerg, je nach Kontext …)" or a long remark about other +characters is ignored, so such a character is drawn as a human. If a character really is +a dwarf but the field says "Mensch (…)", change the field to "Zwerg" and regenerate. + **Privacy and offline:** only the prompts and the profile picture go to your own ComfyUI; nothing leaves your network. Without a reachable ComfyUI nothing is generated. diff --git a/routes/conversation.py b/routes/conversation.py index e0e96b9..2b06429 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -1834,6 +1834,32 @@ def _established_visual_value(sheet: dict, override_key: str, source_key: str, return value +_SPECIES_NOTE_MAX = 60 + + +def _character_species_text(sheet: dict, limit: int = 100) -> str: + """The character's own established species, without extraction reasoning. + + Extraction sometimes appends a long parenthetical that reasons about OTHER + characters ("Mensch (oder Ork-Verbündeter, ... 'Der Zwerg' wird separat + erwähnt ... Warte: 'Der Zwerg lächelte grimmig' ...)"). The sentence filter + in _established_visual_value kept parts of it, so another character's + species reached this character's Subject. A long or quoting parenthetical + is reduced to the leading species word; short notes such as + "Mensch (Thorwaler)" are unchanged. + """ + if _visual_prompt_value(sheet.get("visual_species")).strip(): + return _established_visual_value(sheet, "visual_species", "race_species", limit) + raw = _visual_prompt_value(sheet.get("race_species")).strip() + head, sep, rest = raw.partition("(") + if sep: + note = rest.split(")", 1)[0] if ")" in rest else rest + tail = rest.split(")", 1)[1].strip() if ")" in rest else "" + if len(note) > _SPECIES_NOTE_MAX or tail or re.search(r"['\"»«„“”]", note): + raw = head.strip() + return _established_visual_value({"race_species": raw}, "visual_species", "race_species", limit) + + def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) -> str: """Build the replaceable Subject block used by every character image.""" sheet = _character_form_sheet(sheet) @@ -1843,7 +1869,7 @@ def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) return value return value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…" - species = _established_visual_value(sheet, "visual_species", "race_species", 100) + species = _character_species_text(sheet, 100) appearance = _established_visual_value(sheet, "visual_appearance", "physical", 320, strip_injury=True) costume = _established_visual_value(sheet, "visual_costume", "clothing", 320, strip_injury=True) rows = [ @@ -1898,7 +1924,7 @@ def _character_ear_shape(sheet: dict) -> str: gets ordinary rounded human ears, since a missing/uncertain species is far more likely human than elf in this cast. """ - species_text = _established_visual_value(sheet, "visual_species", "race_species", 100) + species_text = _character_species_text(sheet, 100) if _ELF_SPECIES_RE.search(species_text) or _ORC_SPECIES_RE.search(species_text): return "pointed elf-shaped ears" return "ordinary rounded human ears" @@ -2103,11 +2129,23 @@ _AGE_BODY_LOCK = { _DWARF_SPECIES_RE = re.compile(r"zwerg|dwarf", re.IGNORECASE) +_SPECIES_ALTERNATIVE_RE = re.compile(r"\b(?:oder|or)\b|/", re.IGNORECASE) def _character_is_dwarf(sheet: dict) -> bool: - species = _visual_prompt_value(sheet.get("race_species")).strip() - return bool(_DWARF_SPECIES_RE.search(species)) + """True only when the sheet's OWN species assertion is a dwarf. + + Matching "Zwerg" anywhere in the raw field put the dwarf body lock (short + legs, thick full beard) on humans whose extraction note merely mentioned a + dwarf: "Mensch (oder Zwerg, je nach Kontext ...)" on the boy Marrad, and + "Mensch (... 'Der Zwerg' wird separat erwähnt ...)" on Kolon/Arthag in + book 02 — confirmed on the real library. Only the leading, unhedged + species clause counts. + """ + head = re.split(r"[(\[]", _character_species_text(sheet, 100), maxsplit=1)[0].strip() + if not head or _SPECIES_ALTERNATIVE_RE.search(head): + return False + return bool(_DWARF_SPECIES_RE.search(head)) # An established dwarf (race_species "Erzzwerg") still rendered with the tall, @@ -2482,7 +2520,7 @@ def _character_identity_phrase(name: str, sheet: dict) -> str: who = who.replace("adult woman", "girl").replace("adult man", "boy").replace("adult person", "child") elif category == "teen": who = who.replace("adult ", "teenage ") - species = _established_visual_value(sheet, "visual_species", "race_species", 60) + species = _character_species_text(sheet, 60) parts = [f"{who} named {name}" if name else who] if species: parts.append(species) diff --git a/tests/test_character_species_isolation.py b/tests/test_character_species_isolation.py new file mode 100644 index 0000000..2769df3 --- /dev/null +++ b/tests/test_character_species_isolation.py @@ -0,0 +1,99 @@ +"""A character's species comes only from its OWN species assertion. + +Regression for the dwarf-trait leak found on the real library (2026-10-03): +extraction notes in ``race_species`` that merely mention another character's +species ("Mensch (oder Zwerg, je nach Kontext ...)", "Mensch (... 'Der Zwerg' +wird separat erwähnt ...)") switched on the dwarf body lock (short legs, thick +full beard) for human characters, and the note's text about the other +character reached the Subject clause. +""" +from __future__ import annotations + +import os +import tempfile +import unittest + +os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-species-test-config-")) + +import routes.conversation as conv + +# Shapes copied from real records (book 02): a thin human boy and a human whose +# species note reasons about a separate dwarf. +HUMAN_BOY_HEDGED = { + "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.", +} +HUMAN_WITH_DWARF_NOTE = { + "race_species": ( + "Mensch (oder Ork-Verbündeter, Kontext unklar, aber 'Der Zwerg' wird separat erwähnt. " + "Warte: 'Der Zwerg lächelte grimmig ... Es wäre allein sein Verdienst ...'. " + "Der Text identifiziert den Zwerg als den, der den Erfolg für sich reklamiert.)" + ), + "gender": "male", + "physical": "Schlanker Mann mit kurzem Haar.", + "clothing": "Ein grauer Wollmantel.", +} +DWARF = { + "race_species": "Zwerg (Amboßzwerg)", + "gender": "male", + "physical": "Breit gebaut, mit langem rotem Bart.", + "clothing": "Ein Kettenhemd über einer Wolltunika.", +} +DWARF_TERMS = ("dwarf", "zwerg", "beard") + + +def _render(name: str, sheet: dict) -> list[str]: + sheet = conv._character_form_sheet(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 [*out[:5], *out[5]] + + +class SpeciesAssertionTests(unittest.TestCase): + def test_hedged_mention_of_dwarf_is_not_a_dwarf(self): + self.assertFalse(conv._character_is_dwarf(HUMAN_BOY_HEDGED)) + self.assertFalse(conv._character_is_dwarf(HUMAN_WITH_DWARF_NOTE)) + self.assertNotEqual(conv._age_body_lock(HUMAN_BOY_HEDGED), conv._DWARF_BODY_LOCK) + + def test_real_dwarf_still_gets_dwarf_lock(self): + self.assertTrue(conv._character_is_dwarf(DWARF)) + self.assertTrue(conv._character_is_dwarf({"race_species": "Erzzwerg"})) + self.assertTrue(conv._character_is_dwarf({"race_species": "Mensch", "visual_species": "Zwerg"})) + self.assertEqual(conv._age_body_lock(DWARF), conv._DWARF_BODY_LOCK) + + def test_alternatives_are_not_asserted(self): + self.assertFalse(conv._character_is_dwarf({"race_species": "Mensch oder Zwerg"})) + self.assertFalse(conv._character_is_dwarf({"race_species": "Mensch (oder Zwerg)"})) + + def test_species_reasoning_about_others_is_dropped(self): + self.assertEqual(conv._character_species_text(HUMAN_WITH_DWARF_NOTE), "Mensch") + self.assertEqual(conv._character_species_text(HUMAN_BOY_HEDGED), "Mensch") + # short, factual notes survive unchanged + self.assertEqual(conv._character_species_text({"race_species": "Mensch (Thorwaler)"}), "Mensch (Thorwaler)") + self.assertEqual(conv._character_species_text({"race_species": "Zwerg (Amboßzwerg)"}), "Zwerg (Amboßzwerg)") + facts = conv._character_visual_facts("Arthag", HUMAN_WITH_DWARF_NOTE) + self.assertNotIn("Zwerg", facts) + self.assertIn("species: Mensch", facts) + + +class ConsecutiveCharacterPromptTests(unittest.TestCase): + """A dwarf and a human built back to back: no dwarf trait in the human's prompts.""" + + def test_no_dwarf_traits_in_human_prompts(self): + for order in ((("Garbaz", DWARF), ("Marrad", HUMAN_BOY_HEDGED), ("Arthag", HUMAN_WITH_DWARF_NOTE)), + (("Marrad", HUMAN_BOY_HEDGED), ("Garbaz", DWARF), ("Arthag", HUMAN_WITH_DWARF_NOTE))): + prompts = {name: _render(name, sheet) for name, sheet in order} + for name in ("Marrad", "Arthag"): + text = " ".join(prompts[name]).lower() + for term in DWARF_TERMS: + self.assertNotIn(term, text, f"{term!r} leaked into {name}") + dwarf_text = " ".join(prompts["Garbaz"]).lower() + self.assertIn("adult dwarf", dwarf_text) + self.assertIn("beard", dwarf_text) + + +if __name__ == "__main__": + unittest.main()