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

713 lines
31 KiB
Python

"""Parse user_request strings into structured Intents for the natlang
render flow.
This is the structural-intent extraction layer. The render flow uses
these intents to mutate PromptState deterministically (server-side),
then renders prose. Anything the parser doesn't recognize falls through
as `descriptive_residue` — passed to the model for prose elaboration.
Recognized intent kinds (corresponds to PromptState mutations):
- outfit_strip: "wearing only X" / "just X" / "only X"
- outfit_swap: "switch to X (outfit)" / "change outfit to X" / "in X outfit"
/ "wearing X outfit" (X must match a bio outfit name)
- pose_swap: "doing X (pose)" / "in X pose" / pose-name match
(X must match a bio pose name)
- expression_set: explicit `expression: X` form
- setting_set: "in a/at the X" + location keyword OR "setting: X"
- modifier_apply: any slot_modifier alias hit (delegates to
_detect_modifiers_in_text in ai_api)
- pose_descriptive: posture verb hit (sitting/kneeling/lying/...) OR
residue clause that classifies as pose-related.
Includes flag is_posture: True when the phrase
carries a posture verb (the render flow uses
this to override bio pose anchor).
- expression_descriptive: residue clause classified as expression-related
OR plain emotion word.
- setting_descriptive: residue clause classified as setting-related.
- style_swap: delegates to existing style_search.style_alias_scan
(handled outside this module, in ai_api)
The parser is intentionally regex/keyword-based, not LLM-driven. Speed
matters (sub-millisecond parsing), and the patterns are bounded. The
render flow consumes structural intents deterministically and uses the
descriptive intents to render Pose/Expression/Setting prose without a
model call."""
from __future__ import annotations
import re
from typing import Optional
# ── intent shapes ───────────────────────────────────────────────────
# Each Intent is a dict: {"kind": str, ...kind-specific fields...}
# outfit_strip: {kind, kept_slot: "legwear", target_item: "red_socks", color: "red"}
# outfit_swap: {kind, character_tag: "cammy_white", outfit_name: "Delta Red", outfit_id: int}
# pose_swap: {kind, character_tag, pose_name, pose_id, is_signature, natlang}
# expression_set: {kind, expression: "smiling"}
# setting_set: {kind, setting: "dungeon"}
# modifier_apply: {kind, modifier: dict (from _detect_modifiers_in_text)}
# style_swap: {kind, template_id, name, is_neutral, is_default}
# "wearing only X" / "just X" / "only X"
# Capture X up to a clause boundary so multi-intent prompts like
# "wearing only red socks, smiling, in a dungeon" don't swallow the
# downstream clauses into the strip target.
_STRIP_PATTERNS = [
re.compile(r"\b(?:wearing|in)\s+only\s+(.+?)(?:\.|,|;|$)", re.IGNORECASE),
re.compile(r"\bjust\s+(.+?)(?:\.|,|;|$)", re.IGNORECASE),
re.compile(r"\bonly\s+(?:wearing\s+)?(.+?)(?:\.|,|;|$)", re.IGNORECASE),
]
# "switch to X outfit" / "change to X outfit" / "in X outfit" / "wearing X outfit"
# Anchored variants run first so "switch to delta red outfit" captures
# the full "delta red" rather than terminating the lazy match at the
# first word boundary. The trailing fallbacks run to clause boundary
# for "switch to X" without an outfit/costume/attire anchor.
_OUTFIT_SWAP_PATTERNS = [
re.compile(r"\bswitch\s+(?:to|her\s+to)\s+(?:the\s+)?(.+?)\s+(?:outfit|costume|attire)\b", re.IGNORECASE),
re.compile(r"\bchange\s+(?:outfit\s+to|to)\s+(?:the\s+)?(.+?)\s+(?:outfit|costume|attire)\b", re.IGNORECASE),
re.compile(r"\bin\s+(?:the\s+|her\s+)?(.+?)\s+(?:outfit|costume|attire)\b", re.IGNORECASE),
re.compile(r"\bwearing\s+(?:the\s+|her\s+)?(.+?)\s+(?:outfit|costume|attire)\b", re.IGNORECASE),
re.compile(r"\bswitch\s+(?:to|her\s+to)\s+(?:the\s+)?(.+?)(?:\.|,|;|$)", re.IGNORECASE),
re.compile(r"\bchange\s+(?:outfit\s+to|to)\s+(?:the\s+)?(.+?)(?:\.|,|;|$)", re.IGNORECASE),
]
# "doing X pose" / "in X pose" / "X pose" — anchored forms first so
# "doing spiral arrow pose" doesn't terminate the lazy capture at the
# first word boundary inside "spiral arrow". The bare "doing X" fallback
# runs to clause boundary.
_POSE_SWAP_PATTERNS = [
re.compile(r"\bdoing\s+(?:the\s+|a\s+|her\s+)?(.+?)\s+(?:pose|stance)\b", re.IGNORECASE),
re.compile(r"\bin\s+(?:the\s+|a\s+|her\s+)?(.+?)\s+(?:pose|stance)\b", re.IGNORECASE),
re.compile(r"\b(.+?)\s+(?:pose|stance)\b", re.IGNORECASE),
re.compile(r"\bdoing\s+(?:the\s+|a\s+|her\s+)?(.+?)(?:\.|,|;|$)", re.IGNORECASE),
]
# Expressions — small whitelist of single-word emotional descriptors.
# More phrasal forms ("with a smile", "looks sad") fall through to
# residue and the model handles them.
_EXPRESSION_KEYWORDS = {
"smiling", "smile", "smirking", "smirk",
"frowning", "frown", "scowling", "scowl", "glaring",
"crying", "tearful", "weeping",
"blushing", "embarrassed", "flushed",
"laughing", "giggling",
"angry", "furious", "annoyed",
"neutral", "stoic", "deadpan",
"shocked", "surprised", "wide-eyed",
"sleepy", "drowsy", "tired",
"happy", "joyful", "ecstatic",
"sad", "melancholy", "depressed",
"confident", "smug", "proud",
}
# Setting keywords. Match "in a/at the X" where X contains one of these.
# Plus "setting: X" / "scene: X" / "background: X".
_SETTING_KEYWORDS = {
"dungeon", "forest", "beach", "desert", "city", "alley", "rooftop",
"bedroom", "bathroom", "kitchen", "office", "classroom", "library",
"cafe", "bar", "club", "stage", "studio", "park", "garden",
"battlefield", "ruins", "temple", "shrine", "castle", "tower",
"spaceship", "laboratory", "factory", "warehouse", "garage",
"mountain", "river", "lake", "waterfall", "cave", "grove",
"street", "sidewalk", "balcony", "courtyard", "field",
"underground", "rooftop", "rain", "snow", "fog", "night", "dawn",
"dusk", "sunset", "sunrise",
}
_SETTING_EXPLICIT_RE = re.compile(
r"\b(?:setting|scene|background|location|environment)\s*:\s*(.+?)(?:\.|,|;|$)",
re.IGNORECASE,
)
_SETTING_PHRASE_RE = re.compile(
r"\b(?:in|at|on)\s+(?:a|an|the)?\s*([\w \-]+?)(?:\.|$|,)",
re.IGNORECASE,
)
# Posture verbs — their presence in residue indicates a pose CHANGE
# (overrides any bio-matched pose anchor). Distinct from named-pose swap
# because no bio match is required; the descriptive phrase is the pose.
_POSTURE_VERBS = frozenset({
"sitting", "seated",
"standing", "stood",
"kneeling", "kneeled",
"lying", "laying", "lay", "reclining",
"crouching", "crouched", "squatting",
"jumping", "leaping",
"running", "sprinting",
"walking", "strolling", "striding",
"bending", "hunched", "leaning",
"stretching", "arching",
"dancing",
"fighting", "punching", "kicking",
"posing",
})
# Pose-bin keywords: words that mean a residue clause is about pose/action
# (anatomy, gaze, action verbs). Used by residue binner when no posture
# verb fired. Keep narrow — broad matches over-bin into pose.
_POSE_BIN_KEYWORDS = frozenset({
"legs", "leg", "arms", "arm", "hands", "hand",
"feet", "foot", "toes", "toe",
"knees", "knee", "elbows", "elbow", "shoulders", "shoulder",
"hips", "hip", "back", "chest",
"viewer", "camera",
"showing", "shows", "show", "presenting", "presents",
"holding", "holds", "gripping", "grasping",
"reaching", "pointing", "waving",
"looking", "looks", "gazing", "staring",
"facing", "facing-away", "turned",
})
# Small whitelist of "dressed-state" verbs we don't want classified as
# pose. "wearing X" should NEVER bin to pose — it's outfit territory.
_OUTFIT_BIN_KEYWORDS = frozenset({
"wearing", "wears", "dressed", "clad", "donning",
})
def _normalize(text: str) -> str:
return (text or "").strip()
def _collect_outfit_names(bios: list[dict] | None) -> dict[str, dict]:
"""Map lowercase outfit name -> {character_tag, outfit_name, outfit_id,
is_default, slots, natlang} for fuzzy bio outfit lookup. We use the
bio's pre-loaded list (set at /tag-builder/match-characters time)
rather than re-querying the DB."""
out: dict[str, dict] = {}
for b in bios or []:
char_tag = b.get("tag", "")
# Each bio carries either default_outfit OR user_requested_outfit
# but the OutfitSwap intent must match against the FULL list of
# named outfits the character has. The /match-characters endpoint
# currently only returns the picked one. We fall back to whatever
# is on the bio dict — if a richer list is needed, the bio shape
# gets extended. For the parser's purposes, the picked outfit is
# enough as a starting point; the render flow re-picks from the
# DB once it knows the new outfit_name.
for outfit_field in ("user_requested_outfit", "default_outfit"):
o = b.get(outfit_field)
if not o:
continue
name = (o.get("name") or "").strip()
if name:
out[name.lower()] = {
"character_tag": char_tag,
"outfit_name": name,
"outfit_id": o.get("id"),
"slots": o.get("slots") or [],
"natlang": o.get("natlang") or "",
"tags": o.get("tags") or "",
}
return out
def _collect_pose_names(bios: list[dict] | None) -> dict[str, dict]:
"""Map lowercase pose name -> {character_tag, pose_name, pose_id,
is_signature, natlang, tags} for every pose curated for every
character in `bios`.
Iterates `all_poses` (the full per-character pose list shipped by
match-characters) so the parser sees every candidate the user could
plausibly name — required for the bge-small semantic fallback in
_lookup_bio_pose_in_text to find paraphrases (`fighting pose` →
`Combat Stance`) that aren't substring-matchable. Falls back to
`matched_pose` alone for legacy bio shapes."""
out: dict[str, dict] = {}
for b in bios or []:
char_tag = b.get("tag", "")
all_poses = b.get("all_poses") or []
if all_poses:
for pose in all_poses:
name = (pose.get("name") or "").strip()
if not name:
continue
out.setdefault(name.lower(), {
"character_tag": char_tag,
"pose_name": name,
"pose_id": pose.get("id"),
"is_signature": bool(pose.get("is_signature")),
"natlang": pose.get("natlang") or "",
"tags": pose.get("tags") or "",
})
continue
pose = b.get("matched_pose")
if not pose:
continue
name = (pose.get("name") or "").strip()
if name:
out[name.lower()] = {
"character_tag": char_tag,
"pose_name": name,
"pose_id": pose.get("id"),
"is_signature": bool(pose.get("is_signature")),
"natlang": pose.get("natlang") or "",
"tags": pose.get("tags") or "",
}
return out
def _try_parse_strip(text: str) -> Optional[dict]:
"""Match 'wearing only X' / 'just X' / 'only X' — return the X part.
Caller resolves which slot the X fills. Returns None if no match."""
for pat in _STRIP_PATTERNS:
m = pat.search(text)
if not m:
continue
target = _normalize(m.group(1))
if not target:
continue
return {"kind": "outfit_strip", "target_phrase": target}
return None
def parse_intents(user_request: str,
bios: list[dict] | None) -> dict:
"""Return {intents: list[Intent], descriptive_residue: str}.
Detection order (each layer consumes char spans from the residue):
1. outfit_strip — most specific structural verb
2. outfit_swap — only if no strip
3. pose_swap — bio-matched pose name
4. modifier_apply — slot_modifier alias scan (multiple may fire)
5. expression_set — explicit `expression: X` form
6. setting_set — explicit `setting: X` or `in/at/on <kw>` form
7. posture verbs — bare verbs (`sitting`, `kneeling`, ...)
8. residue binning — split remaining text into clauses, classify
each into pose / expression / setting via
keyword. Emits *_descriptive intents.
Residue is what's left after layers 1-7 consume their spans. Layer 8
classifies and emits descriptive intents — none of those go through
the model. The render flow composes Pose/Expression/Setting prose
from these intents deterministically."""
text = _normalize(user_request)
if not text:
return {"intents": [], "descriptive_residue": ""}
intents: list[dict] = []
consumed: list[tuple[int, int]] = []
outfit_lookup = _collect_outfit_names(bios)
pose_lookup = _collect_pose_names(bios)
# 1. Strip
strip = _try_parse_strip(text)
if strip:
intents.append(strip)
for pat in _STRIP_PATTERNS:
m = pat.search(text)
if m:
consumed.append((m.start(), m.end()))
break
# 2. Outfit swap. Strip + swap is NOT contradictory — "in delta red
# outfit wearing only red socks" means SWAP to Delta Red and THEN
# strip down to socks. The applier runs swap before strip per the
# delta ordering rule, so the strip's kept_slot survives the swap.
swap_match = _find_outfit_swap_with_span(text, outfit_lookup)
if swap_match:
intent, span = swap_match
# Skip if the swap span is fully inside an already-consumed strip
# span (defensive — strip pattern shouldn't overlap, but the
# `in only` form could).
s_start, s_end = span
already_consumed = any(c[0] <= s_start and c[1] >= s_end for c in consumed)
if not already_consumed:
intents.append(intent)
consumed.append(span)
# 3. Pose swap
pose_match = _find_pose_swap_with_span(text, pose_lookup)
if pose_match:
intent, span = pose_match
intents.append(intent)
consumed.append(span)
# 4. Modifier alias scan (lazy import to avoid module-level cycle
# with ai_api). Each modifier is a separate intent. Match spans are
# consumed so the alias text doesn't get re-binned to pose later.
for mod_intent, span in _detect_modifier_intents(text):
intents.append(mod_intent)
if span:
consumed.append(span)
# 5. Explicit expression form (`expression: X`)
expr_explicit = _try_parse_expression_explicit(text)
if expr_explicit:
intent, span = expr_explicit
intents.append(intent)
consumed.append(span)
# 6. Explicit setting forms (`setting: X` or `in/at/on <kw>`)
setting_explicit = _try_parse_setting_explicit(text)
if setting_explicit:
intent, span = setting_explicit
intents.append(intent)
consumed.append(span)
# 7. Posture verbs — bare detection. Each match is a pose_descriptive
# intent flagged as posture (overrides bio pose anchor). Pass bio
# display names so the leading character mention is stripped, plus
# the already-consumed spans so the posture clause doesn't bleed
# into outfit_swap / modifier_apply text the upstream layers already
# claimed.
bio_names_lc = {(b.get("display") or b.get("tag") or "").lower()
for b in (bios or []) if b}
bio_names_lc.discard("")
for posture_intent, span in _detect_posture_intents(text, bio_names_lc, consumed):
intents.append(posture_intent)
if span:
consumed.append(span)
# 8. Residue binning. Take what's left and split into clauses; each
# clause classified into pose/expression/setting via keyword scan.
residue = _residue_from_consumed(text, consumed)
bin_intents, leftover = _bin_residue_to_intents(residue, bios)
intents.extend(bin_intents)
return {"intents": intents, "descriptive_residue": leftover}
# ── span-aware structural finders ──────────────────────────────────
def _find_outfit_swap_with_span(text: str,
outfit_lookup: dict[str, dict]
) -> Optional[tuple[dict, tuple[int, int]]]:
"""Match an outfit-swap phrasing and return (intent, span). Emits a
candidate intent even when the named outfit isn't in the preloaded
bio cache — the render flow re-picks from the DB via _pick_outfit_for
once it knows the new outfit_name. Without this fallback, "switch to
delta red outfit" would silently drop when bio carried only Killer Bee."""
for pat in _OUTFIT_SWAP_PATTERNS:
m = pat.search(text)
if not m:
continue
candidate = _normalize(m.group(1))
if not candidate:
continue
candidate_lc = candidate.lower()
if candidate_lc in outfit_lookup:
hit = outfit_lookup[candidate_lc]
return ({"kind": "outfit_swap", **hit}, (m.start(), m.end()))
for name_lc, hit in outfit_lookup.items():
if name_lc in candidate_lc or candidate_lc in name_lc:
return ({"kind": "outfit_swap", **hit}, (m.start(), m.end()))
return ({"kind": "outfit_swap",
"character_tag": "",
"outfit_name": candidate,
"outfit_id": None,
"slots": [],
"natlang": "",
"tags": ""}, (m.start(), m.end()))
return None
def _find_pose_swap_with_span(text: str,
pose_lookup: dict[str, dict]
) -> Optional[tuple[dict, tuple[int, int]]]:
"""Match pose-swap phrasing. Emits a candidate intent even when the
named pose isn't in the preloaded bio cache — render flow re-picks
via _pick_pose_for over the full DB once it knows the pose name."""
for pat in _POSE_SWAP_PATTERNS:
m = pat.search(text)
if not m:
continue
candidate = _normalize(m.group(1))
if not candidate or len(candidate) < 3:
continue
candidate_lc = candidate.lower()
if candidate_lc in pose_lookup:
return ({"kind": "pose_swap", **pose_lookup[candidate_lc]}, (m.start(), m.end()))
for name_lc, hit in pose_lookup.items():
if name_lc in candidate_lc or candidate_lc in name_lc:
return ({"kind": "pose_swap", **hit}, (m.start(), m.end()))
return ({"kind": "pose_swap",
"character_tag": "",
"pose_name": candidate,
"pose_id": None,
"is_signature": False,
"natlang": "",
"tags": ""}, (m.start(), m.end()))
return None
# ── modifier alias detection ───────────────────────────────────────
def _detect_modifier_intents(text: str) -> list[tuple[dict, Optional[tuple[int, int]]]]:
"""Wrap _detect_modifiers_in_text from ai_api and return modifier_apply
intents with their matched spans. Lazy import to dodge circular
module load order."""
try:
from .ai_api import _detect_modifiers_in_text
except Exception:
return []
detected = _detect_modifiers_in_text(text) or []
out: list[tuple[dict, Optional[tuple[int, int]]]] = []
for mod in detected:
alias = (mod.get("matched_alias") or "").strip()
span = None
if alias:
m = re.search(r"(?<!\w)" + re.escape(alias) + r"(?!\w)",
text, re.IGNORECASE)
if m:
span = (m.start(), m.end())
out.append(({"kind": "modifier_apply", "modifier": mod}, span))
return out
# ── explicit expression / setting (kept separate from descriptive) ──
def _try_parse_expression_explicit(text: str
) -> Optional[tuple[dict, tuple[int, int]]]:
"""Match `expression: X` only. Plain emotion words flow through
residue binning so multi-section prompts ("smiling, sitting") don't
eat the whole text."""
m = re.search(r"\bexpression\s*:\s*(.+?)(?:\.|$)", text, re.IGNORECASE)
if not m:
return None
expr = _normalize(m.group(1))
if not expr:
return None
return ({"kind": "expression_set", "expression": expr}, (m.start(), m.end()))
def _try_parse_setting_explicit(text: str
) -> Optional[tuple[dict, tuple[int, int]]]:
"""Match `setting: X` / `scene: X` / `background: X`. Plain `in a forest`
forms flow through residue binning."""
m = _SETTING_EXPLICIT_RE.search(text)
if not m:
return None
val = _normalize(m.group(1))
if not val:
return None
return ({"kind": "setting_set", "setting": val}, (m.start(), m.end()))
# ── posture verb detection ─────────────────────────────────────────
def _detect_posture_intents(text: str,
bio_display_names: set[str] | None = None,
already_consumed: list[tuple[int, int]] | None = None,
) -> list[tuple[dict, tuple[int, int]]]:
"""Find posture verbs and return pose_descriptive intents. Each
intent carries the clause around the verb (so "sitting with legs
up" stays together) and is flagged is_posture=True so the render
flow knows to override bio pose anchor.
bio_display_names: lowercase character names. Stripped from the
leading edge of the clause to avoid pose prose reading like
"cammy white sitting...". Bio retrieval already handles character
identity — pose prose should describe the action only.
already_consumed: char-spans the upstream layers already claimed
(outfit_swap, outfit_strip, modifier_apply). Posture clause
expansion stops at any consumed-span boundary so "cammy white in
her killer bee outfit sitting with legs up with barefeet" doesn't
yield a pose phrase containing "in her killer bee outfit"."""
names = bio_display_names or set()
consumed = already_consumed or []
out: list[tuple[dict, tuple[int, int]]] = []
for verb in _POSTURE_VERBS:
for m in re.finditer(r"(?<!\w)" + re.escape(verb) + r"(?!\w)",
text, re.IGNORECASE):
# Skip matches inside an already-consumed span.
verb_pos = m.start()
if any(s <= verb_pos < e for s, e in consumed):
continue
clause_start, clause_end = _expand_to_clause_with_consumed(
text, m.start(), m.end(), consumed,
)
phrase = _normalize(text[clause_start:clause_end])
if not phrase:
continue
phrase_lc = phrase.lower()
for name in names:
if name and (phrase_lc.startswith(name + " ") or phrase_lc == name):
phrase = phrase[len(name):].strip(" ,.;:")
phrase_lc = phrase.lower()
# Trim outfit-verb tail. "sitting with legs up only wearing
# red socks" should yield pose phrase "sitting with legs up";
# the strip/fill side already consumed "only wearing red socks"
# via _STRIP_PATTERNS. Without this trim, the whole clause
# echoes into the pose section.
tail_match = re.search(
r"\s+(?:only\s+wearing|only\s+in|wearing|wears|dressed|clad|donning)\b",
phrase, flags=re.IGNORECASE,
)
if tail_match:
phrase = phrase[:tail_match.start()].strip(" ,.;:")
if not phrase:
continue
# Strip dangling prepositions ("sitting with legs up with" →
# "sitting with legs up"). Happens when an upstream consumer
# ate a noun phrase and left a connector preposition behind.
phrase = re.sub(r"\s+(?:with|in|at|on|by|to|from)\s*$",
"", phrase, flags=re.IGNORECASE).strip(" ,.;:")
if not phrase:
continue
out.append((
{"kind": "pose_descriptive",
"phrase": phrase,
"is_posture": True},
(clause_start, clause_end),
))
break
return out
def _expand_to_clause_with_consumed(text: str, start: int, end: int,
consumed: list[tuple[int, int]]
) -> tuple[int, int]:
"""Like _expand_to_clause but also stops at consumed-span boundaries.
Prevents posture clause from absorbing text already claimed by
outfit_swap / strip / modifier_apply."""
BOUNDARY = ",;.!?\n"
s = start
while s > 0 and text[s - 1] not in BOUNDARY:
# Check if we'd cross into a consumed span; stop at its end edge.
candidate = s - 1
if any(c_start <= candidate < c_end for c_start, c_end in consumed):
break
s -= 1
e = end
while e < len(text) and text[e] not in BOUNDARY:
if any(c_start <= e < c_end for c_start, c_end in consumed):
break
e += 1
return s, e
# ── residue binning ────────────────────────────────────────────────
def _residue_from_consumed(text: str,
consumed: list[tuple[int, int]]) -> str:
"""Subtract consumed spans from text. Merges overlapping ranges,
joins surviving slices with single spaces."""
if not consumed:
return text
consumed_sorted = sorted(consumed)
merged: list[tuple[int, int]] = []
for s, e in consumed_sorted:
if merged and s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
parts: list[str] = []
cursor = 0
for s, e in merged:
if s > cursor:
parts.append(text[cursor:s])
cursor = max(cursor, e)
if cursor < len(text):
parts.append(text[cursor:])
return " ".join(p.strip() for p in parts if p.strip())
def _bin_residue_to_intents(residue: str,
bios: list[dict] | None
) -> tuple[list[dict], str]:
"""Split residue into clauses and classify each into pose / expression
/ setting via keyword scan. Emits *_descriptive intents.
Classification priority (per clause):
1. setting keyword -> setting_descriptive
2. expression keyword -> expression_descriptive
3. pose-bin keyword OR posture verb -> pose_descriptive
4. otherwise -> dropped (or kept in leftover)
Character-name mentions and outfit phrasing ("wearing X") are
skipped — they belong to the bio retrieval pipeline, not pose prose."""
if not residue:
return [], ""
bio_display_names = {(b.get("display") or b.get("tag") or "").lower()
for b in (bios or []) if b}
bio_display_names.discard("")
intents: list[dict] = []
leftover_parts: list[str] = []
for clause in _split_clauses(residue):
c = clause.strip(" ,.;:")
if not c:
continue
c_lc = c.lower()
# Drop pure character-name mentions ("cammy white").
if c_lc in bio_display_names:
continue
# Drop pure character-name + nothing else by stripping the leading
# name from the clause.
for name in bio_display_names:
if c_lc.startswith(name + " ") or c_lc.startswith(name + ","):
c = c[len(name):].strip(" ,.;:")
c_lc = c.lower()
if not c:
break
if not c:
continue
# Outfit-bin keywords ("wearing X") — split the clause at the
# outfit verb so the pose prefix survives. "legs up wearing red
# socks" → bin "legs up" as pose, "wearing red socks" goes to
# leftover for the FillSlotDelta extractor downstream.
if any(_word_in(c_lc, kw) for kw in _OUTFIT_BIN_KEYWORDS):
split_match = re.search(
r"\b(wearing|wears|dressed|clad|donning)\b",
c, flags=re.IGNORECASE,
)
if split_match and split_match.start() > 0:
pose_prefix = c[:split_match.start()].strip(" ,.;:")
outfit_tail = c[split_match.start():].strip(" ,.;:")
if pose_prefix:
pose_prefix_lc = pose_prefix.lower()
is_posture_clause = any(_word_in(pose_prefix_lc, v) for v in _POSTURE_VERBS)
if is_posture_clause or any(_word_in(pose_prefix_lc, kw) for kw in _POSE_BIN_KEYWORDS):
intents.append({
"kind": "pose_descriptive",
"phrase": pose_prefix,
"is_posture": is_posture_clause,
})
else:
leftover_parts.append(pose_prefix)
if outfit_tail:
leftover_parts.append(outfit_tail)
continue
leftover_parts.append(c)
continue
# Setting keyword wins (locations imply scene context). Word-
# boundary match — substring match would mis-bin "barefoot"
# (contains "bar") and other modifier aliases as settings.
if any(_word_in(c_lc, kw) for kw in _SETTING_KEYWORDS):
intents.append({"kind": "setting_descriptive", "phrase": c})
continue
# Expression keyword.
if any(_word_in(c_lc, kw) for kw in _EXPRESSION_KEYWORDS):
intents.append({"kind": "expression_descriptive", "phrase": c})
continue
# Pose-bin keyword OR posture verb.
is_posture_clause = any(_word_in(c_lc, v) for v in _POSTURE_VERBS)
if is_posture_clause or any(_word_in(c_lc, kw) for kw in _POSE_BIN_KEYWORDS):
intents.append({
"kind": "pose_descriptive",
"phrase": c,
"is_posture": is_posture_clause,
})
continue
# Unclassified — keep in leftover for logging / future LLM fallback.
leftover_parts.append(c)
return intents, ", ".join(leftover_parts)
def _split_clauses(text: str) -> list[str]:
"""Split on commas, semicolons, ` and `, ` with `, periods. Keeps
sub-clauses small enough to classify cleanly. We don't try to be
grammatical — multiple short clauses bin better than one long one."""
parts = re.split(r"\s*(?:,|;|\band\b|\bwith\b|\.|\?|!)\s*",
text, flags=re.IGNORECASE)
return [p for p in parts if p and p.strip()]
def _word_in(text: str, word: str) -> bool:
return bool(re.search(r"(?<!\w)" + re.escape(word) + r"(?!\w)", text))