Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

1130 lines
47 KiB
Python

"""Section renderers for the natlang v2 pipeline (Phase D).
Each renderer produces ONE section's prose from PromptState. The pipeline
runs them after Phase C's apply_deltas has produced the final cumulative
state, so the renderers see a clean structured fact set with no need to
reconcile prior prose.
Renderers:
- render_character_section(state) — server, deterministic
- render_outfit_section(state) — server, slot-by-slot compose
- render_pose_section(state, *, model) — model call (async)
- render_expression_section(state) — server, just state.expression
- render_setting_section(state) — server, just state.setting
A facts-hash cache keeps byte-identity for unchanged sections across
turns (the LLM is stochastic; re-rendering with identical facts would
return slightly different prose otherwise). Cache is process-local.
"""
from __future__ import annotations
import hashlib
import re
from typing import Any, Awaitable, Callable, Optional
_NL = chr(10)
from .prompt_state import (
PromptState, OutfitState, PoseState, CharacterState, SlotState,
SLOT_NAMES, SLOT_STATE_FILLED, SLOT_STATE_CLEARED,
ORIGIN_BIO, ORIGIN_USER,
)
# ── caches ──────────────────────────────────────────────────────────
# {facts_hash: rendered_prose} — section-level prose cache. Keeps byte-
# identical output when the same facts hit the same renderer across turns.
_PROSE_CACHE: dict[str, str] = {}
def _facts_hash(*parts: Any) -> str:
h = hashlib.sha1()
for p in parts:
h.update(str(p).encode("utf-8"))
h.update(b"\x00")
return h.hexdigest()
def clear_cache() -> None:
"""Test hook — drop the prose cache."""
_PROSE_CACHE.clear()
# ── character section (server, deterministic) ─────────────────────
def render_character_section(state: PromptState,
char: Optional[CharacterState] = None) -> str:
"""Emit `// Character: <Name> (<Series>)` followed by bio.base_natlang
verbatim. The metadata in the header lets callers (style-template
matching, downstream search) see who the section is about without
parsing the prose.
`char` defaults to the primary; pass an explicit CharacterState to
render any character in `state.characters` (multi-character mode)."""
if char is None:
char = state.primary_character()
if char is None or not char.base_natlang.strip():
return ""
body = _strip_leading_section_header(char.base_natlang.strip())
header_parts = ["// Character:"]
name = (char.display or char.tag or "").strip()
if name:
header_parts.append(name)
if char.series.strip():
header_parts[-1] += f" ({char.series.strip()})"
header = " ".join(header_parts)
return f"{header}\n{body}"
# ── outfit section (server, slot-by-slot) ─────────────────────────
_FRUIT_OUTFIT_SYSTEM_PROMPT = (
"You rewrite a single `// Outfit:` section to reflect updated "
"structured facts. Preserve the user's exact phrasing (INCLUDING "
"typos and capitalization) for items still present; change only "
"what the facts say has changed.\n"
"\n"
"STRUCTURED FACTS ARE EXHAUSTIVE — anything not listed is GONE.\n"
"\n"
"Rules:\n"
"- Output ONLY the rewritten outfit prose body — no header, no "
"preamble, no quotes.\n"
"- Items in the facts AND in existing prose: keep the existing "
"prose's phrasing VERBATIM — every qualifier, fabric, cut, fit, "
"color, descriptor, even typos. If existing says `light blue "
"sleeveless thong leotard with stiff tight mock-neck`, output "
"EXACTLY that, not `blue leotard`. The facts are a structural "
"check (what's still present); the existing prose is the "
"authoritative phrasing. NEVER compress to a bare item word.\n"
"- Items in existing prose NOT in the facts: REMOVE them entirely.\n"
"- Items in the facts NOT in existing prose: ADD them by "
"EXTENDING the existing comma-list, not by appending standalone "
"sentences.\n"
" * If existing reads `Wearing X, Y, Z.` and you must add W, "
"the output is `Wearing X, Y, Z, and W.` — comma before the new "
"item, conjunction `and` only before the final item.\n"
" * If you must add TWO new items W and V, the output is "
"`Wearing X, Y, Z, W, and V.` — both go inside the same list.\n"
" * NEVER write `Wearing X, Y, Z. W. V.` — orphan period-"
"sentences are wrong; new items belong INSIDE the existing "
"comma-list with the qualifiers (color, fabric, descriptors) "
"from the facts attached to each item.\n"
" * Preserve qualifiers from the facts verbatim. Fact item "
"`black combat boots` → output `black combat boots` (NOT just "
"`boots`).\n"
"- The `modifier:` lines in the facts are the COMPLETE active list. "
"ANY modifier sentence in existing prose (e.g. `Barefoot.`, "
"`Topless.`) that is NOT in the facts modifier list MUST be "
"removed entirely. If the facts have ZERO modifier lines, the "
"output must have ZERO modifier sentences — drop them.\n"
"- Modifiers IN the facts: emit each as a brief capitalized "
"sentence at the end. Example: `Barefoot.`\n"
"- All slots cleared + modifier present: output ONLY the modifier "
"sentence(s).\n"
"- All slots cleared + no modifiers: output `Otherwise nude.`\n"
"- Do NOT invent details, add adjectives, or reword unchanged items.\n"
"/no_think"
)
async def render_outfit_section(
state: PromptState,
existing_outfit_prose: str = "",
*,
model_compose: Optional[Callable[[str, str], Awaitable[str]]] = None,
char: Optional[CharacterState] = None,
) -> str:
"""FRUIT-style outfit rendering: take existing prose + structured
facts, ask the LLM to surgically update the prose preserving the
user's phrasing where unchanged.
`char` defaults to the primary; pass an explicit CharacterState to
render any character's outfit in multi-character mode.
Falls back to deterministic compose when model_compose is None or
when there's no existing prose to preserve (build mode)."""
if char is None:
char = state.primary_character()
if char is None:
return ""
outfit = char.outfit
header = _outfit_header(char)
# Bio-anchor short-circuit: outfit matches bio with at most a
# modifier applied (barefoot, topless, etc.) -- emit bio prose
# verbatim, then deterministically strip modifier-cleared items
# and prepend the modifier substitute. Preserves the bio's rich
# descriptive prose ("light blue sleeveless thong leotard with
# stiff tight mock-neck, ribbed sweater fabric, ...") instead of
# routing through FRUIT, which compresses to slot tokens
# ("blue leotard, garrison cap, ...").
#
# Gate on natlang_anchor presence rather than bio_outfit_id: some
# match-characters paths drop the id field but still populate the
# anchor. Anchor only gets set from a real bio load, only cleared
# when the user explicitly diverges from the bio outfit (fills /
# strips / user_extras).
bio_anchor_ok = (
outfit.natlang_anchor.strip()
and not outfit.user_extra_phrases
and not _has_user_fills_or_strips(outfit)
)
if bio_anchor_ok:
body = _strip_leading_section_header(outfit.natlang_anchor.strip())
if outfit.active_modifiers:
# Modifier present: drop items the modifier cleared, then
# prepend the modifier's display token. Existing backstop
# _drop_modifier_cleared_items handles the strip; we then
# add modifier names as a short clause at the front so the
# render makes the modifier explicit even if no body
# sentence happened to mention it.
body = _drop_modifier_cleared_items(body, outfit)
mod_clause = ", ".join(
m.replace("_", " ").capitalize()
for m in outfit.active_modifiers
if m
)
if mod_clause and mod_clause.lower() not in body.lower():
body = f"{mod_clause}. {body}" if body else f"{mod_clause}."
return f"{header}\n{body}" if body else ""
# Build the structured facts block — what the model should reflect.
facts = _outfit_facts_for_prompt(outfit)
# When the user diverged from the bio outfit (fill/strip/modifier) and
# we have no existing prose to anchor on, use the V2 chip-composed
# natlang_anchor as the FRUIT seed. Without this, build-mode outfit
# edits drop the rich curated prose ("green sleeveless thong leotard
# with upside-down red triangle insignia...") in favor of generic
# slot-summary text ("Wearing orange leotard, beret, ..."). The
# anchor carries the user's authored chip-level detail; FRUIT just
# has to swap the touched slot's phrasing.
seed_prose = existing_outfit_prose.strip()
if not seed_prose and outfit.natlang_anchor.strip():
seed_prose = _strip_leading_section_header(outfit.natlang_anchor.strip())
# No prose seed at all OR no model: deterministic compose fallback.
if not seed_prose or model_compose is None:
body = _compose_outfit_prose(outfit)
return f"{header}\n{body}" if body else ""
# FRUIT-style edit via LLM.
cache_key = _facts_hash("outfit", facts, seed_prose)
if cache_key in _PROSE_CACHE:
return _PROSE_CACHE[cache_key]
user_prompt = (
f"Existing outfit prose:\n{seed_prose}\n\n"
f"Structured facts (what the outfit should reflect now):\n{facts}\n\n"
f"Rewrite the outfit prose to match the structured facts. "
f"Preserve the user's phrasing for unchanged items."
)
body = ""
try:
raw = await model_compose(_FRUIT_OUTFIT_SYSTEM_PROMPT, user_prompt)
if isinstance(raw, str):
body = raw.strip()
except Exception:
body = ""
if not body:
body = _compose_outfit_prose(outfit)
body = _strip_leading_section_header(body)
if not body:
return ""
# User-fill backstop: 8B-class FRUIT calls occasionally drop a user
# fill from the rewritten body (e.g. user asks for "red socks", facts
# have legwear: red socks, LLM output omits it). Deterministically
# inject any ORIGIN_USER slot phrase that isn't present.
body = _inject_missing_user_fills(body, outfit)
# Modifier-clear backstop: symmetric to the user-fill backstop. When
# a modifier (barefoot, topless, etc.) clears a slot, the LLM should
# remove the corresponding item from prose; at 8B it routinely keeps
# the rich anchor sentence verbatim. Walk CLEARED slots that have
# by_modifier set + prior item info preserved, and remove any phrase
# in the body referencing that item.
body = _drop_modifier_cleared_items(body, outfit)
result = f"{header}\n{body}"
_PROSE_CACHE[cache_key] = result
return result
def _drop_modifier_cleared_items(body: str, outfit: OutfitState) -> str:
"""For each CLEARED-by-modifier slot with preserved item info, strip
body phrases referencing that item.
Phrase removal heuristic: split the body on commas and periods,
drop any segment that contains the item word (e.g. "boots") OR
the slot's source_phrase (e.g. "brown_boots"). Re-join survivors.
Conservative — segments without the item survive, so an unrelated
sentence mentioning a hand or face stays put."""
targets: list[str] = []
for n in SLOT_NAMES:
s = outfit.slot_states[n]
if s.state != SLOT_STATE_CLEARED or not s.by_modifier:
continue
item_word = (s.item or "").replace("_", " ").strip().lower()
if item_word:
targets.append(item_word)
if not targets:
return body
# Segment on commas/periods, keep delimiters so re-join preserves
# punctuation rhythm.
segments = re.split(r"([,.])", body)
cleaned: list[str] = []
skip_next_delim = False
for seg in segments:
if seg in (",", "."):
if skip_next_delim:
skip_next_delim = False
continue
cleaned.append(seg)
continue
seg_lc = seg.lower()
if any(t in seg_lc for t in targets):
skip_next_delim = True
continue
cleaned.append(seg)
result = "".join(cleaned)
# Tidy: leading commas and double-period from removed segments.
result = re.sub(r"^[\s,]+", "", result).strip()
result = re.sub(r",\s*\.", ".", result)
result = re.sub(r"\.\s*\.", ".", result)
result = re.sub(r",\s*,", ",", result)
return result
def _inject_missing_user_fills(body: str, outfit: OutfitState) -> str:
"""For each filled slot, ensure its phrase appears in body. If not,
inject it into the comma-list before the period.
Catches two FRUIT failure modes at 8B:
- ORIGIN_USER fill dropped (e.g. user said "red socks", LLM omits)
- ORIGIN_BIO fill dropped when a modifier is active (e.g. barefoot
causes LLM to over-collapse the body to "Barefoot." losing the
other slots' items).
"""
missing: list[str] = []
body_lc = body.lower()
for n in SLOT_NAMES:
s = outfit.slot_states[n]
if s.state != SLOT_STATE_FILLED:
continue
phrase = _slot_phrase(s).strip()
if not phrase:
continue
# Already present if any meaningful token appears (color or item).
if phrase.lower() in body_lc:
continue
# Use the bare item as fallback check for cases like "red socks"
# already in body as just "socks".
item_lc = (s.item or "").replace("_", " ").lower().strip()
if item_lc and item_lc in body_lc:
continue
missing.append(phrase)
if not missing:
return body
# Inject before the final period of the first sentence (the
# "Wearing ..." sentence). If no period found, append as a new
# comma-extending tail.
extra = ", " + ", and ".join(missing) if len(missing) == 1 else (
", " + ", ".join(missing[:-1]) + f", and {missing[-1]}"
)
# Find the first period that ends a sentence.
m = re.search(r"\.(?:\s|$)", body)
if m:
insert_at = m.start()
return body[:insert_at] + extra + body[insert_at:]
return body + extra
def _outfit_facts_for_prompt(outfit: OutfitState) -> str:
"""Render slot_states + active_modifiers + user_extras as a flat
list the LLM can match against existing prose. Section headings
keep `item` (clothing) and `body_modifier` (barefoot/topless/etc.)
distinct so the LLM doesn't conflate the design-quality `modifiers`
slot (e.g. fingerless_gloves) with body-state modifiers."""
item_lines: list[str] = []
for n in SLOT_NAMES:
s = outfit.slot_states[n]
if s.state == SLOT_STATE_FILLED and s.item:
phrase = _slot_phrase(s)
# Use "design quality" for the design-modifiers slot, "item"
# for everything else, to dodge the modifier/modifiers
# confusion in the LLM's output.
label = "design quality" if n == "modifiers" else "item"
item_lines.append(f" - {label} ({n}): {phrase}")
for extra in outfit.user_extra_phrases:
if extra.strip():
item_lines.append(f" - extra item: {extra.strip()}")
if not item_lines:
item_lines.append(" (no clothing items — fully stripped)")
if outfit.active_modifiers:
mod_lines = [
f" - body_modifier: {m.replace('_', ' ')}"
for m in outfit.active_modifiers
]
else:
mod_lines = [
" (NONE — drop all body-state modifier sentences "
"like `Barefoot.` / `Topless.` from the existing prose)"
]
return (
"Items (clothing/accessories present — use these as the outfit description):\n"
+ "\n".join(item_lines)
+ "\n\nBody-state modifiers (active flags like barefoot/topless/nude):\n"
+ "\n".join(mod_lines)
)
def render_outfit_section_legacy(state: PromptState) -> str:
"""Compose outfit prose from slot_states. Header includes outfit name
and character so downstream consumers (style matching, patch-mode
diffing) can see what's in this slot without parsing prose."""
char = state.primary_character()
if char is None:
return ""
outfit = char.outfit
header = _outfit_header(char)
if (
outfit.natlang_anchor.strip()
and not outfit.active_modifiers
and not outfit.user_extra_phrases
and not _has_user_fills_or_strips(outfit)
):
body = _strip_leading_section_header(outfit.natlang_anchor.strip())
return f"{header}\n{body}"
body = _compose_outfit_prose(outfit)
return f"{header}\n{body}" if body else ""
def _outfit_header(char: CharacterState) -> str:
"""Build `// Outfit: <Name> from Character: <SourceCharacter>`.
When the outfit was borrowed from another character (e.g. user said
`cammy white in chun-li's outfit`), `outfit.source_display` is set
and the header attributes the outfit to the source bio (Chun-Li),
not the subject (Cammy). Without this, readers see Chun-Li's qipao
body labeled as if it belongs to Cammy."""
parts = ["// Outfit:"]
outfit_name = (char.outfit.name or "").strip()
source = (char.outfit.source_display or "").strip()
char_name = source or (char.display or char.tag or "").strip()
if outfit_name:
parts.append(outfit_name)
if char_name:
parts[-1] += f" from Character: {char_name}"
return " ".join(parts)
def _has_user_fills_or_strips(outfit: OutfitState) -> bool:
for n in SLOT_NAMES:
s = outfit.slot_states[n]
if s.state == SLOT_STATE_FILLED and s.origin == ORIGIN_USER:
return True
if s.state == SLOT_STATE_CLEARED and s.by_modifier == "strip":
return True
# User-explicit single-slot clears ("no socks", "remove the
# boots") must defeat the bio-anchor short-circuit — otherwise
# the anchor renders verbatim and re-includes the item the
# user just asked to remove.
if s.state == SLOT_STATE_CLEARED and s.by_modifier == "user_remove":
return True
return False
def _compose_outfit_prose(outfit: OutfitState) -> str:
"""Walk slot_states deterministically. Filled-slot phrases render
in slot taxonomy order so the output is stable across turns."""
filled_phrases: list[str] = []
seen_modifier_attribs: set[str] = set()
for n in SLOT_NAMES:
s = outfit.slot_states[n]
if s.state == SLOT_STATE_FILLED and s.item:
filled_phrases.append(_slot_phrase(s))
# Active modifiers as a single trailing sentence ("Barefoot.")
modifier_phrases: list[str] = []
for mod in outfit.active_modifiers:
if mod in seen_modifier_attribs:
continue
seen_modifier_attribs.add(mod)
modifier_phrases.append(_modifier_to_prose(mod))
pieces: list[str] = []
if filled_phrases:
pieces.append(_join_phrases_into_sentence(filled_phrases))
if modifier_phrases:
pieces.append(_join_modifier_sentences(modifier_phrases))
# User extra phrases. Drop any that are clearly slot-taxonomy items
# (they belong in slot_states, not extras) — without this, a stale
# extras entry like "red socks" can echo as a trailing sentence even
# after a barefoot modifier cleared the legwear slot. user_extras
# should only carry items NOT in the slot taxonomy (rare free-form
# additions like "tiara" if accessories isn't decomposed enough).
filled_token_sets: list[set[str]] = []
for n in SLOT_NAMES:
s = outfit.slot_states[n]
if s.state == SLOT_STATE_FILLED and s.item:
tokens = set(re.findall(r"\w+", _slot_phrase(s).lower()))
if tokens:
filled_token_sets.append(tokens)
for extra in outfit.user_extra_phrases:
text = (extra or "").strip()
if not text:
continue
extra_tokens = set(re.findall(r"\w+", text.lower()))
# Drop if the phrase mentions ANY slot-taxonomy item (it should
# have been a slot fill, not a free-form extra).
if _phrase_is_slot_taxonomy(extra_tokens):
continue
if extra_tokens and any(extra_tokens.issubset(fs) or fs.issubset(extra_tokens)
for fs in filled_token_sets):
continue
if not text.endswith("."):
text += "."
pieces.append(text[0].upper() + text[1:])
if not pieces:
# Nothing filled, no modifiers — totally stripped.
pieces.append("Otherwise nude.")
return " ".join(pieces)
def _slot_phrase(s: SlotState) -> str:
item = (s.item or "").replace("_", " ").strip()
color = (s.color or "").strip()
if color:
return f"{color} {item}"
return item
def _join_phrases_into_sentence(phrases: list[str]) -> str:
"""`["green leotard", "red gauntlets", "red socks"]`
→ "Wearing green leotard, red gauntlets, and red socks."
"""
if not phrases:
return ""
if len(phrases) == 1:
return f"Wearing {phrases[0]}."
if len(phrases) == 2:
return f"Wearing {phrases[0]} and {phrases[1]}."
head = ", ".join(phrases[:-1])
return f"Wearing {head}, and {phrases[-1]}."
def _modifier_to_prose(canonical: str) -> str:
"""`barefoot` → "Barefoot." `topless` → "Topless." Underscores
become spaces, first letter capitalized."""
text = canonical.replace("_", " ").strip()
if not text:
return ""
return text[0].upper() + text[1:]
def _join_modifier_sentences(modifier_phrases: list[str]) -> str:
"""Each modifier is a brief sentence ending in period."""
return " ".join(p if p.endswith(".") else p + "." for p in modifier_phrases if p)
def _phrase_is_slot_taxonomy(tokens: set[str]) -> bool:
"""True when any token in `tokens` is a known clothing keyword from
the slot taxonomy. user_extra_phrases that match this should be
dropped — they belong in slot_states, not free-form extras."""
if not tokens:
return False
try:
from .natlang_facts import _SLOT_KEYWORDS as _SK
except Exception:
return False
return any(t in _SK for t in tokens)
# ── pose section (model call with facts-hash cache) ──────────────
async def render_pose_section(state: PromptState,
*,
model_compose: Optional[Callable[[str, str], Awaitable[str]]] = None,
) -> str:
"""Compose pose prose from bio anchor + descriptive_facts +
pose_modifiers. Model call when descriptive_facts are user-supplied
and would override/extend the anchor. Bio-anchor short-circuit when
no user facts are present.
model_compose: async callable (system_prompt, user_prompt) → str.
Pass None to skip model entirely (returns deterministic fallback).
"""
char = state.primary_character()
if char is None:
return ""
pose = char.pose
header = _pose_header(char)
# Nothing-to-render guard: no bio anchor AND no user facts AND no
# pose modifiers means the user didn't ask for a pose. Returning ""
# so the orchestrator omits the section. Without this, the LLM
# compose path below gets called with an empty user_prompt and
# hallucinates a generic stock pose paragraph the user never asked
# for — same class of leak as a stale expression carrying through.
if (not pose.natlang_anchor.strip()
and not pose.descriptive_facts
and not pose.pose_modifiers):
return ""
# Bio short-circuit: no user facts to integrate.
if not pose.descriptive_facts and not pose.pose_modifiers and pose.natlang_anchor.strip():
body = _strip_leading_section_header(pose.natlang_anchor.strip())
# Body-part-to-slot injection only applies to chip-anchored
# poses (bio_pose_id=None). Bio-authored poses already account
# for the character's outfit in their natlang and would
# double-mention items if augmented.
if pose.bio_pose_id is None:
body = _inject_slot_context_into_pose_body(body, char)
return f"{header}\n{body}"
# Chip-anchored short-circuit with user facts: skip FRUIT compose.
# The chip's authored natlang is precious — image models are tuned
# for the specific phrasings the curator wrote, and FRUIT at 8B
# paraphrases them into softer/less-specific text ("presenting
# feet with focus on soles" → "feet resting near her torso, toes
# pointed subtly upward"). Render as `<chip natlang>. <fact1>.
# <fact2>.` — chip stays verbatim, user facts append cleanly. The
# body-part-to-slot injection still applies for foot-region chips.
if (pose.natlang_anchor.strip()
and pose.bio_pose_id is None
and (pose.descriptive_facts or pose.pose_modifiers)):
anchor = _strip_leading_section_header(pose.natlang_anchor.strip())
anchor = _inject_slot_context_into_pose_body(anchor, char)
parts = [anchor.rstrip(".")] if anchor else []
for fact in pose.descriptive_facts:
fact_clean = (fact or "").strip().rstrip(".").strip()
if fact_clean and fact_clean.lower() not in anchor.lower():
parts.append(fact_clean[0].upper() + fact_clean[1:] if fact_clean else "")
for mod in pose.pose_modifiers:
mod_text = (mod or "").replace("_", " ").strip()
if mod_text and mod_text.lower() not in anchor.lower():
parts.append(mod_text[0].upper() + mod_text[1:])
if parts:
body = ". ".join(p for p in parts if p) + "."
return f"{header}\n{body}"
# Pure facts (no anchor, no model): compose deterministically.
if not pose.natlang_anchor.strip() and not model_compose:
body = _compose_pose_facts_only(pose)
if not body:
return ""
return f"{header}\n{body}"
# Cached?
cache_key = _facts_hash(
"pose",
pose.natlang_anchor,
tuple(pose.descriptive_facts),
tuple(pose.pose_modifiers),
)
if cache_key in _PROSE_CACHE:
return _PROSE_CACHE[cache_key]
# Model call
if model_compose is None:
body = _compose_pose_facts_only(pose) or pose.natlang_anchor or ""
if not body:
return ""
result = f"{header}\n{body}"
_PROSE_CACHE[cache_key] = result
return result
system_prompt = _build_pose_system_prompt()
user_prompt = _build_pose_user_prompt(pose)
body = ""
try:
raw = await model_compose(system_prompt, user_prompt)
if isinstance(raw, str):
body = raw.strip()
except Exception:
body = ""
# Fall back to deterministic compose on empty / non-string / exception.
# Without this, a model timeout or connection-refused leaves the section
# empty even when we have facts in hand.
if not body:
body = _compose_pose_facts_only(pose) or pose.natlang_anchor or ""
body = _strip_leading_section_header(body)
if not body:
return ""
# User-fact backstop: same shape as the outfit user-fill backstop.
# When pose.descriptive_facts contains a user-named action ("sparring",
# "kicking") the LLM compose at 8B occasionally paraphrases without
# using the literal token, making intent untraceable downstream.
# Inject any missing fact as a brief sentence so the user's wording
# survives at minimum.
body = _inject_missing_pose_facts(body, pose)
result = f"{header}\n{body}"
_PROSE_CACHE[cache_key] = result
return result
# Body-part keyword → outfit slot names in priority order. Restricted
# to FOOT-REGION body parts only — image models default feet to bare
# when the pose body says "presenting feet" without mentioning footwear
# or socks, and this is the documented bug we're fixing. Other body
# parts (arms/hands/head/torso) don't have the same default-to-naked
# bias — a chip body saying "arms behind head" doesn't make the image
# model strip the top, so injecting "arms wearing leotard" adds noise
# without fixing anything (and actively produces gibberish for pose
# bodies that mention body parts incidentally, like "Both arms bent
# with hands behind head" → "Both arms wearing leotard bent with hands
# wearing gloves behind head wearing cap").
#
# Priority order matters for foot region: legwear (socks) takes
# precedence over footwear because socks are the closer-to-body layer
# and image models render socks when explicitly mentioned.
_POSE_BODY_PART_TO_SLOTS: dict[str, tuple[str, ...]] = {
"feet": ("legwear", "footwear"),
"foot": ("legwear", "footwear"),
"soles": ("legwear", "footwear"),
"sole": ("legwear", "footwear"),
"toes": ("legwear", "footwear"),
"toe": ("legwear", "footwear"),
}
def _inject_slot_context_into_pose_body(body: str, char: CharacterState) -> str:
"""Augment chip-anchored pose body with current slot content where
a body-part keyword appears. The chip natlang is a generic template
("presenting feet with focus on soles"); the user's outfit slots
carry the specifics (legwear=pink_socks). The image model sees the
pose section in isolation from outfit and defaults body parts to
bare unless the pose body explicitly names the clothing — so we
inject the clothing reference at the body-part keyword position.
Conservative: only injects when the slot is actually FILLED with a
real item, and only once per slot per body. Skips when the chip
body already mentions the slot's item (no double-mention). The
chip's authored prose is preserved otherwise.
Example: "sitting with legs up, presenting feet with focus on soles"
+ legwear=pink_socks → "sitting with legs up, presenting feet
wearing pink socks with focus on soles". Bare-feet case
(legwear+footwear both cleared) leaves the body untouched."""
if not body or char is None:
return body
outfit = char.outfit
body_lc = body.lower()
out = body
used_slots: set[str] = set()
for body_part in _POSE_BODY_PART_TO_SLOTS:
# Word-boundary search against the (possibly already-augmented)
# body so re-runs over the same body part skip cleanly.
m = re.search(rf"\b{re.escape(body_part)}\b", out, re.IGNORECASE)
if not m:
continue
slots = _POSE_BODY_PART_TO_SLOTS[body_part]
fill = None
for slot_name in slots:
if slot_name in used_slots:
continue
ss = outfit.slot_states.get(slot_name)
if ss is None:
continue
if ss.state != SLOT_STATE_FILLED:
continue
item = (ss.item or "").replace("_", " ").strip()
color = (ss.color or "").strip()
if not item:
continue
# Don't re-inject if the body already mentions this item.
if item.lower() in body_lc:
continue
fill = (color, item, slot_name)
break
if fill is None:
continue
color, item, slot_name = fill
clothing = f"{color} {item}".strip() if color else item
# Inject "wearing <clothing>" right after the body-part keyword.
# End of keyword span = m.end() in `out`; insertion preserves
# surrounding punctuation/whitespace.
injection = f" wearing {clothing}"
out = out[:m.end()] + injection + out[m.end():]
body_lc = out.lower()
used_slots.add(slot_name)
return out
def _inject_missing_pose_facts(body: str, pose: PoseState) -> str:
"""Ensure each pose.descriptive_facts entry survives in the body —
either as a direct substring or appended as a brief sentence."""
if not pose.descriptive_facts:
return body
body_lc = body.lower()
missing: list[str] = []
for fact in pose.descriptive_facts:
if not isinstance(fact, str):
continue
f = fact.strip()
if not f:
continue
if f.lower() in body_lc:
continue
missing.append(f)
if not missing:
return body
suffix = " ".join(
m if m.endswith(".") else f"{m.capitalize()}." for m in missing
)
sep = "" if body.rstrip().endswith(".") else "."
return f"{body.rstrip()}{sep} {suffix}"
def _pose_header(char: CharacterState) -> str:
"""`// Pose: <Name> (signature) from Character: <SourceCharacter>`
when a bio pose is loaded; `// Pose: <Chip Name>` (no attribution)
for generic chip applies; `// Pose:` for freeform.
Bio-pose attribution: when the pose was borrowed from another
character (e.g. `tifa lockhart in cammy's victory pose`),
`pose.source_display` is set and the header attributes the pose to
the source bio (Cammy), not the subject (Tifa). Bio pose without a
cross-borrow still attributes to the character owner.
Chip-pose attribution: generic chips (`presenting_feet`) come from
the curated pool, not from any character's bio — `bio_pose_id` stays
None to signal this. Header skips the `from Character` suffix so the
output reads `// Pose: Presenting Feet`."""
parts = ["// Pose:"]
pose_name = (char.pose.name or "").strip()
source = (char.pose.source_display or "").strip()
char_name = source or (char.display or char.tag or "").strip()
if pose_name and char.pose.natlang_anchor.strip():
suffix = pose_name
if char.pose.is_signature:
suffix += " (signature)"
# Attribute "from Character" only for bio-derived poses. Generic
# pose chips set bio_pose_id=None to opt out of attribution.
if char.pose.bio_pose_id is not None and char_name:
suffix += f" from Character: {char_name}"
parts.append(suffix)
return " ".join(parts)
def _compose_pose_facts_only(pose: PoseState) -> str:
"""Deterministic fallback when model isn't available. Joins facts
into a brief sentence."""
parts = []
for f in pose.descriptive_facts:
f = (f or "").strip()
if f:
parts.append(f)
for m in pose.pose_modifiers:
m = m.replace("_", " ").strip()
if m:
parts.append(m)
if not parts:
return ""
body = ", ".join(parts)
if not body.endswith("."):
body += "."
return body[0].upper() + body[1:]
def _build_pose_system_prompt() -> str:
return (
"You write a single concise paragraph describing a character's pose "
"for an image prompt. Use the structured facts as the source of truth. "
"Do not invent details not in the facts. Output only the paragraph — "
"no preface, no headers, no quotes."
)
def _build_pose_user_prompt(pose: PoseState) -> str:
lines = ["Compose a pose paragraph from these facts:"]
if pose.natlang_anchor.strip():
lines.append(f"Bio anchor: {_strip_leading_section_header(pose.natlang_anchor)}")
if pose.descriptive_facts:
lines.append("User facts: " + "; ".join(pose.descriptive_facts))
if pose.pose_modifiers:
lines.append("Pose modifiers: " + ", ".join(
m.replace("_", " ") for m in pose.pose_modifiers
))
return "\n".join(lines)
# ── expression / setting (server, plain text) ────────────────────
def render_expression_section(state: PromptState) -> str:
text = (state.expression or "").strip()
if not text:
return ""
text = _strip_leading_section_header(text)
if not text.endswith("."):
text += "."
text = text[0].upper() + text[1:]
return f"// Expression:\n{text}"
def render_setting_section(state: PromptState) -> str:
text = (state.setting or "").strip()
if not text:
return ""
text = _strip_leading_section_header(text)
# Setting is often a phrase ("in a dungeon at night"). Make it a sentence.
if not text.endswith("."):
text += "."
text = text[0].upper() + text[1:]
return f"// Scene:\n{text}"
# ── no-bio scene edit (FRUIT for non-character prompts) ──────────
_FRUIT_SCENE_SYSTEM_PROMPT = (
"You edit a Stable Diffusion prompt's // Scene body. The user has "
"no curated character bio — the entire scene is freeform descriptive "
"prose.\n"
"\n"
"You will receive:\n"
" EXISTING_SCENE: the current scene prose (may be empty)\n"
" USER_INSTRUCTION: the user's edit request OR a fresh description\n"
"\n"
"Rules:\n"
"- If EXISTING_SCENE is empty: USER_INSTRUCTION IS the new scene. "
"Output it verbatim (or trivially cleaned — first letter capitalized, "
"trailing period). Do NOT elaborate or invent details.\n"
"- If EXISTING_SCENE is non-empty AND USER_INSTRUCTION is an edit "
"(`change X to Y`, `remove X`, `add X`, `replace X with Y`): apply "
"the edit minimally. Preserve every clause USER_INSTRUCTION didn't "
"touch — typos, capitalization, sentence rhythm. Do NOT rewrite or "
"polish unaffected parts.\n"
"- If EXISTING_SCENE is non-empty AND USER_INSTRUCTION is a fresh "
"description (no edit verb): replace EXISTING_SCENE entirely with "
"USER_INSTRUCTION.\n"
"\n"
"Output ONLY the rewritten scene prose body — no header, no "
"preamble, no quotes.\n"
"/no_think"
)
async def edit_scene_no_bio(existing_scene: str,
user_instruction: str,
*,
model_compose: Optional[Callable[[str, str], Awaitable[str]]] = None,
) -> str:
"""FRUIT-style scene edit for non-character prompts. Applies
user_instruction to existing_scene; on build-mode (no existing) the
user_instruction becomes the new scene. Falls back to verbatim
user_instruction when model_compose is unavailable."""
instr = (user_instruction or "").strip()
existing = (existing_scene or "").strip()
if not instr and not existing:
return ""
if model_compose is None or not instr:
return existing or instr
user_prompt = (
f"EXISTING_SCENE:\n{existing}\n\n"
f"USER_INSTRUCTION:\n{instr}\n\n"
f"Output the rewritten scene prose body."
)
try:
raw = await model_compose(_FRUIT_SCENE_SYSTEM_PROMPT, user_prompt)
if isinstance(raw, str) and raw.strip():
return _strip_leading_section_header(raw.strip())
except Exception:
pass
# Fallback: existing prose untouched, or instruction-as-scene if no prior.
return existing or instr
# ── style / negative passthroughs ────────────────────────────────
def render_style_section(state: PromptState) -> str:
if state.style is None:
return ""
name = (state.style.name or "").strip()
if not name:
return ""
return f"// Style:\n{name}"
# ── orchestrator ─────────────────────────────────────────────────
async def render_all_sections(state: PromptState,
*,
node_prompt: str = "",
changed_sections=None,
model_compose=None,
) -> list[dict]:
"""Run every renderer. Sections in `changed_sections` go through
FRUIT-style regen (existing prose + structured facts → LLM rewrite
preserving user phrasing). Sections NOT in `changed_sections` are
preserved verbatim from node_prompt.
Pass changed_sections=None to render everything (used during
ingestion / first build when there's no prior prose).
Style section is rendered by ai_api's post-pass."""
prior_sections = _parse_prior_sections(node_prompt)
def should_regen(kind: str) -> bool:
return changed_sections is None or kind in changed_sections
def _from_prior_or(kind: str, default: str) -> str:
if should_regen(kind):
return default
prior = prior_sections.get(kind)
return _section_from_prior(prior) or default
sections: list[dict] = []
# Iterate ALL characters in state — multi-character scenarios
# (`cammy white and chun-li sparring`) emit Character + Outfit
# blocks per character. Single-character is just len=1 and behaves
# as before. Prior-section preservation only honors the first
# // Character / // Outfit block in node_prompt — multi-char on a
# follow-up turn always re-renders fresh, which is acceptable
# because preservation can't disambiguate per character without
# subject-keyed parsing of the prior prose.
# Filter out phantom characters: matcher false positives can land
# bios with empty base_natlang in state.characters (e.g. `powered`
# n-gram from Iron Man's bio body matching `powered_ciel`).
# render_character_section returns "" for these, but the outfit
# renderer still emits "Otherwise nude." per character — duplicate
# empty // Outfit sections in the output. Skip them entirely.
real_chars = [c for c in state.characters if (c.base_natlang or "").strip()]
is_multi_char = len(real_chars) > 1
for idx, ch in enumerate(real_chars):
is_first = (idx == 0) and not is_multi_char
# Character — bio.base_natlang verbatim normally; preserve user
# prose until SwapCharacter (single-char only).
if is_first:
char_text = _from_prior_or("character", render_character_section(state, char=ch))
else:
char_text = render_character_section(state, char=ch)
if char_text:
sections.append(_section_dict(char_text))
# Outfit — FRUIT-style edit when changed.
if is_first and should_regen("outfit"):
existing_outfit = (prior_sections.get("outfit") or {}).get("body_text", "")
outfit_text = await render_outfit_section(
state, existing_outfit, model_compose=model_compose, char=ch,
)
elif is_first:
outfit_text = _from_prior_or(
"outfit",
await render_outfit_section(state, "", model_compose=model_compose, char=ch),
)
else:
# Secondary character outfits always regen fresh — there's
# no prior-prose-by-character map to preserve from.
outfit_text = await render_outfit_section(
state, "", model_compose=model_compose, char=ch,
)
if outfit_text:
sections.append(_section_dict(outfit_text))
# Pose — LLM regen when changed.
if should_regen("pose"):
pose_text = await render_pose_section(state, model_compose=model_compose)
else:
pose_text = _from_prior_or(
"pose",
await render_pose_section(state, model_compose=model_compose),
)
if pose_text:
sections.append(_section_dict(pose_text))
# Expression — preserve verbatim when not changed.
expr_text = _from_prior_or("expression", render_expression_section(state))
if expr_text:
sections.append(_section_dict(expr_text))
# Setting — same. Header may be either `// Scene:` or `// Setting:`.
if should_regen("setting"):
set_text = render_setting_section(state)
else:
prior = prior_sections.get("scene") or prior_sections.get("setting")
set_text = _section_from_prior(prior) or render_setting_section(state)
if set_text:
sections.append(_section_dict(set_text))
return sections
def _parse_prior_sections(node_prompt: str) -> dict:
"""Extract section bodies from a // Section-formatted node_prompt.
Returns {kind: {header, body_text}} where kind is the lowercased
first word of the section header (character/outfit/pose/expression/
scene/style)."""
if not node_prompt:
return {}
out: dict = {}
current_kind = None
current_header = ""
current_body: list[str] = []
for line in node_prompt.splitlines():
if line.strip().startswith("//"):
if current_kind:
out[current_kind] = {
"header": current_header,
"body_text": _NL.join(current_body).strip(),
}
current_header = line.rstrip()
inner = line.strip().lstrip("/").strip()
kind_word = inner.split(":", 1)[0].strip().lower().split()[0] if inner else ""
if kind_word in ("character", "outfit", "pose", "expression", "scene", "setting", "style"):
current_kind = kind_word
else:
current_kind = None
current_body = []
elif current_kind:
current_body.append(line)
if current_kind:
out[current_kind] = {
"header": current_header,
"body_text": _NL.join(current_body).strip(),
}
return out
def _section_from_prior(prior) -> str:
"""Return header+newline+body from a prior section dict, or empty."""
if not prior:
return ""
header = (prior.get("header") or "").strip()
body = (prior.get("body_text") or "").strip()
if not header or not body:
return ""
return header + _NL + body
def _section_dict(rendered: str) -> dict:
"""Split a rendered "// Header:\nbody" string into the section dict
shape used by the rest of ai_api."""
lines = rendered.split("\n", 1)
header = lines[0].rstrip()
body = lines[1].strip() if len(lines) > 1 else ""
return {
"header": header,
"body_text": body,
"tokens": [body] if body else [],
}
# ── helpers ──────────────────────────────────────────────────────
_LEADING_SECTION_HEADER_RE = re.compile(r"^\s*//\s*[A-Za-z][^\n]*\n+")
def _strip_leading_section_header(text: str) -> str:
if not text:
return text
return _LEADING_SECTION_HEADER_RE.sub("", text).strip()