472 lines
20 KiB
Python
472 lines
20 KiB
Python
"""Natlang v4 -- identify-then-rewrite render path for natlang patches.
|
|
|
|
Replaces `render_all_sections` for the "non-swap" case: when v2's
|
|
decompose produced no swap-shape deltas (SwapOutfit, SwapCharacter,
|
|
SwapPose, ApplyPoseChip, SwapStyle, ClearX), v2's render rebuilds
|
|
section bodies from state -- which is destructive when the prompt has
|
|
rich modifier prose that state doesn't track (insignia descriptions,
|
|
leg-opening modifiers, chest harness details, etc.).
|
|
|
|
v4 sidesteps state-driven render entirely for that case. Three stages:
|
|
|
|
Stage 0 -- preprocess(node_prompt) -> tagged_lines, section_map:
|
|
Parse `// Section:` headers, split each section body on `\\n`, `.`,
|
|
`,` into atomic clauses. Tag each clause with `[CHAR]` / `[OUTFIT]`
|
|
/ `[POSE]` / `[EXPR]` / `[SETTING]` / `[STYLE]`. Preserve per-clause
|
|
trailing separator so reassemble can restore the original prose
|
|
shape exactly (period/comma/newline boundaries survive).
|
|
|
|
Stage 1 -- split_intents(user_request) -> list[str]:
|
|
LLM call splits compound requests into atomic intents.
|
|
|
|
Stage 2 (per intent) -- identify_clauses + rewrite_clauses:
|
|
LLM call 1: identify which tagged clauses are relevant. Verbatim
|
|
substring constraint -- model returns clauses copied exactly from
|
|
the input list with their [TAG] prefix. Out-of-band sentinels:
|
|
NEW_CONTENT (request adds content with no place to attach it) /
|
|
NONE (no change needed).
|
|
LLM call 2: rewrite each identified clause. Tag prefix preserved
|
|
in output. Empty body after the tag = delete that clause.
|
|
|
|
Stage 3 -- reassemble(edited_tagged_lines, section_map) -> str:
|
|
Strip tags, restore per-clause separators, re-emit `// Section:`
|
|
headers. Sections the model didn't touch are byte-identical to
|
|
input. Non-destructive by algorithm, not by trusting the LLM to
|
|
preserve.
|
|
|
|
This module is invoked from `_run_natlang_v2` after deltas are computed.
|
|
v2's state mutation still runs (FillSlotDelta etc. update PromptState
|
|
for TagBuilder UI). v4 just replaces the body-rendering step.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from typing import Optional
|
|
|
|
|
|
_HEADER_KIND_TO_TAG = {
|
|
"character": "[CHAR]",
|
|
"outfit": "[OUTFIT]",
|
|
"pose": "[POSE]",
|
|
"expression": "[EXPR]",
|
|
"setting": "[SETTING]",
|
|
"scene": "[SETTING]",
|
|
"style": "[STYLE]",
|
|
}
|
|
|
|
_TAG_PREFIX_RE = re.compile(r"^\s*(\[[A-Z]+\])\s+(.*)$")
|
|
# Bare-tag form: just `[OUTFIT]` or `[OUTFIT]\s*` with nothing after.
|
|
# Used by the rewrite step to signal "delete this clause" -- we map it
|
|
# to empty text so the survivors filter in reassemble drops it.
|
|
_BARE_TAG_RE = re.compile(r"^\s*\[[A-Z]+\]\s*$")
|
|
|
|
|
|
# ── Stage 0: preprocess + reassemble ───────────────────────────────
|
|
|
|
|
|
def _section_tag(header: str) -> str:
|
|
m = re.match(r"^\s*//\s*(\w+)", header)
|
|
if not m:
|
|
return "[UNKNOWN]"
|
|
return _HEADER_KIND_TO_TAG.get(m.group(1).lower(), "[UNKNOWN]")
|
|
|
|
|
|
def _split_body_preserve_separators(body: str) -> list[dict]:
|
|
"""Split a section body into clauses while remembering each clause's
|
|
trailing separator (`,`, `.`, `\\n`, or `""` for the last clause).
|
|
Returns list of {text, sep} dicts."""
|
|
if not body or not body.strip():
|
|
return []
|
|
parts = re.split(r"([,.\n])", body)
|
|
out: list[dict] = []
|
|
i = 0
|
|
while i < len(parts):
|
|
text = parts[i].strip() if i < len(parts) else ""
|
|
sep = parts[i + 1] if (i + 1) < len(parts) else ""
|
|
if text:
|
|
out.append({"text": text, "sep": sep})
|
|
i += 2
|
|
return out
|
|
|
|
|
|
def preprocess(prompt: str) -> tuple[list[str], dict]:
|
|
"""Returns (tagged_lines, section_map).
|
|
|
|
Each line in tagged_lines is `[TAG] clause`. section_map carries
|
|
the per-section + per-clause data reassemble() needs."""
|
|
parts = re.split(r"(?m)^(\s*//\s*[^\n]+)$", prompt)
|
|
sections: list[dict] = []
|
|
if parts and parts[0].strip():
|
|
sections.append({"header": "", "body_raw": parts[0]})
|
|
i = 1
|
|
while i < len(parts):
|
|
header = parts[i].strip()
|
|
body = parts[i + 1] if (i + 1) < len(parts) else ""
|
|
sections.append({"header": header, "body_raw": body})
|
|
i += 2
|
|
|
|
tagged_lines: list[str] = []
|
|
for sec_idx, sec in enumerate(sections):
|
|
header = sec["header"]
|
|
tag = _section_tag(header) if header else "[UNKNOWN]"
|
|
sec["tag"] = tag
|
|
body = sec["body_raw"]
|
|
neg_match = re.search(r"^\s*Negative Prompt:\s*$", body, re.MULTILINE)
|
|
body_before_neg = body[:neg_match.start()] if neg_match else body
|
|
neg_block = body[neg_match.start():] if neg_match else ""
|
|
clauses_with_seps = _split_body_preserve_separators(body_before_neg)
|
|
sec["neg_block"] = neg_block.strip()
|
|
sec["clauses"] = clauses_with_seps
|
|
sec["clause_indices"] = []
|
|
for c in clauses_with_seps:
|
|
tagged_lines.append(f"{tag} {c['text']}")
|
|
sec["clause_indices"].append(len(tagged_lines) - 1)
|
|
return tagged_lines, {"sections": sections, "tagged_lines": tagged_lines}
|
|
|
|
|
|
def _strip_tag(line: str) -> str:
|
|
# Bare `[TAG]` (delete signal from rewrite) -> empty string so the
|
|
# reassemble survivors filter drops the clause entirely.
|
|
if _BARE_TAG_RE.match(line):
|
|
return ""
|
|
m = _TAG_PREFIX_RE.match(line)
|
|
return m.group(2).strip() if m else line.strip()
|
|
|
|
|
|
def _join_clauses_with_separators(items: list[dict]) -> str:
|
|
survivors = [it for it in items if it.get("text", "").strip()]
|
|
if not survivors:
|
|
return ""
|
|
pieces: list[str] = []
|
|
for i, it in enumerate(survivors):
|
|
text = it["text"].strip()
|
|
is_last = (i == len(survivors) - 1)
|
|
sep = it.get("sep", "")
|
|
if is_last:
|
|
if sep == ".":
|
|
pieces.append(text + ".")
|
|
else:
|
|
pieces.append(text)
|
|
continue
|
|
if sep == "\n":
|
|
pieces.append(text + "\n")
|
|
elif sep == ".":
|
|
pieces.append(text + ". ")
|
|
elif sep == ",":
|
|
pieces.append(text + ", ")
|
|
else:
|
|
pieces.append(text + " ")
|
|
return "".join(pieces).rstrip()
|
|
|
|
|
|
def reassemble(edited_tagged_lines: list[str], smap: dict) -> str:
|
|
sections = smap["sections"]
|
|
out: list[str] = []
|
|
for sec in sections:
|
|
header = sec["header"]
|
|
clause_indices = sec["clause_indices"]
|
|
sec_clauses = sec.get("clauses") or []
|
|
edited_items: list[dict] = []
|
|
for idx, original in zip(clause_indices, sec_clauses):
|
|
line = edited_tagged_lines[idx]
|
|
new_text = _strip_tag(line)
|
|
edited_items.append({"text": new_text, "sep": original.get("sep", "")})
|
|
body = _join_clauses_with_separators(edited_items)
|
|
neg = sec.get("neg_block") or ""
|
|
if header:
|
|
out.append(header)
|
|
if body.strip():
|
|
out.append(body)
|
|
if neg:
|
|
out.append("")
|
|
out.append(neg)
|
|
out.append("")
|
|
return "\n".join(out).strip()
|
|
|
|
|
|
# ── Stage 1: intent splitter ────────────────────────────────────────
|
|
|
|
SPLIT_SYSTEM = """You split image-prompt edit requests into atomic single-intent requests.
|
|
|
|
An atomic intent is ONE change. "bigger feet and blue eyes" is two intents; "longer hair" is one.
|
|
|
|
Output rules:
|
|
- One atomic intent per line.
|
|
- Drop only conjunctions (and / with / , / +); keep the rest verbatim.
|
|
- No preamble, no markdown.
|
|
- If the request is already atomic, output one line.
|
|
- If empty or no actionable change, output nothing."""
|
|
|
|
|
|
# ── Stage 2: identify + rewrite ────────────────────────────────────
|
|
|
|
IDENTIFY_SYSTEM = """You inspect a list of image-prompt clauses, each prefixed with a section tag, and identify which clauses need to change to satisfy a user's atomic edit request. You do NOT rewrite anything.
|
|
|
|
Section tag meanings:
|
|
[CHAR] -- physical features of the character (body parts, build, hair, eyes, skin)
|
|
[OUTFIT] -- clothing, accessories, footwear
|
|
[POSE] -- body position, gestures, gaze, what body parts are presented
|
|
[EXPR] -- facial affect
|
|
[SETTING] -- environment, location, background, mood
|
|
[STYLE] -- rendering style or aesthetic
|
|
|
|
Routing by request type (use the tags to pick the right clauses):
|
|
- Anatomy modifications (bigger feet, longer hair, broader shoulders) -> [CHAR] clauses ONLY. Do not touch [OUTFIT] clauses just because they mention foot-adjacent items.
|
|
- Clothing add/remove/color -> [OUTFIT] clauses.
|
|
- Body-state modifiers (barefoot, topless, nude) -> [OUTFIT] clauses.
|
|
- Pose / posture changes -> [POSE] clauses.
|
|
- Expression / facial -> [EXPR] clauses.
|
|
- Setting / scene -> [SETTING] clauses.
|
|
- Style -> [STYLE] clauses.
|
|
|
|
Strict output rules:
|
|
- Output the relevant clauses, one per line, each copied VERBATIM from the input INCLUDING the [TAG] prefix.
|
|
- Pick the FEWEST clauses that satisfy the request. Usually ONE.
|
|
- If anatomy mod targets a body part not yet mentioned in any [CHAR] clause, pick the [CHAR] clause describing general physique/build as the extension target.
|
|
- If no clause is relevant and no clause is a reasonable extension target (e.g., request needs a [SETTING] clause but none exist), output exactly: NEW_CONTENT
|
|
- If the request requires no change, output exactly: NONE
|
|
- No preamble, no commentary, no markdown."""
|
|
|
|
|
|
REWRITE_SYSTEM = """You rewrite selected clauses of an image prompt to satisfy a user's atomic edit request.
|
|
|
|
Each input clause begins with a section tag like [CHAR] or [OUTFIT]. You MUST keep the SAME tag in your replacement (the tag identifies which section the replacement belongs to).
|
|
|
|
Strict output rules:
|
|
- Output the replacement clauses under `--- CLAUSE N ---` headers in the same order as inputs.
|
|
- Preserve the original [TAG] prefix at the start of each replacement.
|
|
- Preserve clause-style prose (a short comma-list clause stays comma-list-style; a sentence stays a sentence).
|
|
- Apply only the minimum change the user asked for.
|
|
|
|
Operation cases (in priority order):
|
|
|
|
COLOR / QUALIFIER CHANGE -- when the user's intent is `<color> <item>` or `<adjective> <item>` and the input clause already contains that item with a different color/qualifier:
|
|
REPLACE the color/qualifier IN PLACE. Keep everything else in the clause.
|
|
Examples:
|
|
intent "blue gloves" + clause "[OUTFIT] red gloves" -> "[OUTFIT] blue gloves"
|
|
intent "pink leotard" + clause "[OUTFIT] Wearing green sleeveless thong leotard with insignia"
|
|
-> "[OUTFIT] Wearing pink sleeveless thong leotard with insignia"
|
|
intent "longer hair" + clause "[CHAR] long hair" -> "[CHAR] longer hair"
|
|
Do NOT delete the clause. Do NOT add a duplicate clause.
|
|
|
|
ADDITIVE -- when the user asks to add a feature/item that isn't present in any input clause:
|
|
Extend the clause inline. Examples:
|
|
intent "bigger feet" + clause "[CHAR] toned athletic female body" -> "[CHAR] toned athletic female body with bigger feet"
|
|
intent "red socks" + clause "[OUTFIT] Barefoot" -> "[OUTFIT] Barefoot, red socks"
|
|
|
|
REMOVAL -- when the user asks to REMOVE / DROP / GET RID OF / TAKE OFF an item:
|
|
Output the [TAG] prefix on its own line with NOTHING after it -- e.g., just `[OUTFIT]`. The reassembler drops empty clauses.
|
|
The item to remove may appear inside a compound noun (target=boots strips "combat boots", "calf-high boots"; target=gloves strips "fingerless gloves"). Strip the entire noun phrase including color/size adjectives.
|
|
Only output an empty `[TAG]` when the user EXPLICITLY asked to remove. If the user asked to change a color or replace an item, use COLOR / QUALIFIER CHANGE or ADDITIVE instead.
|
|
|
|
No commentary, no markdown fences."""
|
|
|
|
|
|
def _parse_identify(raw: str, tagged_lines: list[str]) -> tuple[list[str], str]:
|
|
text = (raw or "").strip()
|
|
if not text:
|
|
return [], "parse_fail"
|
|
if text.startswith("```"):
|
|
ls = text.splitlines()
|
|
if ls[0].startswith("```"):
|
|
ls = ls[1:]
|
|
if ls and ls[-1].startswith("```"):
|
|
ls = ls[:-1]
|
|
text = "\n".join(ls).strip()
|
|
if text == "NEW_CONTENT":
|
|
return [], "NEW_CONTENT"
|
|
if text == "NONE":
|
|
return [], "NONE"
|
|
spans = [s.strip() for s in text.splitlines() if s.strip()]
|
|
if not spans:
|
|
return [], "parse_fail"
|
|
line_set = set(tagged_lines)
|
|
if any(s not in line_set for s in spans):
|
|
return spans, "parse_fail"
|
|
return spans, "ok"
|
|
|
|
|
|
def _parse_rewrite(raw: str, n: int) -> tuple[list[str], str]:
|
|
text = (raw or "").strip()
|
|
if not text:
|
|
return [], "parse_fail"
|
|
parts = re.split(r"(?m)^---\s*CLAUSE\s*\d+\s*---\s*$", text)
|
|
parts = [p.strip() for p in parts]
|
|
if parts and not parts[0]:
|
|
parts = parts[1:]
|
|
if len(parts) != n:
|
|
return parts, "count_mismatch"
|
|
return parts, "ok"
|
|
|
|
|
|
async def split_intents(user_request: str, model_compose) -> list[str]:
|
|
"""model_compose: async (system_prompt, user_msg) -> str."""
|
|
raw = await model_compose(SPLIT_SYSTEM, f"User edit request: {user_request}")
|
|
return [s.strip() for s in (raw or "").splitlines() if s.strip()]
|
|
|
|
|
|
# Anatomy classification: size/shape adjective + body part. Used to
|
|
# programmatically constrain identify_clauses to [CHAR]-only when the
|
|
# intent is unambiguously anatomy -- 8B otherwise picks foot-adjacent
|
|
# [OUTFIT] clauses (like "Barefoot") for "bigger feet". This is
|
|
# generic linguistic shape (adjective + noun), not a fixture rule.
|
|
_ANATOMY_ADJECTIVES = (
|
|
"bigger", "larger", "smaller", "tinier", "huge", "huger", "tiny",
|
|
"longer", "shorter",
|
|
"wider", "narrower", "broader",
|
|
"thicker", "thinner", "skinnier",
|
|
"oversized", "enlarged", "massive", "petite",
|
|
)
|
|
_ANATOMY_BODY_PARTS = (
|
|
"feet", "foot", "soles", "toe", "toes",
|
|
"hair", "hairs",
|
|
"hand", "hands", "fingers",
|
|
"shoulder", "shoulders",
|
|
"waist", "hip", "hips",
|
|
"leg", "legs", "thigh", "thighs", "calves",
|
|
"arm", "arms",
|
|
"eye", "eyes", "lip", "lips",
|
|
"breast", "breasts", "chest", "bust",
|
|
"ear", "ears", "neck", "nose", "mouth",
|
|
"butt", "ass", "rear",
|
|
)
|
|
|
|
|
|
def classify_intent_target_tag(intent: str) -> Optional[str]:
|
|
"""If the intent unambiguously targets one section (e.g. anatomy
|
|
modification -> [CHAR]), return that tag. Else return None and let
|
|
the LLM identify freely."""
|
|
if not intent:
|
|
return None
|
|
intent_lc = intent.lower()
|
|
# Word-boundary match so "ear" doesn't match "wear" / "earrings".
|
|
words = set(re.findall(r"\b\w+\b", intent_lc))
|
|
has_adj = bool(words & set(_ANATOMY_ADJECTIVES))
|
|
has_part = bool(words & set(_ANATOMY_BODY_PARTS))
|
|
if has_adj and has_part:
|
|
return "[CHAR]"
|
|
return None
|
|
|
|
|
|
async def identify_clauses(tagged_lines: list[str],
|
|
atomic_request: str,
|
|
model_compose) -> tuple[list[str], str]:
|
|
# Programmatic section constraint: when the intent unambiguously
|
|
# targets one section (e.g. anatomy mod -> [CHAR]), filter the
|
|
# tagged_lines to that section before showing them to the LLM.
|
|
# Prevents 8B from picking foot-adjacent [OUTFIT] clauses like
|
|
# "Barefoot" as the target for "bigger feet" -- the LLM literally
|
|
# can't see [OUTFIT] clauses in that case, only [CHAR] ones.
|
|
target_tag = classify_intent_target_tag(atomic_request)
|
|
scoped_lines = tagged_lines
|
|
if target_tag:
|
|
filtered = [l for l in tagged_lines if l.lstrip().startswith(target_tag)]
|
|
# Fall back to all lines if filter empties the input (no
|
|
# [CHAR] clauses present -- shouldn't happen in practice but
|
|
# don't trap identify in an impossible state).
|
|
if filtered:
|
|
scoped_lines = filtered
|
|
user_msg = (
|
|
f"User atomic request: {atomic_request}\n\n"
|
|
f"Clauses (one per line, prefixed with section tag):\n"
|
|
+ "\n".join(scoped_lines)
|
|
)
|
|
raw = await model_compose(IDENTIFY_SYSTEM, user_msg)
|
|
# Validate against the ORIGINAL tagged_lines (so substitution
|
|
# downstream still works -- _parse_identify checks substring
|
|
# presence in the full list).
|
|
return _parse_identify(raw, tagged_lines)
|
|
|
|
|
|
async def rewrite_clauses(spans: list[str],
|
|
atomic_request: str,
|
|
model_compose) -> tuple[list[str], str]:
|
|
parts = []
|
|
for i, s in enumerate(spans, 1):
|
|
parts.append(f"--- CLAUSE {i} ---\n{s}")
|
|
user_msg = (
|
|
f"User atomic request: {atomic_request}\n\n"
|
|
f"Clauses to rewrite (output replacement under matching header, "
|
|
f"preserving the [TAG] prefix):\n\n"
|
|
+ "\n\n".join(parts)
|
|
)
|
|
raw = await model_compose(REWRITE_SYSTEM, user_msg)
|
|
return _parse_rewrite(raw, len(spans))
|
|
|
|
|
|
async def edit_prompt(prompt: str, user_request: str, model_compose) -> dict:
|
|
"""Full pipeline. Returns {output, intents, edits, statuses}."""
|
|
tagged, smap = preprocess(prompt)
|
|
intents = await split_intents(user_request, model_compose)
|
|
if not intents:
|
|
return {"output": prompt, "intents": [], "edits": [], "statuses": []}
|
|
edited = list(tagged)
|
|
edits: list[dict] = []
|
|
statuses: list[str] = []
|
|
for atomic in intents:
|
|
spans, ident_status = await identify_clauses(edited, atomic, model_compose)
|
|
if ident_status in ("NEW_CONTENT", "NONE"):
|
|
edits.append({"intent": atomic, "spans": [], "replacements": [],
|
|
"status": ident_status})
|
|
statuses.append(ident_status)
|
|
continue
|
|
if ident_status == "parse_fail":
|
|
edits.append({"intent": atomic, "spans": spans, "replacements": [],
|
|
"status": "identify_parse_fail"})
|
|
statuses.append("identify_parse_fail")
|
|
continue
|
|
reps, rew_status = await rewrite_clauses(spans, atomic, model_compose)
|
|
if rew_status != "ok":
|
|
edits.append({"intent": atomic, "spans": spans,
|
|
"replacements": reps, "status": f"rewrite_{rew_status}"})
|
|
statuses.append(f"rewrite_{rew_status}")
|
|
continue
|
|
for orig, new in zip(spans, reps):
|
|
try:
|
|
idx = edited.index(orig)
|
|
edited[idx] = new
|
|
except ValueError:
|
|
pass
|
|
edits.append({"intent": atomic, "spans": spans, "replacements": reps,
|
|
"status": "ok"})
|
|
statuses.append("ok")
|
|
output = reassemble(edited, smap)
|
|
return {"output": output, "intents": intents, "edits": edits,
|
|
"statuses": statuses}
|
|
|
|
|
|
# ── delta classification for the swap-shape gate ───────────────────
|
|
|
|
|
|
def has_swap_shape_delta(deltas: list) -> bool:
|
|
"""Decide whether v2's state-driven render should run or whether
|
|
v4 identify-rewrite should take over.
|
|
|
|
Swap-shape deltas (SwapOutfit/SwapCharacter/SwapPose/ApplyPoseChip/
|
|
SwapStyle/ClearX) carry a KB-anchored verbatim payload that v2's
|
|
render is best at surfacing. Non-swap deltas (FillSlot/ClearSlot/
|
|
PoseChange/SetExpression/SetSetting) are exactly where v2's render
|
|
becomes destructive -- state doesn't track rich modifier prose,
|
|
so the FRUIT recompose drops it. Those cases route to v4 instead.
|
|
|
|
Empty deltas (no delta type produced; common for anatomy mods that
|
|
v2 has no delta type for) also route to v4."""
|
|
if not deltas:
|
|
return False
|
|
from . import natlang_facts as _nlf
|
|
swap_types = (
|
|
_nlf.SwapCharacterDelta,
|
|
_nlf.SwapOutfitDelta,
|
|
_nlf.SwapPoseDelta,
|
|
_nlf.ApplyPoseChipDelta,
|
|
_nlf.SwapStyleDelta,
|
|
_nlf.ApplyModifierDelta, # modifier brings curated substitute_section + clears_slots
|
|
_nlf.StripDelta,
|
|
_nlf.ClearSettingDelta,
|
|
_nlf.ClearExpressionDelta,
|
|
_nlf.ClearPoseDelta,
|
|
_nlf.ClearStyleDelta,
|
|
)
|
|
return any(isinstance(d, swap_types) for d in deltas)
|