fix(characters): base dwarf body lock and species text on the character's own species assertion
Extraction notes in race_species that mention another character's species
("Mensch (oder Zwerg, ...)", "... 'Der Zwerg' wird separat erwähnt ...")
switched on the dwarf body lock (short legs, full beard) for human characters
and carried a sentence about another character into the Subject clause.
Confirmed on the real library: Marrad, Kolon and Arthag (book 02).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e60126721d
commit
4fd21b1d1c
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
99
tests/test_character_species_isolation.py
Normal file
99
tests/test_character_species_isolation.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user