From e19f2b524c80ed764d831e1763a83466c713a124 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 20 Sep 2026 05:34:31 +0200 Subject: [PATCH] fix(characters): period-safe, uninjured, more varied image prompts with taller adult proportions Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 + routes/conversation.py | 113 +++++++++++++++++++++++++++---- tests/test_character_identity.py | 37 ++++++++++ 3 files changed, 139 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c438b6..d482733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] +### Changed +- **Character images: period-safe, uninjured, less uniform.** Costume and appearance text sent to the image model is now rewritten to period garments (Uniform, Weste, Hut, Sporen, Jacke, Schal … became livery tabard, jerkin, felt cap, doublet …) because the extracted sheets contained such words for ~45 characters and the images came out in wartime/cowboy dress; injury clauses (blood, wounds, bandages, burns, limping, torn clothes) are removed clause by clause from appearance, costume and the story-moment text instead of dropping whole sentences; characters with armour or weapons get an individual garment cut and colour scheme (stable per name, colours named in the sheet win) instead of one shared list of gambeson/brigandine/plate. Saved prompts are cleaned the same way at render time. Adult figures are described as eight head-heights tall with a small head, to counter the short, stubby turnaround figures. The stored sheets are unchanged. + ### Fixed - **Character-sheet extraction returned nothing with a reasoning model**: the blocking `/api/character-sheets` call could spend its whole token budget thinking and answer with empty content. It now retries once with thinking switched off (and once more without the switch if the backend rejects it). diff --git a/routes/conversation.py b/routes/conversation.py index 3b7c23f..722643b 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -1694,14 +1694,67 @@ def _primary_visual_role(sheet: dict) -> str: _TRANSIENT_INJURY_RE = re.compile( - r"\b(blut\w*|wunden?|verletz\w*|geschwollen|entstellt|blaue\s+flecken|pr[eä]llung\w*|" + r"\b(blut\w*|wunden?|verwundet\w*|verletz\w*|geschwollen|entstellt|blaue\s+flecken|pr[eä]llung\w*|" r"schnittwunde\w*|messerschnitt\w*|riss(?:e|en)?|durchbohrt\w*|zerschunden|erschöpfung|" + r"verbrannt\w*|verbrennung\w*|gebrochen\w*|humpel\w*|hinkt\w*|verband\w*|verbunden\w*|" + r"schramm\w*|abschürfung\w*|striemen|zerfetzt\w*|zerrissen\w*|angeschlagen|" r"blood\w*|wound\w*|bruis\w*|injur\w*|bleeding|swollen|disfigured|gash\w*|laceration\w*|" + r"bandag\w*|limp\w*|torn|scrape\w*|burn(?:s|ed|t)|broken|" r"exhaust(?:ed|ion))\b", re.IGNORECASE, ) +_INJURY_CLAUSE_RE = re.compile( + r"(?:[,;]\s*|\s+(?:und|and|aber|but)\s+)?" + r"(?:(?!\s(?:und|and|aber|but)\s)[^,;.!?])*?" + + _TRANSIENT_INJURY_RE.pattern + + r"(?:(?!\s(?:und|and|aber|but)\s)[^,;.!?])*", + re.IGNORECASE, +) + + +def _strip_injury_clauses(text: str) -> str: + """Drop only the clauses that describe an acute injury; the rest of the text stays.""" + out = _INJURY_CLAUSE_RE.sub("", text) + out = re.sub(r"^\s*[,;]\s*", "", out) + out = re.sub(r"\s+([,;.])", r"\1", out) + out = re.sub(r"([:])\s*[,;]\s*", r"\1 ", out) + out = re.sub(r"\.{2,}", ".", out) + return re.sub(r"\s{2,}", " ", out).strip() + + +# Extraction copies the book's own words, and some of them read as modern or +# Wild-West/wartime wardrobe to an image model ("Uniform", "Weste", "Hut", +# "Sporen"). Rewrite them to period garments before they reach a prompt; +# the stored sheet is not changed. +_PERIOD_WORDING = ( + (r"offiziers[- ]?uniform\w*|officer'?s? uniform", "tailored officer's doublet and tabard in house colours"), + (r"(?:soldaten|milit[äa]r|wach|garde)[- ]?uniform\w*|soldier'?s? uniform", "quilted gambeson with a livery tabard"), + (r"uniformjacke\w*|uniform jacket", "quilted arming doublet"), + (r"uniform(?:en|e)?\b|uniformiert\w*", "livery tabard and gambeson"), + (r"soldatenkleidung|soldatische\w*", "period men-at-arms' dress"), + (r"soldatenstiefel", "stitched leather boots"), + (r"milit[äa]rische\w*|milit[äa]r\w*", "martial"), + (r"jacken?\b|jacket\w*", "doublet"), + (r"westen?\b|waistcoat\w*|vest\b", "sleeveless leather jerkin"), + (r"schals?\b|scarf|scarves", "wool neck-wrap"), + (r"h[üu]te?\b(?!\w)|\bhats?\b", "soft felt cap"), + (r"sporen|spurs?", "riding boots"), + (r"hosen?\b|trousers|pants", "hose"), + (r"orden\b|medals?", "guild badge"), + (r"kragen\b|collar\w*", "standing collar"), + (r"epauletten|schulterklappen|epaulet\w*", "shoulder pauldron"), +) +_PERIOD_WORDING_RES = tuple((re.compile(r"\b(?:" + pat + r")", re.IGNORECASE), repl) for pat, repl in _PERIOD_WORDING) + + +def _period_wording(text: str) -> str: + for pattern, repl in _PERIOD_WORDING_RES: + text = pattern.sub(repl, text) + return text + + def _established_visual_value(sheet: dict, override_key: str, source_key: str, limit: int = 320, strip_injury: bool = False) -> str: """Return visual facts that are established rather than speculative prose. @@ -1725,7 +1778,7 @@ def _established_visual_value(sheet: dict, override_key: str, source_key: str, """ override = _visual_prompt_value(sheet.get(override_key)).strip() if override: - return override[:limit] + return _period_wording(override)[:limit] raw = _visual_prompt_value(sheet.get(source_key)).strip() if not raw: return "" @@ -1741,9 +1794,11 @@ def _established_visual_value(sheet: dict, override_key: str, source_key: str, grounded = [ sentence.strip() for sentence in sentences if sentence.strip() and not uncertain.search(sentence) - and not (strip_injury and _TRANSIENT_INJURY_RE.search(sentence)) ] value = " ".join(grounded).strip() + if strip_injury: + value = _strip_injury_clauses(value) + value = _period_wording(value) if len(value) > limit: value = value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…" return value @@ -1760,7 +1815,7 @@ def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) species = _established_visual_value(sheet, "visual_species", "race_species", 100) appearance = _established_visual_value(sheet, "visual_appearance", "physical", 320, strip_injury=True) - costume = _established_visual_value(sheet, "visual_costume", "clothing", 320) + costume = _established_visual_value(sheet, "visual_costume", "clothing", 320, strip_injury=True) rows = [ ("name", name), ("gender", clipped("gender", 40)), @@ -1973,9 +2028,9 @@ _AGE_BODY_LOCK = { # convention gets a tall, slender adult look by also specifying leg # length as a fraction of total height and a slim waist, not just an # overall height ratio, so this states that explicitly. - "Natural adult human body proportions: an average adult stands about seven and a half head-heights " - "tall, with long legs making up slightly more than half of total height, a slender waist, and a " - "correctly proportioned torso and arms. ", + "Natural adult human body proportions: a fully grown adult stands about eight head-heights tall, " + "with a small head relative to the body, long legs making up slightly more than half of total height, " + "long straight limbs, a slender waist, and a correctly proportioned torso and arms. ", ), } @@ -2287,6 +2342,36 @@ def _positive_conditioning_text(value: str) -> str: return re.sub(r"\s{2,}", " ", value).strip(" ,;") +_GARMENT_CUTS = ( + "a quilted gambeson with a knee-length tabard", + "a riveted leather brigandine over a linen shirt", + "a mail hauberk under a hooded surcoat", + "a scale-lined leather cuirass with a short fur-trimmed cloak", + "a padded aketon with a heavy wool cloak clasped at one shoulder", + "a lamellar leather harness over a long-sleeved tunic", + "a short arming doublet with laced sleeves and a wide leather belt", + "a long belted surcoat over a mail coif and hose", +) +_GARMENT_COLOURS = ( + "faded indigo and undyed linen", "oxblood red and dark brown leather", "moss green and buff", + "charcoal grey and ochre", "rust orange and black", "slate blue and cream", + "forest green and tan", "plum and grey", "mustard yellow and brown", "teal and bone white", + "russet and olive", "black and dull silver", +) + + +def _character_design_variation(name: str, sheet: dict) -> str: + """A stable, per-character garment cut and palette, so that characters with the + same job do not all get the same armour. Colours the sheet already names win.""" + digest = hashlib.sha256(_fingerprint_text(name or "").encode("utf-8")).digest() + cut = _GARMENT_CUTS[digest[0] % len(_GARMENT_CUTS)] + established = _visual_prompt_value(sheet.get("visual_costume") or sheet.get("clothing")) + if re.search(r"\b(rot|blau|gr[üu]n|schwarz|wei[ßs]|grau|braun|gelb|golden|silbern|purpur|violett|orange|" + r"red|blue|green|black|white|grey|gray|brown|yellow|gold|silver|purple)\w*", established, re.IGNORECASE): + return f"{cut}, in the colours already described" + return f"{cut}, in {_GARMENT_COLOURS[digest[1] % len(_GARMENT_COLOURS)]}" + + def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, book_profile: dict, visual_style: str, saved_profile: str, saved_reference: str) -> tuple[str, str, str, str, str, list[str]]: @@ -2317,10 +2402,11 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, # structured fallback already uses _primary_visual_role when no edited # Subject is available. props = _character_prop_item_list(sheet) - scene_evidence = _positive_conditioning_text(_character_scene_evidence(sheet)) + scene_evidence = _positive_conditioning_text(_period_wording(_strip_injury_clauses(_character_scene_evidence(sheet)))) has_headwear = _character_has_established_headwear(sheet) - portrait_subject = _positive_conditioning_text(portrait_subject) - sheet_subject = _positive_conditioning_text(sheet_subject) + # Saved prompts were written before the wording/injury clean-up existed. + portrait_subject = _positive_conditioning_text(_period_wording(_strip_injury_clauses(portrait_subject))) + sheet_subject = _positive_conditioning_text(_period_wording(_strip_injury_clauses(sheet_subject))) ear_shape = _character_ear_shape(sheet) no_headwear = ( "Use only the explicitly established period headwear." @@ -2365,8 +2451,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, authority_lock = ( ( "Any military rank, command, or authority in this world is shown only through hand-forged period arms and " - "dress: quilted wool gambesons, riveted leather brigandine, forged plate or mail, sword belts, spears, " - "poleaxes, and crossbows, tailored by a period armorer or tailor. " + f"dress, cut individually for this character: {_character_design_variation(name, sheet)}. " ) if _character_has_established_combat_gear(sheet) else "" ) @@ -2383,7 +2468,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, # a civilian with no armor established, repeating the actual clothing # words here (not just pointing at them) gives that description a # second, closer mention instead of relying on the Subject clause alone. - established_costume = _established_visual_value(sheet, "visual_costume", "clothing", 320) + established_costume = _established_visual_value(sheet, "visual_costume", "clothing", 320, strip_injury=True) clothing_lock = ( f"CLOTHING LOCK: this character's complete outfit in every view is exactly {established_costume} — " "its own established materials, and nothing more elaborate. " @@ -2465,7 +2550,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, "LEFT THIRD: one complete full-body strict front view, shoulders hips knees and feet square to camera. " "CENTER THIRD: one complete full-body strict side view with the entire body rotated exactly 90 degrees toward image-left. " "RIGHT THIRD: one complete full-body strict rear view looking directly away, showing the back of head, back garment seams, backs of legs and heels. " - f"All three figures are the identical {identical_noun} with identical face, species, age, body proportions, hair, facial hair, costume construction, seams, palette, material wear, and footwear. {proportion_lock}Keep the exact anatomically established facial structure and species traits from Subject in every view. Use the same full-length 50mm camera distance in every panel. Align all three crowns and all three soles horizontally; each complete head-to-boots silhouette occupies the same height and remains centered in its equal-width third. Use a neutral stance, relaxed arms, visibly empty hands, unobstructed belts, and unobstructed backs. " + f"All three figures are the identical {identical_noun} with identical face, species, age, body proportions, hair, facial hair, costume construction, seams, palette, material wear, and footwear. {proportion_lock}Keep the exact anatomically established facial structure and species traits from Subject in every view. Use the same full-length 50mm camera distance in every panel. Each figure is a tall, upright, fully grown adult whose height is about four times the width of the body at the shoulders. Align all three crowns and all three soles horizontally; each complete head-to-boots silhouette occupies the same height and remains centered in its equal-width third. Use a neutral stance, relaxed arms, visibly empty hands, unobstructed belts, and unobstructed backs. " "Plain empty warm-white seamless studio background, neutral museum-soft light, soft contact shadows. Keep generous margin above every head and below every foot. The frame contains exactly the three required figures and the plain seamless background. Keep every surface unlettered and free of graphic overlays. Output 1536x1024." ) side_view = ( diff --git a/tests/test_character_identity.py b/tests/test_character_identity.py index 9938d10..6e2897c 100644 --- a/tests/test_character_identity.py +++ b/tests/test_character_identity.py @@ -169,5 +169,42 @@ class VisualPairReviewTests(unittest.TestCase): self.assertTrue(all("asset_reused_across_characters" in i["issues"] for i in issues)) +class PromptHygieneTests(unittest.TestCase): + """Facts that reach an image prompt must be period-safe, uninjured, and not identical between characters.""" + + def facts(self, **sheet): + from routes.conversation import _character_visual_facts + return _character_visual_facts("Test", sheet) + + def test_modern_wardrobe_words_become_period_garments(self): + text = self.facts(clothing="Offiziersuniform mit Weste, Hut und Sporen. Uniformjacke, Schal.") + for modern in ("uniform", "weste", "hut", "sporen", "jacke", "schal"): + self.assertNotIn(modern, text.lower(), modern) + self.assertIn("doublet", text) + + def test_injury_clauses_are_dropped_but_the_rest_of_the_sentence_stays(self): + text = self.facts(physical="Grüne Augen, blutverschmierte Stirn und schwarzes Haar. Sein Arm ist verbunden.") + self.assertIn("Grüne Augen", text) + self.assertIn("schwarzes Haar", text) + self.assertNotIn("blut", text.lower()) + self.assertNotIn("verbunden", text.lower()) + + def test_injured_clothing_is_cleaned_too(self): + text = self.facts(clothing="Graue Robe, an der Seite zerrissen und blutig.") + self.assertIn("Graue Robe", text) + self.assertNotIn("blutig", text.lower()) + + def test_soldiers_get_different_cuts_and_colours(self): + from routes.conversation import _character_design_variation + sheet = {"clothing": "Rüstung und Schwert"} + designs = {_character_design_variation(n, sheet) for n in ("Alrik", "Baron", "Coran", "Darrag", "Elda", "Fenn", "Garbag", "Himgi")} + self.assertGreaterEqual(len(designs), 5) + self.assertEqual(_character_design_variation("Alrik", sheet), _character_design_variation("Alrik", sheet)) + + def test_colours_the_sheet_already_names_are_kept(self): + from routes.conversation import _character_design_variation + self.assertIn("colours already described", _character_design_variation("Alrik", {"clothing": "rote Rüstung"})) + + if __name__ == "__main__": unittest.main()