10556 lines
456 KiB
Python
10556 lines
456 KiB
Python
"""
|
||
AI provider layer — Claude + OpenAI-compatible (Ollama, llama.cpp, LM Studio...).
|
||
|
||
Config lives in {user_dir}/PromptChain/ai_config.json and is read server-side
|
||
only. API keys are never returned to the client — the config GET returns a
|
||
sanitized shape with has_key flags in place of the raw key.
|
||
|
||
Streaming: generate spawns an asyncio task keyed by request_id. Tokens are
|
||
sent to the client via the existing send_ws broadcast channel on the
|
||
`promptchain_ai_stream` event. Cancel looks up the task and cancels it.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
import aiohttp
|
||
from aiohttp import web
|
||
import folder_paths
|
||
import server
|
||
|
||
from .api_utils import atomic_write_json, error_response, parse_json
|
||
from . import model_settings
|
||
from .shared import send_ws
|
||
from .tags import get_store as get_tag_store
|
||
|
||
logger = logging.getLogger("promptchain.ai")
|
||
# Dedicated debug channel — dumps full system prompt, user message, body
|
||
# buffer, STYLE parsing, splice result. Enabled by default so we can trace
|
||
# why a given model went off-script. Mute via logging config to silence.
|
||
dbg = logging.getLogger("promptchain.ai.debug")
|
||
if dbg.level == logging.NOTSET:
|
||
dbg.setLevel(logging.INFO)
|
||
routes = server.PromptServer.instance.routes
|
||
|
||
|
||
def _safe_for_log(text: str) -> str:
|
||
"""ComfyUI's default log stream on Windows is cp1252 — characters
|
||
like `→`, emoji, and curly quotes raise UnicodeEncodeError and the
|
||
whole log record gets dropped. Round-trip via cp1252 with
|
||
`errors='replace'` so unencodable code points become `?` instead of
|
||
losing the dump entirely. No-op when the underlying stream is utf-8."""
|
||
if not text:
|
||
return text or ""
|
||
try:
|
||
return text.encode("cp1252", "replace").decode("cp1252")
|
||
except Exception:
|
||
return text.encode("ascii", "replace").decode("ascii")
|
||
|
||
|
||
def _trunc(text: str, limit: int = 6000) -> str:
|
||
if not text:
|
||
return ""
|
||
safe = _safe_for_log(text)
|
||
if len(safe) <= limit:
|
||
return safe
|
||
return safe[:limit] + f"\n...[+{len(safe) - limit} chars truncated]"
|
||
|
||
|
||
def _dump(request_id: str, label: str, body: str):
|
||
safe_label = _safe_for_log(label)
|
||
dbg.info("ai[%s] %s (%d chars):\n%s\n--- /%s ---",
|
||
request_id, safe_label, len(body or ""), _trunc(body or ""), safe_label)
|
||
|
||
# Model-agnostic safety limits for the aiohttp-side call. Providers
|
||
# enforce their own limits on top.
|
||
_CONNECT_TIMEOUT = 10
|
||
_READ_TIMEOUT = 300 # 5 min — vision + reasoning can stretch
|
||
# Generous cap for reasoning models (Qwen3, DeepSeek-R1, etc.) that burn
|
||
# tokens on <think>...</think> before any visible output. With our
|
||
# typical ~1K user_chars of directives + bios, reasoning alone can run
|
||
# 3-5K tokens. max_tokens is a runaway cap, not a target — models stop
|
||
# when done, so erring high costs nothing.
|
||
_MAX_TOKENS = 16384
|
||
|
||
# The local model onboarding offers to pull and the AI assistant's
|
||
# prompt-engineering is tuned around. Single source of truth — the rest of
|
||
# this file references the tag in comments only.
|
||
RECOMMENDED_LOCAL_MODEL = "qwen3-vl:8b-instruct"
|
||
_DEFAULT_OLLAMA_ROOT = "http://localhost:11434"
|
||
|
||
|
||
# ── cloud service registry ────────────────────────────────────────
|
||
# Labels/help links are on the frontend; the backend only needs the
|
||
# routing info (base_url and whether it speaks Anthropic vs OpenAI shape).
|
||
|
||
_CLOUD_SERVICES: dict[str, dict] = {
|
||
"claude": {"base_url": None, "shape": "anthropic"},
|
||
"openai": {"base_url": "https://api.openai.com/v1", "shape": "openai"},
|
||
"grok": {"base_url": "https://api.x.ai/v1", "shape": "openai"},
|
||
"gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "shape": "openai"},
|
||
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "shape": "openai"},
|
||
"deepseek": {"base_url": "https://api.deepseek.com/v1", "shape": "openai"},
|
||
"groq": {"base_url": "https://api.groq.com/openai/v1", "shape": "openai"},
|
||
"mistral": {"base_url": "https://api.mistral.ai/v1", "shape": "openai"},
|
||
"other": {"base_url": None, "shape": "openai"},
|
||
}
|
||
|
||
|
||
def _cloud_base_url(cloud: dict) -> str:
|
||
"""Return the effective base URL for a cloud service (registry entry
|
||
or user-supplied 'other')."""
|
||
service = cloud.get("service") or "claude"
|
||
spec = _CLOUD_SERVICES.get(service)
|
||
if not spec:
|
||
return ""
|
||
if service == "other":
|
||
return (cloud.get("base_url") or "").strip().rstrip("/")
|
||
return spec["base_url"] or ""
|
||
|
||
|
||
# ── config storage ────────────────────────────────────────────────
|
||
|
||
def _config_path() -> Path:
|
||
return Path(folder_paths.get_user_directory()) / "PromptChain" / "ai_config.json"
|
||
|
||
|
||
def _load_config() -> dict:
|
||
path = _config_path()
|
||
if not path.exists():
|
||
return {}
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return _migrate_config(json.load(f))
|
||
except Exception as e:
|
||
logger.warning("ai_config parse failed: %s", e)
|
||
return {}
|
||
|
||
|
||
def _migrate_config(cfg: dict) -> dict:
|
||
"""v1 shape was `{provider: 'claude', claude: {api_key, model}}`. v2
|
||
folds claude into the cloud-services object. Migrate lazily on load
|
||
so existing users don't re-enter their key."""
|
||
if not isinstance(cfg, dict):
|
||
return {}
|
||
if cfg.get("provider") == "claude":
|
||
claude = cfg.pop("claude", None) or {}
|
||
cfg["provider"] = "cloud"
|
||
cfg["cloud"] = {
|
||
"service": "claude",
|
||
"api_key": claude.get("api_key"),
|
||
"model": claude.get("model") or "claude-haiku-4-5",
|
||
}
|
||
return cfg
|
||
|
||
|
||
def _save_config(config: dict):
|
||
path = _config_path()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
atomic_write_json(path, config)
|
||
|
||
|
||
def _sanitize(config: dict) -> dict:
|
||
"""Strip secrets before sending to the browser."""
|
||
cloud = config.get("cloud") or {}
|
||
local = config.get("local") or {}
|
||
return {
|
||
"provider": config.get("provider") or None,
|
||
"cloud": {
|
||
"service": cloud.get("service") or "claude",
|
||
"model": cloud.get("model") or "",
|
||
"base_url": cloud.get("base_url") or "",
|
||
"has_key": bool(cloud.get("api_key")),
|
||
},
|
||
"local": {
|
||
"base_url": local.get("base_url") or "",
|
||
"model": local.get("model") or "",
|
||
"auto_start": _local_auto_start(local),
|
||
},
|
||
}
|
||
|
||
|
||
def _local_auto_start(local: dict) -> bool:
|
||
"""Default ON — matches the shipped behavior; missing key in legacy
|
||
configs reads as enabled."""
|
||
v = local.get("auto_start")
|
||
return True if v is None else bool(v)
|
||
|
||
|
||
# ── config endpoints ──────────────────────────────────────────────
|
||
|
||
@routes.get("/promptchain/ai/config")
|
||
async def _api_get_config(request):
|
||
return web.json_response(_sanitize(_load_config()))
|
||
|
||
|
||
@routes.post("/promptchain/ai/config")
|
||
async def _api_set_config(request):
|
||
body, err = await parse_json(request)
|
||
if err:
|
||
return err
|
||
|
||
existing = _load_config()
|
||
provider = body.get("provider")
|
||
if provider not in (None, "", "cloud", "local"):
|
||
return error_response("invalid provider")
|
||
|
||
merged = dict(existing)
|
||
merged["provider"] = provider or None
|
||
# Records that the choice came from the user (even when it's None), so the
|
||
# first-run Ollama auto-default never overrides a deliberate selection.
|
||
merged["user_set"] = True
|
||
|
||
# Cloud: preserve api_key when a new one isn't supplied (same pattern
|
||
# as the CivitAI key — blank means "keep", empty-string-with-the-key
|
||
# key present means "clear").
|
||
if "cloud" in body:
|
||
c_in = body["cloud"] or {}
|
||
c_cur = dict(existing.get("cloud") or {})
|
||
if "service" in c_in:
|
||
svc = (c_in.get("service") or "").strip().lower()
|
||
c_cur["service"] = svc if svc in _CLOUD_SERVICES else "claude"
|
||
if "model" in c_in:
|
||
c_cur["model"] = (c_in.get("model") or "").strip()
|
||
if "base_url" in c_in:
|
||
c_cur["base_url"] = (c_in.get("base_url") or "").strip().rstrip("/")
|
||
if "api_key" in c_in:
|
||
key = (c_in.get("api_key") or "").strip()
|
||
if key:
|
||
c_cur["api_key"] = key
|
||
else:
|
||
c_cur.pop("api_key", None)
|
||
merged["cloud"] = c_cur
|
||
|
||
if "local" in body:
|
||
l_in = body["local"] or {}
|
||
l_cur = dict(existing.get("local") or {})
|
||
if "base_url" in l_in:
|
||
l_cur["base_url"] = (l_in.get("base_url") or "").strip().rstrip("/")
|
||
if "model" in l_in:
|
||
l_cur["model"] = (l_in.get("model") or "").strip()
|
||
if "auto_start" in l_in:
|
||
l_cur["auto_start"] = bool(l_in.get("auto_start"))
|
||
merged["local"] = l_cur
|
||
|
||
_save_config(merged)
|
||
return web.json_response(_sanitize(merged))
|
||
|
||
|
||
# ── connection tests / model detection ────────────────────────────
|
||
|
||
@routes.post("/promptchain/ai/test")
|
||
async def _api_test(request):
|
||
"""Validate a provider config. Body values take precedence over stored."""
|
||
body, err = await parse_json(request)
|
||
if err:
|
||
return err
|
||
|
||
stored = _load_config()
|
||
provider = body.get("provider") or stored.get("provider")
|
||
if provider == "cloud":
|
||
cloud = {**(stored.get("cloud") or {}), **(body.get("cloud") or {})}
|
||
service = cloud.get("service") or "claude"
|
||
api_key = (cloud.get("api_key") or "").strip()
|
||
if not api_key:
|
||
return web.json_response({"ok": False, "error": "API key missing"})
|
||
if service == "claude":
|
||
model = cloud.get("model") or "claude-haiku-4-5"
|
||
return web.json_response(await _test_claude(api_key, model))
|
||
base_url = _cloud_base_url(cloud)
|
||
if not base_url:
|
||
return web.json_response({"ok": False, "error": "Base URL missing"})
|
||
return web.json_response(await _test_openai_compat(base_url, api_key))
|
||
|
||
if provider == "local":
|
||
local = {**(stored.get("local") or {}), **(body.get("local") or {})}
|
||
base_url = (local.get("base_url") or "").strip().rstrip("/")
|
||
if not base_url:
|
||
return web.json_response({"ok": False, "error": "Base URL missing"})
|
||
return web.json_response(await _test_local(base_url))
|
||
|
||
return web.json_response({"ok": False, "error": "No provider selected"})
|
||
|
||
|
||
def _ollama_exe() -> str | None:
|
||
"""Locate the ollama binary. shutil.which resolves PATH as it was when
|
||
ComfyUI booted — a freshly-installed Ollama won't be on that PATH until
|
||
restart, so we also probe the per-OS default install location."""
|
||
exe = shutil.which("ollama")
|
||
if exe:
|
||
return exe
|
||
candidates = []
|
||
if sys.platform == "win32":
|
||
local = os.environ.get("LOCALAPPDATA")
|
||
if local:
|
||
candidates.append(Path(local) / "Programs" / "Ollama" / "ollama.exe")
|
||
elif sys.platform == "darwin":
|
||
candidates += [Path("/usr/local/bin/ollama"),
|
||
Path("/opt/homebrew/bin/ollama"),
|
||
Path("/Applications/Ollama.app/Contents/Resources/ollama")]
|
||
else:
|
||
candidates += [Path("/usr/local/bin/ollama"), Path("/usr/bin/ollama")]
|
||
for c in candidates:
|
||
if c.exists():
|
||
return str(c)
|
||
return None
|
||
|
||
|
||
def _winget_exe() -> str | None:
|
||
"""winget ships with App Installer on Win10 1809+/Win11 but isn't
|
||
guaranteed (LTSC, stripped images, older builds). None == fall back to
|
||
the manual download link."""
|
||
if sys.platform != "win32":
|
||
return None
|
||
return shutil.which("winget")
|
||
|
||
|
||
def _spawn_ollama(exe: str) -> None:
|
||
"""Launch `ollama serve` fully detached so it outlives the ComfyUI
|
||
process and doesn't inherit its console."""
|
||
if sys.platform == "win32":
|
||
DETACHED = 0x00000008 # DETACHED_PROCESS
|
||
NEW_GROUP = 0x00000200 # CREATE_NEW_PROCESS_GROUP
|
||
subprocess.Popen(
|
||
[exe, "serve"],
|
||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||
creationflags=DETACHED | NEW_GROUP, close_fds=True,
|
||
)
|
||
else:
|
||
subprocess.Popen(
|
||
[exe, "serve"],
|
||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||
start_new_session=True, close_fds=True,
|
||
)
|
||
|
||
|
||
async def _ensure_ollama_running(ollama_root: str, exe: str | None) -> bool:
|
||
"""Return True if Ollama answers on its root URL, spawning it once if
|
||
a binary is available and it isn't already up."""
|
||
if (await _test_local(f"{ollama_root}/v1")).get("ok") or await _is_ollama(ollama_root):
|
||
return True
|
||
if not exe:
|
||
return False
|
||
try:
|
||
_spawn_ollama(exe)
|
||
except Exception:
|
||
return False
|
||
for _ in range(12):
|
||
await asyncio.sleep(0.5)
|
||
if await _is_ollama(ollama_root):
|
||
return True
|
||
return False
|
||
|
||
|
||
@routes.post("/promptchain/ai/wake-local")
|
||
async def _api_wake_local(request):
|
||
"""Try to start Ollama when the configured local provider isn't
|
||
answering. Windows installs put Ollama in PATH and auto-start on
|
||
login by default, so the typical hit-rate case is 'user killed it
|
||
via Task Manager' — `ollama serve` brings it back in <1s.
|
||
|
||
No-op for non-local provider configs. Returns:
|
||
- {ok: True, already_running: True} — was already up
|
||
- {ok: True, started: True} — we spawned it; port responded
|
||
- {ok: False, error: "..."} — not in PATH, or didn't come up
|
||
"""
|
||
cfg = _load_config()
|
||
if cfg.get("provider") != "local":
|
||
return web.json_response({"ok": False, "error": "Local provider not configured"})
|
||
|
||
local = cfg.get("local") or {}
|
||
base_url = (local.get("base_url") or "").strip().rstrip("/")
|
||
if not base_url:
|
||
return web.json_response({"ok": False, "error": "No local base_url configured"})
|
||
if not _local_auto_start(local):
|
||
return web.json_response({"ok": False, "error": "Auto-start disabled in settings."})
|
||
|
||
# Already up? Skip the spawn — avoids creating zombie processes when
|
||
# the panel re-opens during a working session.
|
||
if (await _test_local(base_url)).get("ok"):
|
||
return web.json_response({"ok": True, "already_running": True})
|
||
|
||
exe = _ollama_exe()
|
||
if not exe:
|
||
return web.json_response({
|
||
"ok": False,
|
||
"error": "Ollama not found in PATH. Install from https://ollama.com/download.",
|
||
})
|
||
|
||
try:
|
||
_spawn_ollama(exe)
|
||
except Exception as e:
|
||
return web.json_response({"ok": False, "error": f"Failed to spawn ollama: {e}"})
|
||
|
||
# Poll for up to ~6s — cold first-launch on Windows is usually <2s,
|
||
# but the desktop app stack sometimes takes longer on first boot.
|
||
for _ in range(12):
|
||
await asyncio.sleep(0.5)
|
||
if (await _test_local(base_url)).get("ok"):
|
||
return web.json_response({"ok": True, "started": True})
|
||
|
||
return web.json_response({
|
||
"ok": False,
|
||
"error": "Spawned ollama but it didn't answer on the configured port.",
|
||
})
|
||
|
||
|
||
def _configured_ollama_root() -> str:
|
||
"""Ollama root the setup flow targets: the user's saved local base_url
|
||
if they have one, else the default port. Independent of which provider
|
||
is currently active so onboarding can probe before anything's saved."""
|
||
local = (_load_config().get("local") or {})
|
||
base_url = (local.get("base_url") or "").strip().rstrip("/")
|
||
return _ollama_root(base_url) if base_url else _DEFAULT_OLLAMA_ROOT
|
||
|
||
|
||
async def _list_ollama_model_names(ollama_root: str) -> list[str]:
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=8)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.get(f"{ollama_root}/api/tags") as resp:
|
||
if resp.status != 200:
|
||
return []
|
||
data = await resp.json()
|
||
return [m.get("name") or m.get("model") for m in (data.get("models") or [])
|
||
if (m.get("name") or m.get("model"))]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _model_in_list(model: str, names: list[str]) -> bool:
|
||
"""Ollama appends `:latest` when a name carries no tag. A tagged request
|
||
(qwen3-vl:8b-instruct) must match exactly; an untagged one matches any
|
||
installed tag of the same base model."""
|
||
if ":" not in model:
|
||
return any(n.split(":")[0] == model for n in names)
|
||
return model in names or f"{model}:latest" in names
|
||
|
||
|
||
# Well-known local LLM server ports, by product convention. We can only
|
||
# *detect* these (and use them over the OpenAI-compat path) — unlike Ollama
|
||
# we can't start, install, or pull models for them, so they're surfaced as
|
||
# "already running? use it" rather than a guided setup.
|
||
_LOCAL_PROBE_TARGETS = [
|
||
(11434, "Ollama"),
|
||
(1234, "LM Studio"),
|
||
(8080, "llama.cpp"),
|
||
(5001, "KoboldCpp"),
|
||
(1337, "Jan"),
|
||
(8000, "vLLM / LocalAI"),
|
||
]
|
||
|
||
|
||
async def _probe_local_servers() -> list[dict]:
|
||
"""Fan out a fast GET to each well-known port and report whichever answer
|
||
an Ollama /api/tags or OpenAI-compat /v1/models. Refused ports fail
|
||
instantly; the short timeout only bounds a port that accepts but hangs."""
|
||
async def probe(port: int, label: str) -> dict | None:
|
||
root = f"http://localhost:{port}"
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=1.0)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
try:
|
||
async with session.get(f"{root}/api/tags") as r:
|
||
if r.status == 200:
|
||
return {"port": port, "kind": "ollama", "label": "Ollama",
|
||
"base_url": f"{root}/v1"}
|
||
except Exception:
|
||
pass
|
||
async with session.get(f"{root}/v1/models") as r:
|
||
if r.status == 200:
|
||
return {"port": port, "kind": "openai", "label": label,
|
||
"base_url": f"{root}/v1"}
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
found = await asyncio.gather(*[probe(p, l) for p, l in _LOCAL_PROBE_TARGETS])
|
||
return [f for f in found if f]
|
||
|
||
|
||
@routes.get("/promptchain/ai/setup-status")
|
||
async def _api_setup_status(request):
|
||
"""One-shot snapshot for the onboarding/panel AI-setup UI. Triggered on
|
||
open (event-driven) — never polled."""
|
||
ollama_root = _configured_ollama_root()
|
||
running, detected = await asyncio.gather(
|
||
_is_ollama(ollama_root),
|
||
_probe_local_servers(),
|
||
)
|
||
names = await _list_ollama_model_names(ollama_root) if running else []
|
||
return web.json_response({
|
||
"platform": sys.platform,
|
||
"recommended_model": RECOMMENDED_LOCAL_MODEL,
|
||
"ollama_installed": _ollama_exe() is not None,
|
||
"ollama_running": running,
|
||
"model_present": _model_in_list(RECOMMENDED_LOCAL_MODEL, names),
|
||
"winget_available": _winget_exe() is not None,
|
||
"installed_models": names,
|
||
# Non-Ollama OpenAI-compatible servers we found listening locally.
|
||
"detected_servers": [d for d in detected if d["kind"] != "ollama"],
|
||
})
|
||
|
||
|
||
@routes.post("/promptchain/ai/auto-configure")
|
||
async def _api_auto_configure(request):
|
||
"""First-run default: if the user hasn't configured a provider yet and
|
||
Ollama is already serving the recommended model, select Local so the
|
||
assistant works with zero setup. Skips once a provider is set or the user
|
||
has made any explicit choice (`user_set`) so None is never overridden."""
|
||
cfg = _load_config()
|
||
if cfg.get("provider") or cfg.get("user_set"):
|
||
return web.json_response({"configured": False, "reason": "already chosen"})
|
||
ollama_root = _configured_ollama_root()
|
||
if not await _is_ollama(ollama_root):
|
||
return web.json_response({"configured": False, "reason": "ollama offline"})
|
||
names = await _list_ollama_model_names(ollama_root)
|
||
if not _model_in_list(RECOMMENDED_LOCAL_MODEL, names):
|
||
return web.json_response({"configured": False, "reason": "recommended model missing"})
|
||
_save_config({
|
||
"provider": "local",
|
||
"local": {"base_url": f"{ollama_root}/v1", "model": RECOMMENDED_LOCAL_MODEL},
|
||
})
|
||
return web.json_response({"configured": True, "provider": "local",
|
||
"model": RECOMMENDED_LOCAL_MODEL})
|
||
|
||
|
||
async def _open_sse(request) -> web.StreamResponse:
|
||
resp = web.StreamResponse(headers={
|
||
"Content-Type": "text/event-stream",
|
||
"Cache-Control": "no-cache",
|
||
"X-Accel-Buffering": "no",
|
||
})
|
||
await resp.prepare(request)
|
||
return resp
|
||
|
||
|
||
async def _sse_send(resp: web.StreamResponse, obj: dict) -> None:
|
||
await resp.write(b"data: " + json.dumps(obj).encode("utf-8") + b"\n\n")
|
||
|
||
|
||
@routes.post("/promptchain/ai/pull-model")
|
||
async def _api_pull_model(request):
|
||
"""Proxy Ollama's /api/pull progress stream to the browser as SSE so
|
||
the setup UI can render a live progress bar. Spawns Ollama first if a
|
||
binary exists but the server is down."""
|
||
body, err = await parse_json(request)
|
||
if err:
|
||
return err
|
||
model = (body.get("model") or RECOMMENDED_LOCAL_MODEL).strip()
|
||
ollama_root = _configured_ollama_root()
|
||
|
||
if not await _ensure_ollama_running(ollama_root, _ollama_exe()):
|
||
return web.json_response(
|
||
{"error": "Ollama isn't running and couldn't be started. Install it first."},
|
||
status=409)
|
||
|
||
resp = await _open_sse(request)
|
||
try:
|
||
# No total timeout — a multi-GB pull legitimately runs for minutes.
|
||
timeout = aiohttp.ClientTimeout(total=None, sock_read=120)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.post(f"{ollama_root}/api/pull",
|
||
json={"name": model, "stream": True}) as upstream:
|
||
if upstream.status != 200:
|
||
detail = (await upstream.text())[:200]
|
||
await _sse_send(resp, {"error": f"HTTP {upstream.status}: {detail}"})
|
||
return resp
|
||
async for raw in upstream.content:
|
||
line = raw.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
evt = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
await _sse_send(resp, evt)
|
||
await _sse_send(resp, {"done": True})
|
||
except ConnectionResetError:
|
||
pass # client navigated away mid-pull; Ollama keeps the download
|
||
except Exception as e:
|
||
logger.warning("pull-model stream failed", exc_info=True)
|
||
try:
|
||
await _sse_send(resp, {"error": str(e)})
|
||
except Exception:
|
||
pass
|
||
return resp
|
||
|
||
|
||
@routes.post("/promptchain/ai/install-ollama")
|
||
async def _api_install_ollama(request):
|
||
"""Install Ollama via winget (Windows only) and stream its output as
|
||
SSE. Every other platform falls back to the manual download link in the
|
||
UI — we don't attempt unattended installs there (macOS is a .app drag,
|
||
Linux needs sudo for the systemd service)."""
|
||
winget = _winget_exe()
|
||
if not winget:
|
||
return web.json_response(
|
||
{"error": "Automatic install needs winget (Windows). "
|
||
"Download Ollama from https://ollama.com/download."},
|
||
status=400)
|
||
|
||
resp = await _open_sse(request)
|
||
try:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
winget, "install", "--id", "Ollama.Ollama", "-e",
|
||
"--silent", "--accept-source-agreements", "--accept-package-agreements",
|
||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||
stdin=asyncio.subprocess.DEVNULL,
|
||
)
|
||
assert proc.stdout is not None
|
||
async for raw in proc.stdout:
|
||
text = raw.decode("utf-8", "replace").rstrip()
|
||
if text:
|
||
await _sse_send(resp, {"line": text})
|
||
code = await proc.wait()
|
||
# winget exits 0 on success. A fresh install isn't on the running
|
||
# ComfyUI's PATH, but _ollama_exe() probes the install dir directly
|
||
# so setup-status will see it without a restart.
|
||
await _sse_send(resp, {"done": True, "code": code,
|
||
"ok": code == 0, "installed": _ollama_exe() is not None})
|
||
except ConnectionResetError:
|
||
pass
|
||
except Exception as e:
|
||
logger.warning("install-ollama failed", exc_info=True)
|
||
try:
|
||
await _sse_send(resp, {"error": str(e)})
|
||
except Exception:
|
||
pass
|
||
return resp
|
||
|
||
|
||
async def _test_claude(api_key: str, model: str) -> dict:
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=15)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.post(
|
||
"https://api.anthropic.com/v1/messages",
|
||
headers={
|
||
"x-api-key": api_key,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json",
|
||
},
|
||
json={
|
||
"model": model,
|
||
"max_tokens": 8,
|
||
"messages": [{"role": "user", "content": "ping"}],
|
||
},
|
||
) as resp:
|
||
if resp.status == 200:
|
||
return {"ok": True}
|
||
data = await resp.json()
|
||
msg = (data.get("error") or {}).get("message") or f"HTTP {resp.status}"
|
||
return {"ok": False, "error": msg}
|
||
except Exception as e:
|
||
return {"ok": False, "error": f"Connection failed: {e}"}
|
||
|
||
|
||
async def _test_local(base_url: str) -> dict:
|
||
# Prefer /v1/models (standard OpenAI shape). Fall back to Ollama's
|
||
# /api/tags when /v1/models is missing — that's the telltale sign of
|
||
# raw Ollama running without the /v1 shim.
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=10)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
try:
|
||
async with session.get(f"{base_url}/models") as resp:
|
||
if resp.status == 200:
|
||
return {"ok": True}
|
||
except Exception:
|
||
pass
|
||
ollama = _ollama_root(base_url)
|
||
async with session.get(f"{ollama}/api/tags") as resp:
|
||
if resp.status == 200:
|
||
return {"ok": True}
|
||
return {"ok": False, "error": f"HTTP {resp.status}"}
|
||
except Exception as e:
|
||
return {"ok": False, "error": f"Connection failed: {e}"}
|
||
|
||
|
||
async def _test_openai_compat(base_url: str, api_key: str) -> dict:
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=15)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
headers = {"Authorization": f"Bearer {api_key}"}
|
||
async with session.get(f"{base_url}/models", headers=headers) as resp:
|
||
if resp.status == 200:
|
||
return {"ok": True}
|
||
text = await resp.text()
|
||
return {"ok": False, "error": f"HTTP {resp.status}: {text[:160]}"}
|
||
except Exception as e:
|
||
return {"ok": False, "error": f"Connection failed: {e}"}
|
||
|
||
|
||
def _ollama_root(base_url: str) -> str:
|
||
"""Derive Ollama root (http://host:11434) from a config base_url that
|
||
typically ends in /v1. If the user's URL doesn't end in /v1 we return
|
||
it unchanged — Ollama's native endpoints are siblings of /v1 so this
|
||
only helps when the user configured the OpenAI-compat path."""
|
||
if base_url.endswith("/v1"):
|
||
return base_url[:-3]
|
||
return base_url
|
||
|
||
|
||
@routes.post("/promptchain/ai/detect-models")
|
||
async def _api_detect_models(request):
|
||
"""Try to enumerate models at a base URL. Works for Ollama (native
|
||
/api/tags) and any server exposing /v1/models (OpenAI-compat)."""
|
||
body, err = await parse_json(request)
|
||
if err:
|
||
return err
|
||
base_url = (body.get("base_url") or "").strip().rstrip("/")
|
||
if not base_url:
|
||
return web.json_response({"models": [], "source": "none"})
|
||
# Optional bearer token for cloud providers (OpenAI, Grok, ...).
|
||
# Local servers don't expect auth; absent key = unauthenticated GET.
|
||
api_key = (body.get("api_key") or "").strip()
|
||
auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||
|
||
timeout = aiohttp.ClientTimeout(total=10)
|
||
try:
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
# Ollama native first — richer (capability flags).
|
||
ollama_root = _ollama_root(base_url)
|
||
try:
|
||
async with session.get(f"{ollama_root}/api/tags") as resp:
|
||
if resp.status == 200:
|
||
data = await resp.json()
|
||
models = []
|
||
for m in (data.get("models") or []):
|
||
name = m.get("name") or m.get("model")
|
||
if name:
|
||
models.append({"name": name})
|
||
if models:
|
||
# Second pass to flag vision-capable models.
|
||
models = await _flag_ollama_vision(session, ollama_root, models)
|
||
return web.json_response({"models": models, "source": "ollama"})
|
||
except Exception:
|
||
pass
|
||
|
||
# Generic OpenAI-compat fallback.
|
||
try:
|
||
async with session.get(f"{base_url}/models", headers=auth_headers) as resp:
|
||
if resp.status == 200:
|
||
data = await resp.json()
|
||
items = data.get("data") or data.get("models") or []
|
||
models = []
|
||
for m in items:
|
||
if isinstance(m, dict):
|
||
name = m.get("id") or m.get("name")
|
||
else:
|
||
name = str(m)
|
||
if name:
|
||
models.append({"name": name})
|
||
return web.json_response({"models": models, "source": "openai"})
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
return web.json_response({"models": [], "error": str(e)})
|
||
|
||
return web.json_response({"models": []})
|
||
|
||
|
||
async def _flag_ollama_vision(session, ollama_root: str, models: list[dict]) -> list[dict]:
|
||
"""Probe each model's capabilities via /api/show. Best-effort — on any
|
||
failure we leave vision unset (UI treats that as unknown/permissive)."""
|
||
async def probe(m):
|
||
try:
|
||
async with session.post(f"{ollama_root}/api/show", json={"name": m["name"]}) as resp:
|
||
if resp.status == 200:
|
||
data = await resp.json()
|
||
caps = data.get("capabilities") or []
|
||
if "vision" in caps:
|
||
m["vision"] = True
|
||
except Exception:
|
||
pass
|
||
return m
|
||
|
||
return await asyncio.gather(*[probe(m) for m in models])
|
||
|
||
|
||
# ── active request tracking ───────────────────────────────────────
|
||
|
||
_active_requests: dict[str, asyncio.Task] = {}
|
||
# Per-request reasoning-char counter set by streaming endpoints when the
|
||
# upstream surfaces a `reasoning`/`reasoning_content` delta. Lets callers
|
||
# distinguish a genuinely empty response from the "thought hard then
|
||
# emitted nothing" failure mode common to abliterated/thinking-mode
|
||
# variants — those return empty body with non-empty reasoning, and
|
||
# benefit from a retry with an output-forcing nudge.
|
||
_request_reasoning_chars: dict[str, int] = {}
|
||
|
||
|
||
def _cleanup_request(request_id: str):
|
||
_active_requests.pop(request_id, None)
|
||
_request_reasoning_chars.pop(request_id, None)
|
||
|
||
|
||
@routes.post("/promptchain/ai/cancel")
|
||
async def _api_cancel(request):
|
||
body, err = await parse_json(request)
|
||
if err:
|
||
return err
|
||
request_id = body.get("request_id") or ""
|
||
task = _active_requests.get(request_id)
|
||
if task and not task.done():
|
||
task.cancel()
|
||
return web.json_response({"status": "ok"})
|
||
|
||
|
||
# ── grounding block assembly ──────────────────────────────────────
|
||
|
||
def _build_grounding(model_hash: str) -> dict:
|
||
cfg = model_settings.load(model_hash) if model_hash else None
|
||
if not cfg:
|
||
return {}
|
||
keys = (
|
||
"display_name", "model_name", "version", "architecture", "family",
|
||
"author", "description", "tags", "url", "trigger",
|
||
"quality_position", "negative", "notes",
|
||
"tag_sources", "tag_format", "prompt_style",
|
||
"default_prompt_id",
|
||
)
|
||
return {k: cfg.get(k) for k in keys if cfg.get(k) is not None}
|
||
|
||
|
||
def _list_arch_prompts(arch: str | None) -> list[dict]:
|
||
if not arch:
|
||
return []
|
||
from . import prompts as _prompts
|
||
try:
|
||
return _prompts.list_prompts(architecture=arch)
|
||
except Exception:
|
||
logger.debug("failed to load prompts for arch %s", arch, exc_info=True)
|
||
return []
|
||
|
||
|
||
def _style_full_name(p: dict) -> str:
|
||
return f"{p.get('category', 'Misc')} > {p.get('name', 'Unnamed')}"
|
||
|
||
|
||
def _find_prompt_by_full_name(prompts_list: list[dict], full_name: str) -> dict | None:
|
||
target = full_name.strip().lower()
|
||
for p in prompts_list:
|
||
if _style_full_name(p).lower() == target:
|
||
return p
|
||
return None
|
||
|
||
|
||
def _find_prompt_by_id(prompts_list: list[dict], prompt_id: str) -> dict | None:
|
||
for p in prompts_list:
|
||
if p.get("id") == prompt_id:
|
||
return p
|
||
return None
|
||
|
||
|
||
# ── style template assembly ────────────────────────────────────────────
|
||
# Templates in `data/prompts/*.json` come in three shapes (per the B0
|
||
# probe of all 587 templates):
|
||
# 1. Flux/Illustrious: "// Your Tags\n{cursor}\n\n// SectionHeader\n
|
||
# <modifiers>" with optional trailing "\n\nNegative Prompt:\n<negs>"
|
||
# 2. Pony quality: "score_9, score_8_up, score_7_up, {cursor}" — cursor
|
||
# inline among modifiers.
|
||
# 3. Pony style: bare modifier line, no cursor, no wrapper.
|
||
# All three flatten to (positive_modifiers, negative_modifiers) for
|
||
# server-side `// Style:` section injection.
|
||
|
||
_TEMPLATE_YOUR_TAGS_BLOCK = re.compile(
|
||
r"^\s*//\s*Your\s+Tags\s*\n\s*\{cursor\}\s*\n\s*\n",
|
||
re.IGNORECASE,
|
||
)
|
||
_TEMPLATE_LEADING_HEADER = re.compile(r"^\s*//\s*[^\n]+\n")
|
||
_TEMPLATE_NEG_MARKER = re.compile(
|
||
r"\n\s*Negative\s+Prompt\s*:\s*\n", re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _parse_style_template_text(text: str) -> tuple[list[str], list[str]]:
|
||
"""Pull positive modifiers and embedded negatives out of a prompt
|
||
template's `text` field. Both lists are bare comma-split tokens
|
||
suitable for direct insertion into output sections.
|
||
|
||
Returns ([], []) when the template body is empty after wrapper-
|
||
stripping (template was just `{cursor}` — nothing to inject)."""
|
||
if not text:
|
||
return [], []
|
||
body = text
|
||
body = _TEMPLATE_YOUR_TAGS_BLOCK.sub("", body, count=1)
|
||
body = body.replace("{cursor}", "").strip()
|
||
if not body:
|
||
return [], []
|
||
neg_split = _TEMPLATE_NEG_MARKER.split(body, maxsplit=1)
|
||
positive_part = neg_split[0].strip()
|
||
negative_part = neg_split[1].strip() if len(neg_split) > 1 else ""
|
||
positive_part = _TEMPLATE_LEADING_HEADER.sub("", positive_part, count=1)
|
||
positive_part = positive_part.strip()
|
||
pos_tokens = [
|
||
t.strip() for t in _split_prompt_tokens(positive_part) if t.strip()
|
||
]
|
||
neg_tokens = [
|
||
t.strip() for t in _split_prompt_tokens(negative_part) if t.strip()
|
||
]
|
||
return pos_tokens, neg_tokens
|
||
|
||
|
||
def _build_style_section(template: dict) -> dict | None:
|
||
"""Construct a `// Style: <Name>` section dict from a template.
|
||
Returns None if the template has no usable positive modifiers
|
||
(after stripping wrapper + cursor).
|
||
|
||
`body_text` holds the joined positive prose (mirrors what natlang
|
||
assembly emits) so prose-mode output keeps the template body verbatim
|
||
rather than re-joining comma-fragments — matters when the template
|
||
body has multi-clause prose where ", ".join would alter spacing."""
|
||
name = (template.get("name") or "").strip() or "Style"
|
||
pos_tokens, _ = _parse_style_template_text(template.get("text") or "")
|
||
if not pos_tokens:
|
||
return None
|
||
return {
|
||
"header": f"// Style: {name}",
|
||
"tokens": pos_tokens,
|
||
"body_text": ", ".join(pos_tokens),
|
||
"is_style_injected": True,
|
||
}
|
||
|
||
|
||
def _replace_or_append_style_section(sections: list[dict],
|
||
style_section: dict) -> list[dict]:
|
||
"""Replace any existing `// Style:` section in `sections` with the
|
||
new one, or append it as the last positive section if none exists.
|
||
Negative Prompt sections always stay last in output_text assembly,
|
||
so appending here puts Style after Setting/Scene but before Neg."""
|
||
out: list[dict] = []
|
||
inserted = False
|
||
for s in sections:
|
||
if (not s.get("is_negative")
|
||
and _section_key_from_header(s.get("header") or "") == "style"):
|
||
if not inserted:
|
||
out.append(style_section)
|
||
inserted = True
|
||
continue
|
||
out.append(s)
|
||
if inserted:
|
||
return out
|
||
# Insert before the first negative section (so Style stays in the
|
||
# positive block) or at the end if no negs exist.
|
||
final: list[dict] = []
|
||
placed = False
|
||
for s in out:
|
||
if not placed and s.get("is_negative"):
|
||
final.append(style_section)
|
||
placed = True
|
||
final.append(s)
|
||
if not placed:
|
||
final.append(style_section)
|
||
return final
|
||
|
||
|
||
def _merge_template_negatives(sections: list[dict],
|
||
template: dict) -> list[dict]:
|
||
"""Pull the embedded `Negative Prompt:` line out of a style template
|
||
and merge into the output's Negative Prompt section. Deduped by
|
||
canonical (lowercase, weighted-stripped) form so user-typed negs
|
||
aren't overwritten and re-applying the same style doesn't duplicate.
|
||
|
||
No-op if the template carries no negatives or every negative is
|
||
already present. If there's no Negative Prompt section in
|
||
`sections`, one is created at the end."""
|
||
_, neg_tokens = _parse_style_template_text(template.get("text") or "")
|
||
if not neg_tokens:
|
||
return sections
|
||
|
||
target: dict | None = None
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
target = s
|
||
break
|
||
if target is None:
|
||
target = {
|
||
"header": "Negative Prompt:",
|
||
"tokens": [],
|
||
"is_negative": True,
|
||
}
|
||
sections = sections + [target]
|
||
|
||
existing: set[str] = set()
|
||
for t in target.get("tokens") or []:
|
||
canon = _canonicalize_token(t).lower()
|
||
if canon:
|
||
existing.add(canon)
|
||
m = _WEIGHTED_TOKEN_RE.match(t)
|
||
if m:
|
||
inner = _canonicalize_token(m.group(1)).lower()
|
||
if inner:
|
||
existing.add(inner)
|
||
|
||
for t in neg_tokens:
|
||
canon = _canonicalize_token(t).lower()
|
||
if canon and canon in existing:
|
||
continue
|
||
target["tokens"].append(t)
|
||
if canon:
|
||
existing.add(canon)
|
||
|
||
return sections
|
||
|
||
|
||
_STYLE_HEADER_RE = re.compile(r"^//\s*style\b\s*:?", re.IGNORECASE)
|
||
_GENERIC_HEADER_RE = re.compile(r"^//\s*\w[\w\s\-]*\s*:")
|
||
|
||
|
||
def _swap_style_template_in_prompt(prompt: str, template: dict) -> str:
|
||
"""Replace the `// Style: <X>` section (header + body) and the
|
||
`Negative Prompt:` section content with the given template's full
|
||
contents. Used as a post-pass after rails/hybrid finishes a style
|
||
apply — the rails dispatcher works at sentence-level and doesn't
|
||
promote a style intent to a full template swap, so leftover scene-
|
||
shaped sentences and stale negatives persist without this pass.
|
||
|
||
If the prompt has no `// Style:` section, one is inserted before
|
||
the Negative Prompt section (or at the end). If the prompt has no
|
||
Negative Prompt section, one is appended.
|
||
|
||
No-op when the template carries no positive tokens — treats that
|
||
as "not really a usable template" rather than wiping the section.
|
||
"""
|
||
if not template or not prompt:
|
||
return prompt
|
||
pos_tokens, neg_tokens = _parse_style_template_text(
|
||
template.get("text") or ""
|
||
)
|
||
if not pos_tokens and not neg_tokens:
|
||
return prompt
|
||
|
||
name = (template.get("name") or "").strip()
|
||
new_style_header = f"// Style: {name}" if name else "// Style:"
|
||
new_style_body = ", ".join(pos_tokens) if pos_tokens else ""
|
||
new_neg_body = ", ".join(neg_tokens) if neg_tokens else ""
|
||
|
||
lines = prompt.splitlines()
|
||
out: list[str] = []
|
||
i = 0
|
||
style_replaced = False
|
||
neg_replaced = False
|
||
|
||
def _is_section_boundary(s: str) -> bool:
|
||
return bool(_GENERIC_HEADER_RE.match(s.strip())) or s.strip() == "Negative Prompt:"
|
||
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
stripped = line.strip()
|
||
|
||
if _STYLE_HEADER_RE.match(stripped):
|
||
# Swap entire style section: header + body until next
|
||
# section boundary or EOF. Ensure a blank line follows
|
||
# so it visually separates from the next section.
|
||
out.append(new_style_header)
|
||
if new_style_body:
|
||
out.append(new_style_body)
|
||
i += 1
|
||
while i < len(lines) and not _is_section_boundary(lines[i]):
|
||
i += 1
|
||
# If a next section follows, leave a blank line gap.
|
||
if i < len(lines):
|
||
out.append("")
|
||
style_replaced = True
|
||
continue
|
||
|
||
if stripped == "Negative Prompt:":
|
||
out.append("Negative Prompt:")
|
||
if new_neg_body:
|
||
out.append(new_neg_body)
|
||
i += 1
|
||
while i < len(lines) and not _is_section_boundary(lines[i]):
|
||
i += 1
|
||
if i < len(lines):
|
||
out.append("")
|
||
neg_replaced = True
|
||
continue
|
||
|
||
out.append(line)
|
||
i += 1
|
||
|
||
if not style_replaced and (new_style_body or new_style_header):
|
||
# No style section existed. Insert before Negative Prompt (if
|
||
# we already emitted it via the negative branch) or at end.
|
||
if neg_replaced:
|
||
for j in range(len(out) - 1, -1, -1):
|
||
if out[j].strip() == "Negative Prompt:":
|
||
if out and out[max(0, j - 1)].strip():
|
||
out.insert(j, "")
|
||
out.insert(j, new_style_header)
|
||
if new_style_body:
|
||
out.insert(j + 1, new_style_body)
|
||
break
|
||
else:
|
||
if out and out[-1].strip():
|
||
out.append("")
|
||
out.append(new_style_header)
|
||
if new_style_body:
|
||
out.append(new_style_body)
|
||
|
||
if not neg_replaced and new_neg_body:
|
||
if out and out[-1].strip():
|
||
out.append("")
|
||
out.append("Negative Prompt:")
|
||
out.append(new_neg_body)
|
||
|
||
return "\n".join(out)
|
||
|
||
|
||
_SECTION_HEADER_RE_MC = re.compile(r"^//\s*(\w[\w\-]*)\s*[:](.*)$", re.IGNORECASE)
|
||
|
||
|
||
def _parse_existing_prompt_state(node_prompt: str) -> dict:
|
||
"""Light parse of an existing `// Section:` prompt. Used by the
|
||
multi-char edit compose path to preserve outfit / style / negs
|
||
from the existing prompt when rebuilding around a new character.
|
||
|
||
Returns:
|
||
{
|
||
'character_sections': [(header_line, body_text), ...],
|
||
'outfit_body': str, # combined Outfit section body
|
||
'pose_body': str, # combined Pose section body
|
||
'scene_body': str,
|
||
'style_body': str,
|
||
'quality_body': str,
|
||
'negative_body': str,
|
||
}
|
||
"""
|
||
out = {
|
||
"character_sections": [],
|
||
"outfit_body": "",
|
||
"pose_body": "",
|
||
"scene_body": "",
|
||
"style_header_line": "",
|
||
"style_body": "",
|
||
"quality_body": "",
|
||
"negative_body": "",
|
||
}
|
||
if not (node_prompt or "").strip():
|
||
return out
|
||
lines = node_prompt.splitlines()
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
stripped = line.strip()
|
||
m = _SECTION_HEADER_RE_MC.match(stripped)
|
||
if m:
|
||
concept = m.group(1).lower()
|
||
body_lines: list[str] = []
|
||
i += 1
|
||
while i < len(lines):
|
||
nxt = lines[i].strip()
|
||
if _SECTION_HEADER_RE_MC.match(nxt) or nxt == "Negative Prompt:":
|
||
break
|
||
if nxt:
|
||
body_lines.append(lines[i])
|
||
i += 1
|
||
body = "\n".join(body_lines).strip()
|
||
if concept == "character":
|
||
out["character_sections"].append((stripped, body))
|
||
elif concept == "outfit":
|
||
out["outfit_body"] = (
|
||
(out["outfit_body"] + "\n" + body).strip()
|
||
if out["outfit_body"] else body
|
||
)
|
||
elif concept == "pose":
|
||
out["pose_body"] = (
|
||
(out["pose_body"] + "\n" + body).strip()
|
||
if out["pose_body"] else body
|
||
)
|
||
elif concept == "scene":
|
||
out["scene_body"] = (
|
||
(out["scene_body"] + "\n" + body).strip()
|
||
if out["scene_body"] else body
|
||
)
|
||
elif concept == "style":
|
||
out["style_body"] = (
|
||
(out["style_body"] + "\n" + body).strip()
|
||
if out["style_body"] else body
|
||
)
|
||
if not out["style_header_line"]:
|
||
out["style_header_line"] = stripped
|
||
elif concept == "quality":
|
||
out["quality_body"] = (
|
||
(out["quality_body"] + "\n" + body).strip()
|
||
if out["quality_body"] else body
|
||
)
|
||
continue
|
||
if stripped == "Negative Prompt:":
|
||
i += 1
|
||
neg_lines: list[str] = []
|
||
while i < len(lines):
|
||
nxt = lines[i].strip()
|
||
if _SECTION_HEADER_RE_MC.match(nxt) or nxt == "Negative Prompt:":
|
||
break
|
||
if nxt:
|
||
neg_lines.append(lines[i])
|
||
i += 1
|
||
out["negative_body"] = "\n".join(neg_lines).strip()
|
||
continue
|
||
i += 1
|
||
return out
|
||
|
||
|
||
async def _maybe_compose_multichar_edit(
|
||
request: web.Request,
|
||
node_prompt: str,
|
||
user_request: str,
|
||
character_queries: list[str] | None,
|
||
model_hash: str,
|
||
bios: list[dict],
|
||
) -> str | None:
|
||
"""Edit-mode multi-character compose path.
|
||
|
||
Triggers when the chat agent passed 2+ entries in character_queries
|
||
but the existing node_prompt has 0-1 `// Character:` sections —
|
||
i.e., the user is ADDING a character. The agent's request text is
|
||
unreliable in this transition (treats new chars as scene content).
|
||
|
||
Bypass the hybrid path entirely:
|
||
1. Build a planner-shaped dict from character_queries (cast),
|
||
existing prompt state (preserved outfit/style/negs), and the
|
||
user request decompose (interaction verb + scene_text).
|
||
2. compose_from_plan → structured `// Section:` body
|
||
3. compose_scene_paragraph → inline-bundle cinematic prose
|
||
4. Re-attach the preserved negative block verbatim.
|
||
|
||
Returns the final prompt as cinematic prose, or None if conditions
|
||
don't match — caller falls through to normal hybrid path. Single-
|
||
character paths are untouched.
|
||
"""
|
||
cleaned_queries = [
|
||
q.strip() for q in (character_queries or [])
|
||
if q and q.strip()
|
||
]
|
||
if len(cleaned_queries) < 2:
|
||
return None
|
||
existing_char_count = sum(
|
||
1 for line in (node_prompt or "").splitlines()
|
||
if line.strip().lower().startswith("// character:")
|
||
)
|
||
if existing_char_count >= 2:
|
||
# Already multi-char; let hybrid + polish post-pass handle.
|
||
return None
|
||
|
||
logger.info(
|
||
"multichar-edit: trigger queries=%r existing_chars=%d",
|
||
cleaned_queries, existing_char_count,
|
||
)
|
||
|
||
existing_state = _parse_existing_prompt_state(node_prompt)
|
||
|
||
# Resolve each character via the tag-builder DB so we get series +
|
||
# base_natlang. Bios passed in by the chat agent's preflight may
|
||
# already have this, but re-resolve for safety so a missing field
|
||
# doesn't drop a character.
|
||
import os as _os
|
||
import sqlite3
|
||
db_path = _os.path.join(
|
||
_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||
"data", "tag-builder", "tag-builder.db",
|
||
)
|
||
if not _os.path.exists(db_path):
|
||
logger.warning("multichar-edit: tag-builder.db not at %r — fallback", db_path)
|
||
return None
|
||
|
||
resolved_bios: list[dict] = []
|
||
try:
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
try:
|
||
for q in cleaned_queries:
|
||
# Normalize '-'<->'_' on BOTH sides: the chat agent emits
|
||
# underscore-canonical Danbooru tags (chun_li), but a few
|
||
# characters are stored hyphenated (chun-li). Without this
|
||
# the lookup misses, <2 resolve, and the whole multi-char
|
||
# compose silently falls back to hybrid. No two distinct
|
||
# character tags differ only by separator, so this is safe.
|
||
row = conn.execute(
|
||
"SELECT tag, display, series, base_natlang "
|
||
"FROM characters WHERE "
|
||
"LOWER(REPLACE(tag, '-', '_')) = "
|
||
"LOWER(REPLACE(?, '-', '_')) LIMIT 1",
|
||
(q,),
|
||
).fetchone()
|
||
if row:
|
||
resolved_bios.append(dict(row))
|
||
else:
|
||
logger.info(
|
||
"multichar-edit: tag=%r not in characters table — skipping",
|
||
q,
|
||
)
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
logger.warning("multichar-edit: bio lookup failed", exc_info=True)
|
||
return None
|
||
|
||
if len(resolved_bios) < 2:
|
||
logger.info(
|
||
"multichar-edit: only %d/%d characters resolved — fallback",
|
||
len(resolved_bios), len(cleaned_queries),
|
||
)
|
||
return None
|
||
|
||
# Run decompose on the user request to extract interaction + scene.
|
||
# Pose intents map to the interaction verb. Scene intents map to
|
||
# scene_text. Other intents are intentionally ignored — the
|
||
# composer derives outfits from existing state + DB defaults.
|
||
try:
|
||
_pc_root = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
|
||
if _pc_root not in sys.path:
|
||
sys.path.insert(0, _pc_root)
|
||
from scripts.natlang_decompose_probe import decompose
|
||
except Exception:
|
||
logger.warning("multichar-edit: decompose import failed", exc_info=True)
|
||
return None
|
||
try:
|
||
decomposed, _decompose_raw = await decompose(user_request)
|
||
except Exception:
|
||
logger.warning("multichar-edit: decompose call failed", exc_info=True)
|
||
return None
|
||
intents = (decomposed or {}).get("intents") or []
|
||
interaction_text = ""
|
||
scene_text = ""
|
||
for it in intents:
|
||
c = (it.get("concept") or "").lower()
|
||
t = (it.get("text") or "").strip()
|
||
if not t:
|
||
continue
|
||
if c in ("pose", "action") and not interaction_text:
|
||
interaction_text = t
|
||
elif c == "scene" and not scene_text:
|
||
scene_text = t
|
||
# Heuristic fallback when decompose doesn't isolate the interaction:
|
||
# the user's literal request usually contains the verb. Common
|
||
# interaction verbs surface here even when decompose is noisy.
|
||
if not interaction_text:
|
||
lc = (user_request or "").lower()
|
||
for verb in (
|
||
"fighting", "sparring", "dueling", "racing", "dancing",
|
||
"kissing", "hugging", "embracing", "chasing", "wrestling",
|
||
"boxing", "punching", "kicking", "blocking",
|
||
):
|
||
if verb in lc:
|
||
interaction_text = verb
|
||
break
|
||
|
||
# Decide outfit_text per character. Preserve existing prompt's
|
||
# outfit body for the EXISTING character (matched by display name
|
||
# in any of the existing `// Character:` headers). New characters
|
||
# get outfit_text="" + outfit_source="canon" (use their default).
|
||
existing_displays_lc: set[str] = set()
|
||
for header, _body in existing_state["character_sections"]:
|
||
# Header shape: "// Character: <Display> (<Series>)"
|
||
m = re.match(r"//\s*character\s*:\s*(.+?)(?:\s*\([^)]+\))?\s*$",
|
||
header, re.IGNORECASE)
|
||
if m:
|
||
existing_displays_lc.add(m.group(1).strip().lower())
|
||
|
||
per_character: list[dict] = []
|
||
for bio in resolved_bios:
|
||
display = (bio.get("display") or "").strip()
|
||
tag = (bio.get("tag") or "").strip()
|
||
if display.lower() in existing_displays_lc and existing_state["outfit_body"]:
|
||
# Existing character — carry forward the outfit verbatim
|
||
outfit_text = existing_state["outfit_body"]
|
||
outfit_source = "literal"
|
||
else:
|
||
# New character — use her default canon outfit
|
||
outfit_text = ""
|
||
outfit_source = "canon"
|
||
per_character.append({
|
||
"tag": tag,
|
||
"outfit_text": outfit_text,
|
||
"outfit_source": outfit_source,
|
||
"pose_text": "",
|
||
})
|
||
|
||
cast = [
|
||
{"tag": b.get("tag"), "display": b.get("display")}
|
||
for b in resolved_bios
|
||
]
|
||
|
||
plan_dict = {
|
||
"cast": cast,
|
||
"per_character": per_character,
|
||
"interaction": interaction_text,
|
||
"scene_text": scene_text,
|
||
"style_text": "", # style is preserved separately, see below
|
||
"lighting_text": "",
|
||
}
|
||
|
||
logger.info(
|
||
"multichar-edit: plan cast=%s interaction=%r scene=%r",
|
||
[c["display"] for c in cast],
|
||
interaction_text or "(none)",
|
||
scene_text or "(none)",
|
||
)
|
||
|
||
# compose_from_plan builds the structured `// Section:` body. We
|
||
# then INJECT the preserved style + negs back in (compose_from_plan
|
||
# doesn't preserve existing prompt state — it builds from the plan
|
||
# only) before polishing.
|
||
try:
|
||
from scripts.natlang_compose_from_plan import compose_from_plan
|
||
structured = compose_from_plan(
|
||
plan_dict, resolved_bios, db_path,
|
||
default_negative_block="", # we re-attach existing negs below
|
||
)
|
||
except Exception:
|
||
logger.warning("multichar-edit: compose_from_plan failed", exc_info=True)
|
||
return None
|
||
if not (structured or "").strip():
|
||
logger.warning("multichar-edit: compose_from_plan returned empty")
|
||
return None
|
||
|
||
# Polish to inline-bundle prose. Skip the style step in the
|
||
# composer — we re-attach the preserved `// Style: <Name>` section
|
||
# AFTER the prose so the style-template swap pipeline (which
|
||
# operates on section headers) continues to work on multi-char
|
||
# output. Style as a global modifier doesn't need to be inside the
|
||
# subject prose for the encoder; only the per-character bundles +
|
||
# scene need that binding.
|
||
try:
|
||
from scripts.natlang_scene_composer import compose_scene_paragraph
|
||
paragraph, _raw = await compose_scene_paragraph(
|
||
structured,
|
||
include_style_in_prose=False,
|
||
)
|
||
except Exception:
|
||
logger.warning("multichar-edit: scene composer failed", exc_info=True)
|
||
return None
|
||
if not (paragraph or "").strip():
|
||
logger.warning("multichar-edit: scene composer returned empty")
|
||
return None
|
||
|
||
# Sanity check: every named character must survive.
|
||
char_display_list = [b.get("display") for b in resolved_bios]
|
||
dropped = [
|
||
d for d in char_display_list
|
||
if d and d.lower() not in paragraph.lower()
|
||
]
|
||
if dropped:
|
||
logger.warning(
|
||
"multichar-edit: composer dropped %s — fallback to hybrid",
|
||
dropped,
|
||
)
|
||
return None
|
||
|
||
# Assemble final: prose + // Style: <preserved> + Negative Prompt.
|
||
pieces: list[str] = [paragraph.rstrip()]
|
||
style_header = existing_state.get("style_header_line") or ""
|
||
style_body = existing_state.get("style_body") or ""
|
||
if style_header and style_body:
|
||
pieces.append(f"{style_header}\n{style_body.strip()}")
|
||
elif style_body:
|
||
pieces.append(f"// Style:\n{style_body.strip()}")
|
||
neg = existing_state["negative_body"].strip()
|
||
if neg:
|
||
pieces.append(f"Negative Prompt:\n{neg}")
|
||
final = "\n\n".join(pieces)
|
||
logger.info(
|
||
"multichar-edit: composed %d -> %d chars (cast=%s, style_split=%s)",
|
||
len(node_prompt or ""), len(final), char_display_list,
|
||
bool(style_header or style_body),
|
||
)
|
||
return final
|
||
|
||
|
||
async def _polish_multichar_to_prose(final_prompt: str,
|
||
pipeline_name: str = "hybrid-v1") -> str:
|
||
"""When `final_prompt` has 2+ `// Character:` sections, fold the
|
||
sectioned body into ONE flowing cinematic paragraph via the
|
||
scene composer. Single-character prompts are returned verbatim
|
||
(sectioned form is KB-rich and structure-preserving is preferred).
|
||
|
||
Modern natlang T2I encoders (T5-XXL on Flux, Qwen3-4B on Z-Image,
|
||
Qwen2.5-VL on Qwen-Image) don't honor `// Section:` boundaries —
|
||
a multi-character prompt with shared `// Outfit:` and `// Pose:`
|
||
sections leaves the encoder with no way to bind features per
|
||
subject. Inline-bundle prose with spatial anchors
|
||
(`On the left, A wears X, doing Y. On the right, B wears Z…`)
|
||
is what these models train on and render reliably.
|
||
|
||
Sanity check: every named character must survive the rewrite —
|
||
if the composer drops one (LLM nondeterminism), keep the
|
||
structured form rather than ship a broken paragraph.
|
||
|
||
Negative Prompt block is preserved verbatim and re-attached.
|
||
No-op when the composer isn't importable, the prompt is empty,
|
||
or only one character is in scope. Errors fall back to input.
|
||
"""
|
||
if not final_prompt or not final_prompt.strip():
|
||
return final_prompt
|
||
char_section_count = sum(
|
||
1 for line in final_prompt.splitlines()
|
||
if line.strip().lower().startswith("// character:")
|
||
)
|
||
if char_section_count < 2:
|
||
return final_prompt
|
||
try:
|
||
from scripts.natlang_scene_composer import compose_scene_paragraph
|
||
except Exception:
|
||
logger.warning(
|
||
"ai-patch (%s): scene composer unavailable for multi-char polish",
|
||
pipeline_name,
|
||
)
|
||
return final_prompt
|
||
if "\n\nNegative Prompt:" in final_prompt:
|
||
positive, _, negative = final_prompt.partition("\n\nNegative Prompt:")
|
||
elif final_prompt.startswith("Negative Prompt:"):
|
||
positive = ""
|
||
negative = final_prompt[len("Negative Prompt:"):]
|
||
else:
|
||
positive = final_prompt
|
||
negative = ""
|
||
char_names: list[str] = []
|
||
for line in positive.splitlines():
|
||
if line.strip().lower().startswith("// character:"):
|
||
name = line.split(":", 1)[1].strip()
|
||
name = re.sub(r"\s*\([^)]+\)\s*$", "", name).strip()
|
||
if name:
|
||
char_names.append(name)
|
||
try:
|
||
paragraph, _raw = await compose_scene_paragraph(positive)
|
||
except Exception:
|
||
logger.warning(
|
||
"ai-patch (%s): scene composer raised", pipeline_name, exc_info=True,
|
||
)
|
||
return final_prompt
|
||
if not (paragraph or "").strip():
|
||
logger.warning(
|
||
"ai-patch (%s): scene composer returned empty — keeping structured",
|
||
pipeline_name,
|
||
)
|
||
return final_prompt
|
||
dropped = [
|
||
name for name in char_names
|
||
if name and name.lower() not in paragraph.lower()
|
||
]
|
||
if dropped:
|
||
logger.warning(
|
||
"ai-patch (%s): multi-char polish dropped %s — keeping structured",
|
||
pipeline_name, dropped,
|
||
)
|
||
return final_prompt
|
||
logger.info(
|
||
"ai-patch (%s): multi-char polish %d -> %d chars (chars=%s)",
|
||
pipeline_name, len(positive), len(paragraph), char_names,
|
||
)
|
||
if negative.strip():
|
||
return paragraph.rstrip() + "\n\nNegative Prompt:" + negative
|
||
return paragraph
|
||
|
||
|
||
def _find_style_intent(intents: list[dict]) -> dict | None:
|
||
"""Find the last `concept=style` intent in the trace that resolved
|
||
against a known template (resolved_source=='style'). Returns the
|
||
intent dict (with `resolved_match_name`) or None.
|
||
Last-wins semantics: if a turn applied two styles, the final state
|
||
is the second swap."""
|
||
found = None
|
||
for it in intents or []:
|
||
if ((it.get("concept") or "").lower() == "style"
|
||
and (it.get("resolved_source") or "").lower() == "style"
|
||
and (it.get("op") or "replace").lower() in ("replace", "add")
|
||
and it.get("resolved_match_name")):
|
||
found = it
|
||
return found
|
||
|
||
|
||
_OUTER_WEIGHT_RE = re.compile(r"^\((.+):(\d+\.?\d*)\)$")
|
||
|
||
|
||
def _format_output(text: str, tag_format: str) -> str:
|
||
"""Post-process a single tag token. Model emits Danbooru-canonical
|
||
underscored tags; convert to spaces when the target model expects
|
||
spaces, AND escape any literal parens that come from canonical-tag
|
||
franchise suffixes (`mythra_(xenoblade)` → `mythra \\(xenoblade\\)`).
|
||
|
||
The outermost `(content:weight)` weight-wrapper is preserved as-is —
|
||
its parens are A1111 syntax, not literal characters. Only the
|
||
parens *inside* the tag name get escaped, because SD's parser
|
||
otherwise reads them as nested weight markers and the whole token
|
||
structure breaks."""
|
||
if tag_format != "spaces" or not text:
|
||
return text
|
||
text = text.replace("_", " ")
|
||
m = _OUTER_WEIGHT_RE.match(text)
|
||
if m:
|
||
# Weighted form: only escape parens INSIDE the content.
|
||
content = _escape_unescaped_parens(m.group(1))
|
||
return f"({content}:{m.group(2)})"
|
||
# Non-weighted token: escape all unescaped parens.
|
||
return _escape_unescaped_parens(text)
|
||
|
||
|
||
def _escape_unescaped_parens(s: str) -> str:
|
||
"""Add `\\` before `(` / `)` chars not already preceded by `\\`.
|
||
Idempotent: applying twice gives the same result."""
|
||
s = re.sub(r"(?<!\\)\(", r"\\(", s)
|
||
s = re.sub(r"(?<!\\)\)", r"\\)", s)
|
||
return s
|
||
|
||
|
||
async def _call_provider_complete(
|
||
request_id: str,
|
||
provider: str,
|
||
config: dict,
|
||
system: str,
|
||
user_request: str,
|
||
images: list[dict] | None = None,
|
||
) -> str:
|
||
images = images or []
|
||
if provider == "cloud":
|
||
cloud = config.get("cloud") or {}
|
||
service = cloud.get("service") or "claude"
|
||
api_key = (cloud.get("api_key") or "").strip()
|
||
model = (cloud.get("model") or "").strip()
|
||
if not api_key or not model:
|
||
return ""
|
||
if service == "claude":
|
||
return await _claude_complete(request_id, api_key, model, system, user_request, images)
|
||
base_url = _cloud_base_url(cloud)
|
||
if not base_url:
|
||
return ""
|
||
return await _openai_compat_complete(
|
||
request_id, base_url, model, system, user_request, images, api_key=api_key,
|
||
)
|
||
if provider == "local":
|
||
local = config.get("local") or {}
|
||
base_url = (local.get("base_url") or "").strip().rstrip("/")
|
||
model = (local.get("model") or "").strip()
|
||
if not base_url or not model:
|
||
return ""
|
||
return await _openai_compat_complete(
|
||
request_id, base_url, model, system, user_request, images,
|
||
)
|
||
return ""
|
||
|
||
|
||
async def _claude_complete(request_id, api_key, model, system, user, images) -> str:
|
||
content = []
|
||
for img in images:
|
||
data = img.get("data")
|
||
media = img.get("media_type") or "image/jpeg"
|
||
if not data:
|
||
continue
|
||
content.append({
|
||
"type": "image",
|
||
"source": {"type": "base64", "media_type": media, "data": data},
|
||
})
|
||
content.append({"type": "text", "text": user})
|
||
payload = {
|
||
"model": model,
|
||
"max_tokens": _MAX_TOKENS,
|
||
"system": system,
|
||
"messages": [{"role": "user", "content": content}],
|
||
}
|
||
timeout = aiohttp.ClientTimeout(connect=_CONNECT_TIMEOUT, sock_read=_READ_TIMEOUT)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.post(
|
||
"https://api.anthropic.com/v1/messages",
|
||
headers={
|
||
"x-api-key": api_key,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json",
|
||
},
|
||
json=payload,
|
||
) as resp:
|
||
if resp.status != 200:
|
||
err = (await resp.text())[:200]
|
||
logger.warning("claude_complete[%s] HTTP %d: %s", request_id, resp.status, err)
|
||
return ""
|
||
data = await resp.json()
|
||
blocks = data.get("content") or []
|
||
return "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
||
|
||
|
||
async def _openai_compat_complete(
|
||
request_id, base_url, model, system, user, images, api_key: str | None = None,
|
||
) -> str:
|
||
content = []
|
||
for img in images:
|
||
data = img.get("data")
|
||
media = img.get("media_type") or "image/jpeg"
|
||
if not data:
|
||
continue
|
||
content.append({
|
||
"type": "image_url",
|
||
"image_url": {"url": f"data:{media};base64,{data}"},
|
||
})
|
||
content.append({"type": "text", "text": user})
|
||
messages = [
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": content if images else user},
|
||
]
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"stream": False,
|
||
"max_tokens": _MAX_TOKENS,
|
||
}
|
||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||
timeout = aiohttp.ClientTimeout(connect=_CONNECT_TIMEOUT, sock_read=_READ_TIMEOUT)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.post(
|
||
f"{base_url}/chat/completions", json=payload, headers=headers,
|
||
) as resp:
|
||
if resp.status != 200:
|
||
err = (await resp.text())[:200]
|
||
logger.warning("openai_complete[%s] HTTP %d: %s", request_id, resp.status, err)
|
||
return ""
|
||
data = await resp.json()
|
||
choices = data.get("choices") or []
|
||
if not choices:
|
||
return ""
|
||
msg = choices[0].get("message") or {}
|
||
return msg.get("content") or ""
|
||
|
||
|
||
async def _run_generation(request_id: str, provider: str, config: dict,
|
||
system: str, user_request: str, images: list[dict]) -> str:
|
||
"""Streams from the configured provider with thinking events for the
|
||
live counter, then returns the collected body for the caller to parse
|
||
+ validate. Visible deltas are not emitted — `/ai/patch` (the only
|
||
caller) reads the full body post-stream as JSON."""
|
||
try:
|
||
if provider == "cloud":
|
||
cloud = config.get("cloud") or {}
|
||
service = cloud.get("service") or "claude"
|
||
api_key = (cloud.get("api_key") or "").strip()
|
||
model = (cloud.get("model") or "").strip()
|
||
if not api_key:
|
||
_emit(request_id, "error", error="Cloud API key not configured")
|
||
return ""
|
||
if not model:
|
||
_emit(request_id, "error", error="Cloud model not configured")
|
||
return ""
|
||
if service == "claude":
|
||
return await _stream_claude(
|
||
request_id, api_key, model, system, user_request, images,
|
||
)
|
||
base_url = _cloud_base_url(cloud)
|
||
if not base_url:
|
||
_emit(request_id, "error", error="Cloud base URL missing")
|
||
return ""
|
||
return await _stream_openai_compat(
|
||
request_id, base_url, model, system, user_request, images,
|
||
api_key=api_key,
|
||
)
|
||
if provider == "local":
|
||
return await _stream_local(
|
||
request_id, config, system, user_request, images,
|
||
)
|
||
_emit(request_id, "error", error=f"unknown provider {provider}")
|
||
return ""
|
||
except asyncio.CancelledError:
|
||
_emit(request_id, "cancelled")
|
||
raise
|
||
except Exception as e:
|
||
logger.exception("generation failed")
|
||
_emit(request_id, "error", error=str(e))
|
||
return ""
|
||
|
||
|
||
def _emit(request_id: str, event: str, *, content: str = "", error: str = "",
|
||
tokens: int | None = None):
|
||
payload = {
|
||
"request_id": request_id,
|
||
"event": event,
|
||
"content": content,
|
||
"error": error,
|
||
"t": time.time(),
|
||
}
|
||
if tokens is not None:
|
||
payload["tokens"] = tokens
|
||
send_ws("promptchain_ai_stream", payload)
|
||
|
||
|
||
# ── Claude streaming ──────────────────────────────────────────────
|
||
|
||
async def _stream_claude(request_id: str, api_key: str, model: str,
|
||
system: str, user_request: str, images: list[dict]) -> str:
|
||
"""Streams from Claude with thinking events for the live counter, then
|
||
returns the collected body for the caller to parse + validate. Visible
|
||
deltas are suppressed — the patch endpoint reads the full body post-
|
||
stream and emit-as-JSON would be noise."""
|
||
content = []
|
||
for img in images:
|
||
data = img.get("data")
|
||
media = img.get("media_type") or "image/jpeg"
|
||
if not data:
|
||
continue
|
||
content.append({
|
||
"type": "image",
|
||
"source": {"type": "base64", "media_type": media, "data": data},
|
||
})
|
||
content.append({"type": "text", "text": user_request})
|
||
|
||
payload = {
|
||
"model": model,
|
||
"max_tokens": _MAX_TOKENS,
|
||
"system": system,
|
||
"messages": [{"role": "user", "content": content}],
|
||
"stream": True,
|
||
}
|
||
|
||
logger.info("stream_claude[%s] model=%s images=%d user_chars=%d",
|
||
request_id, model, len(images), len(user_request))
|
||
chars_streamed = 0
|
||
deltas_received = 0
|
||
saw_stop = False
|
||
parse_failures = 0
|
||
body_buffer: list[str] = []
|
||
|
||
timeout = aiohttp.ClientTimeout(connect=_CONNECT_TIMEOUT, sock_read=_READ_TIMEOUT)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.post(
|
||
"https://api.anthropic.com/v1/messages",
|
||
headers={
|
||
"x-api-key": api_key,
|
||
"anthropic-version": "2023-06-01",
|
||
"content-type": "application/json",
|
||
},
|
||
json=payload,
|
||
) as resp:
|
||
logger.info("stream_claude[%s] HTTP %d", request_id, resp.status)
|
||
if resp.status != 200:
|
||
data = await resp.text()
|
||
logger.warning("stream_claude[%s] error body: %s", request_id, data[:500])
|
||
_emit(request_id, "error", error=f"HTTP {resp.status}: {data[:200]}")
|
||
return ""
|
||
|
||
async for raw in resp.content:
|
||
line = raw.decode("utf-8", errors="replace").strip()
|
||
if not line or not line.startswith("data:"):
|
||
continue
|
||
data_str = line[5:].strip()
|
||
if not data_str:
|
||
continue
|
||
try:
|
||
evt = json.loads(data_str)
|
||
except Exception as e:
|
||
parse_failures += 1
|
||
logger.debug("stream_claude[%s] json parse fail: %s | line=%r", request_id, e, data_str[:200])
|
||
continue
|
||
evt_type = evt.get("type")
|
||
if evt_type == "content_block_delta":
|
||
delta = evt.get("delta") or {}
|
||
if delta.get("type") == "text_delta":
|
||
text = delta.get("text", "")
|
||
deltas_received += 1
|
||
chars_streamed += len(text)
|
||
body_buffer.append(text)
|
||
elif evt_type == "message_stop":
|
||
saw_stop = True
|
||
return "".join(body_buffer)
|
||
elif evt_type == "error":
|
||
err = (evt.get("error") or {}).get("message") or "stream error"
|
||
logger.warning("stream_claude[%s] stream error event: %s", request_id, err)
|
||
_emit(request_id, "error", error=err)
|
||
return ""
|
||
_log_stream_exit("stream_claude", request_id, deltas_received, chars_streamed, saw_stop, parse_failures)
|
||
return "".join(body_buffer)
|
||
|
||
|
||
def _log_stream_exit(fn: str, request_id: str, deltas: int, chars: int, saw_stop: bool, parse_failures: int):
|
||
# Stream loop exited without an explicit stop event — log whether we
|
||
# got any content at all so "empty response" cases are visible in the
|
||
# server log rather than silently resolving as a zero-delta success.
|
||
level = logging.WARNING if chars == 0 else logging.INFO
|
||
logger.log(
|
||
level,
|
||
"%s[%s] stream loop exited without stop: deltas=%d chars=%d parse_failures=%d",
|
||
fn, request_id, deltas, chars, parse_failures,
|
||
)
|
||
|
||
|
||
# ── Local (OpenAI-compat + Ollama native) streaming ───────────────
|
||
|
||
async def _stream_local(request_id: str, config: dict, system: str,
|
||
user_request: str, images: list[dict]) -> str:
|
||
local = config.get("local") or {}
|
||
base_url = (local.get("base_url") or "").strip().rstrip("/")
|
||
model = (local.get("model") or "").strip()
|
||
if not base_url:
|
||
_emit(request_id, "error", error="Local base URL not configured")
|
||
return ""
|
||
if not model:
|
||
_emit(request_id, "error", error="Local model not configured")
|
||
return ""
|
||
|
||
# If the user has images AND we're talking to Ollama, prefer the native
|
||
# /api/chat path since it takes images at the message level (cleaner
|
||
# than the OpenAI-compat data: URL shape). Otherwise use /v1/chat/completions
|
||
# universally — works for llama.cpp, LM Studio, Ollama's /v1, KoboldCPP.
|
||
ollama_root = _ollama_root(base_url)
|
||
use_ollama_native = bool(images) and await _is_ollama(ollama_root)
|
||
|
||
if use_ollama_native:
|
||
return await _stream_ollama_native(
|
||
request_id, ollama_root, model, system, user_request, images,
|
||
)
|
||
# Probe Ollama once even on the no-images branch so we can pass
|
||
# think=false through the OpenAI-compat path (Ollama tolerates it,
|
||
# cloud providers reject unknown fields).
|
||
is_ollama = await _is_ollama(ollama_root)
|
||
return await _stream_openai_compat(
|
||
request_id, base_url, model, system, user_request, images,
|
||
is_ollama=is_ollama,
|
||
)
|
||
|
||
|
||
async def _is_ollama(ollama_root: str) -> bool:
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=3)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.get(f"{ollama_root}/api/tags") as resp:
|
||
return resp.status == 200
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
async def _is_ollama_model_loaded(ollama_root: str, model: str) -> bool:
|
||
"""True if `model` is currently loaded into Ollama's VRAM (`/api/ps`).
|
||
Used to gate the 'Loading model' status indicator on cold first
|
||
calls. Returns False on any probe failure — the request continues
|
||
normally without the indicator rather than blocking."""
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=3)
|
||
target = (model or "").strip().lower()
|
||
if not target:
|
||
return False
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.get(f"{ollama_root}/api/ps") as resp:
|
||
if resp.status != 200:
|
||
return False
|
||
data = await resp.json()
|
||
for m in (data.get("models") or []):
|
||
for key in ("name", "model"):
|
||
v = (m.get(key) or "").strip().lower()
|
||
if v == target:
|
||
return True
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
async def _warmup_ollama_model(ollama_root: str, model: str) -> None:
|
||
"""Force Ollama to load `model` into VRAM by hitting /api/generate
|
||
with no prompt — Ollama loads the model and returns once it's ready.
|
||
This makes the 'Loading model' status linger accurately during the
|
||
actual VRAM populate, instead of being clobbered by the next stage's
|
||
status the moment we emit it. Quietly no-ops on failure; the real
|
||
chat call would have triggered the load anyway, we just lose the
|
||
indicator."""
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=300)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.post(
|
||
f"{ollama_root}/api/generate",
|
||
json={"model": model, "keep_alive": "5m"},
|
||
) as resp:
|
||
await resp.read()
|
||
except Exception:
|
||
logger.warning("ollama warmup failed", exc_info=True)
|
||
|
||
|
||
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>([\s\S]*?)</think>", re.IGNORECASE)
|
||
_THINK_OPEN_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
|
||
|
||
|
||
async def _stream_ollama_native(request_id: str, ollama_root: str, model: str,
|
||
system: str, user_request: str, images: list[dict]) -> str:
|
||
messages = [{"role": "system", "content": system}]
|
||
msg = {"role": "user", "content": user_request}
|
||
if images:
|
||
msg["images"] = [img.get("data") for img in images if img.get("data")]
|
||
messages.append(msg)
|
||
|
||
# Suppress reasoning blocks: qwen3-thinking and similar burn 5-20k tokens
|
||
# of <think>...</think> per turn. We're feeding the model curated bundle
|
||
# data, not asking it to reason — pure waste. Ollama 0.5+ honors `think`.
|
||
# num_ctx: Ollama defaults to 2048 unless the Modelfile sets higher. Our
|
||
# system prompt + bio + bundles + thinking can exceed that easily, which
|
||
# silently truncates input mid-prompt and confuses the model — likely
|
||
# cause of the recurring deadlock pattern. 32K gives qwen3-vl plenty of
|
||
# room (model itself supports 256K).
|
||
# repeat_penalty=1.3: 8B Qwen occasionally degenerates into a
|
||
# comma-list repetition loop when given thin context (empty bios,
|
||
# short user message). Default 1.1 isn't strong enough to break the
|
||
# KV-cache reinforcement once it starts. 1.3 stops the runaway
|
||
# without hurting legitimate output. Seen in the wild: the
|
||
# `mythra_(xenoblade_chronicles_2)` matcher-miss → 175s of
|
||
# `mythra_serene, mythra_graceful, mythra_radiant, ...` cycle.
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"stream": True,
|
||
"think": False,
|
||
"options": {"num_ctx": 32768, "repeat_penalty": 1.3},
|
||
}
|
||
logger.info("stream_ollama[%s] url=%s model=%s images=%d user_chars=%d",
|
||
request_id, f"{ollama_root}/api/chat", model, len(images), len(user_request))
|
||
chars_streamed = 0
|
||
deltas_received = 0
|
||
saw_done = False
|
||
parse_failures = 0
|
||
body_buffer: list[str] = []
|
||
thinking_buffer: list[str] = []
|
||
# Ollama streams approximately one detokenized output token per chunk,
|
||
# so chunk count is a close proxy for token count. eval_count on the
|
||
# final done event is the ground truth if we ever need to reconcile.
|
||
reasoning_tokens = 0
|
||
saw_think_block = False
|
||
first_event_logged = False
|
||
|
||
timeout = aiohttp.ClientTimeout(connect=_CONNECT_TIMEOUT, sock_read=_READ_TIMEOUT)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.post(f"{ollama_root}/api/chat", json=payload) as resp:
|
||
logger.info("stream_ollama[%s] HTTP %d", request_id, resp.status)
|
||
if resp.status != 200:
|
||
body = await resp.text()
|
||
logger.warning("stream_ollama[%s] error body: %s", request_id, body[:500])
|
||
_emit(request_id, "error", error=f"HTTP {resp.status}: {body[:200]}")
|
||
return ""
|
||
async for raw in resp.content:
|
||
line = raw.decode("utf-8", errors="replace").strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
evt = json.loads(line)
|
||
except Exception as e:
|
||
parse_failures += 1
|
||
logger.debug("stream_ollama[%s] json parse fail: %s | line=%r", request_id, e, line[:200])
|
||
continue
|
||
if not first_event_logged:
|
||
first_event_logged = True
|
||
dbg.info("ai[%s] ollama first event keys=%s message_keys=%s raw=%s",
|
||
request_id, list(evt.keys()),
|
||
list((evt.get("message") or {}).keys()),
|
||
_trunc(json.dumps(evt, ensure_ascii=True), 1500))
|
||
msg = evt.get("message") or {}
|
||
# Newer Ollama splits reasoning into a dedicated `thinking`
|
||
# field. Surface it via a separate WS event so the client
|
||
# can show "Reasoning…" without polluting the prompt body.
|
||
thinking_chunk = msg.get("thinking") or ""
|
||
if thinking_chunk:
|
||
thinking_buffer.append(thinking_chunk)
|
||
reasoning_tokens += 1
|
||
_request_reasoning_chars[request_id] = (
|
||
_request_reasoning_chars.get(request_id, 0) + len(thinking_chunk)
|
||
)
|
||
_emit(request_id, "thinking", content=thinking_chunk, tokens=reasoning_tokens)
|
||
chunk = msg.get("content", "")
|
||
if chunk:
|
||
if not saw_think_block and _THINK_OPEN_RE.search(chunk):
|
||
saw_think_block = True
|
||
dbg.info("ai[%s] inline <think> block detected in content "
|
||
"(chunk=%r) — body_buffer will contain reasoning",
|
||
request_id, chunk[:200])
|
||
deltas_received += 1
|
||
chars_streamed += len(chunk)
|
||
body_buffer.append(chunk)
|
||
if evt.get("done"):
|
||
saw_done = True
|
||
# Ollama puts timing/eval counts on the done event.
|
||
dbg.info(
|
||
"ai[%s] ollama done metadata: total=%sms load=%sms "
|
||
"prompt_eval=%s tokens (%sms) eval=%s tokens (%sms) "
|
||
"thinking_chars=%d body_chars=%d inline_think=%s",
|
||
request_id,
|
||
_ms(evt.get("total_duration")), _ms(evt.get("load_duration")),
|
||
evt.get("prompt_eval_count"), _ms(evt.get("prompt_eval_duration")),
|
||
evt.get("eval_count"), _ms(evt.get("eval_duration")),
|
||
sum(len(t) for t in thinking_buffer),
|
||
chars_streamed, saw_think_block,
|
||
)
|
||
if thinking_buffer:
|
||
_dump(request_id, "thinking_buffer", "".join(thinking_buffer))
|
||
return "".join(body_buffer)
|
||
_log_stream_exit("stream_ollama", request_id, deltas_received, chars_streamed, saw_done, parse_failures)
|
||
if thinking_buffer:
|
||
_dump(request_id, "thinking_buffer", "".join(thinking_buffer))
|
||
return "".join(body_buffer)
|
||
|
||
|
||
def _ms(ns) -> str:
|
||
"""Ollama reports durations in nanoseconds. Render as integer ms for logs."""
|
||
if not isinstance(ns, (int, float)):
|
||
return "?"
|
||
return f"{int(ns / 1_000_000)}"
|
||
|
||
|
||
async def _stream_openai_compat(request_id: str, base_url: str, model: str,
|
||
system: str, user_request: str, images: list[dict],
|
||
api_key: str | None = None,
|
||
is_ollama: bool = False) -> str:
|
||
content = []
|
||
for img in images:
|
||
data = img.get("data")
|
||
media = img.get("media_type") or "image/jpeg"
|
||
if not data:
|
||
continue
|
||
content.append({
|
||
"type": "image_url",
|
||
"image_url": {"url": f"data:{media};base64,{data}"},
|
||
})
|
||
content.append({"type": "text", "text": user_request})
|
||
|
||
messages = [
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": content if images else user_request},
|
||
]
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"stream": True,
|
||
"max_tokens": _MAX_TOKENS,
|
||
}
|
||
if is_ollama:
|
||
# Ollama-served reasoning models burn 5-20k tokens on <think>
|
||
# blocks per turn. We're feeding curated bundle data, not asking
|
||
# the model to derive anything — disable reasoning. Field is
|
||
# Ollama-specific; OpenAI/Anthropic reject unknown payload keys.
|
||
payload["think"] = False
|
||
# Match the native /api/chat path — bump num_ctx so our system
|
||
# prompt + bio + bundles + thinking aren't truncated to 2048.
|
||
# qwen3-vl supports 256K natively; 32K is comfortable headroom.
|
||
# repeat_penalty=1.3: breaks comma-list degenerate loops on
|
||
# thin-context calls (see _stream_ollama_native for the
|
||
# observed-in-wild cause).
|
||
payload["options"] = {"num_ctx": 32768, "repeat_penalty": 1.3}
|
||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||
|
||
logger.info("stream_openai_compat[%s] url=%s model=%s images=%d user_chars=%d",
|
||
request_id, f"{base_url}/chat/completions", model, len(images), len(user_request))
|
||
chars_streamed = 0
|
||
deltas_received = 0
|
||
saw_done = False
|
||
parse_failures = 0
|
||
body_buffer: list[str] = []
|
||
thinking_buffer: list[str] = []
|
||
# Most OpenAI-compat servers stream ~1 token per delta chunk; counting
|
||
# reasoning_content deltas is a close (not exact) proxy for token count.
|
||
reasoning_tokens = 0
|
||
saw_think_block = False
|
||
first_event_logged = False
|
||
|
||
timeout = aiohttp.ClientTimeout(connect=_CONNECT_TIMEOUT, sock_read=_READ_TIMEOUT)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.post(f"{base_url}/chat/completions", json=payload, headers=headers) as resp:
|
||
logger.info("stream_openai_compat[%s] HTTP %d", request_id, resp.status)
|
||
if resp.status != 200:
|
||
body = await resp.text()
|
||
logger.warning("stream_openai_compat[%s] error body: %s", request_id, body[:500])
|
||
_emit(request_id, "error", error=f"HTTP {resp.status}: {body[:200]}")
|
||
return ""
|
||
async for raw in resp.content:
|
||
line = raw.decode("utf-8", errors="replace").strip()
|
||
if not line or not line.startswith("data:"):
|
||
continue
|
||
data_str = line[5:].strip()
|
||
if data_str == "[DONE]":
|
||
saw_done = True
|
||
if thinking_buffer:
|
||
_dump(request_id, "thinking_buffer", "".join(thinking_buffer))
|
||
return "".join(body_buffer)
|
||
if not data_str:
|
||
continue
|
||
try:
|
||
evt = json.loads(data_str)
|
||
except Exception as e:
|
||
parse_failures += 1
|
||
logger.debug("stream_openai_compat[%s] json parse fail: %s | line=%r", request_id, e, data_str[:200])
|
||
continue
|
||
if not first_event_logged:
|
||
first_event_logged = True
|
||
delta_obj = (evt.get("choices") or [{}])[0].get("delta") or {}
|
||
dbg.info("ai[%s] openai-compat first event delta_keys=%s raw=%s",
|
||
request_id, list(delta_obj.keys()),
|
||
_trunc(json.dumps(evt, ensure_ascii=True), 1500))
|
||
choices = evt.get("choices") or []
|
||
if not choices:
|
||
continue
|
||
delta_obj = choices[0].get("delta") or {}
|
||
# DeepSeek-R1, Qwen-Reasoning, vLLM-with-think etc. expose
|
||
# reasoning via `reasoning_content`. Capture and surface as
|
||
# a separate event so the client can show "Reasoning…".
|
||
reasoning = delta_obj.get("reasoning_content") or delta_obj.get("reasoning") or ""
|
||
if reasoning:
|
||
thinking_buffer.append(reasoning)
|
||
reasoning_tokens += 1
|
||
_request_reasoning_chars[request_id] = (
|
||
_request_reasoning_chars.get(request_id, 0) + len(reasoning)
|
||
)
|
||
_emit(request_id, "thinking", content=reasoning, tokens=reasoning_tokens)
|
||
delta = delta_obj.get("content") or ""
|
||
if delta:
|
||
if not saw_think_block and _THINK_OPEN_RE.search(delta):
|
||
saw_think_block = True
|
||
dbg.info("ai[%s] inline <think> block detected in content "
|
||
"(chunk=%r) — body_buffer will contain reasoning",
|
||
request_id, delta[:200])
|
||
deltas_received += 1
|
||
chars_streamed += len(delta)
|
||
body_buffer.append(delta)
|
||
finish = choices[0].get("finish_reason")
|
||
if finish:
|
||
logger.info("stream_openai_compat[%s] finish_reason=%s deltas=%d chars=%d",
|
||
request_id, finish, deltas_received, chars_streamed)
|
||
_log_stream_exit("stream_openai_compat", request_id, deltas_received, chars_streamed, saw_done, parse_failures)
|
||
if thinking_buffer:
|
||
_dump(request_id, "thinking_buffer", "".join(thinking_buffer))
|
||
return "".join(body_buffer)
|
||
|
||
|
||
# ── patch planner ─────────────────────────────────────────────────
|
||
# Single endpoint backing the AI Assistant panel. The model streams
|
||
# internally for the live token counter but returns one round-trip
|
||
# response shaped as sectioned tag output (or natlang prose); the
|
||
# server validates and post-processes before reply.
|
||
|
||
def _patch_system_prompt(bios: list[dict] | None = None,
|
||
has_node_prompt: bool = False,
|
||
prompt_style: str = "tags") -> str:
|
||
bios = bios or []
|
||
has_bio = bool(bios)
|
||
if prompt_style == "natural":
|
||
return _patch_system_prompt_natlang_impl(
|
||
has_bio=has_bio,
|
||
has_node_prompt=has_node_prompt,
|
||
)
|
||
has_default_outfit_slots = any(
|
||
(b.get("default_outfit") or {}).get("slots") for b in bios
|
||
)
|
||
has_override_outfit_slots = any(
|
||
(b.get("user_requested_outfit") or {}).get("slots") for b in bios
|
||
)
|
||
has_any_outfit_slots = has_default_outfit_slots or has_override_outfit_slots
|
||
multi_char = sum(1 for b in bios if b and b.get("tag")) >= 2
|
||
return _patch_system_prompt_impl(
|
||
has_bio=has_bio,
|
||
has_default_outfit_slots=has_default_outfit_slots,
|
||
has_override_outfit_slots=has_override_outfit_slots,
|
||
has_any_outfit_slots=has_any_outfit_slots,
|
||
has_node_prompt=has_node_prompt,
|
||
multi_char=multi_char,
|
||
)
|
||
|
||
|
||
def _patch_system_prompt_natlang_impl(has_bio: bool,
|
||
has_node_prompt: bool = False) -> str:
|
||
"""Natlang-mode system prompt for checkpoints trained on natural-
|
||
language captions (z-image, Qwen-Image, etc.). Mirrors the tag-mode
|
||
sectioned output structure — same `// Section:` headers, same order
|
||
— but section bodies are prose paragraphs instead of comma-separated
|
||
tag tokens. Negatives stay tag-shaped (template authoring convention,
|
||
downstream conditioning expectation)."""
|
||
parts = [
|
||
"You are writing a Stable Diffusion prompt for the user. "
|
||
"The target checkpoint is trained on natural-language captions, "
|
||
"not Danbooru tags — section bodies are descriptive PROSE, not "
|
||
"comma-separated tag lists.",
|
||
"",
|
||
]
|
||
if has_node_prompt:
|
||
parts.extend([
|
||
"PATCH MODE — a non-empty node_prompt is provided in the user "
|
||
"message. You are PATCHING it, not rebuilding it.",
|
||
"- Reproduce every section that exists in node_prompt VERBATIM. "
|
||
"Section headers, ordering, and prose bodies survive unchanged "
|
||
"unless the user's request directly modifies them.",
|
||
"- The ONLY edits you may make are direct consequences of the "
|
||
"user's request (an explicit add, remove, or swap).",
|
||
"- Sentences the user did not mention MUST appear unchanged in "
|
||
"their original section. Sections the user did not mention MUST "
|
||
"appear unchanged in your output.",
|
||
"- Do NOT \"clean up\", \"streamline\", or \"focus\" the existing "
|
||
"prompt. Minimum-change is the rule.",
|
||
"- AUTO-DECOMPOSE: if node_prompt has NO `// Section:` headers "
|
||
"(it's flat prose pasted from somewhere else), distribute its "
|
||
"content across the canonical sections (Character / Outfit / "
|
||
"Pose, Action & Prop / Expression / Setting / Scene) in your "
|
||
"output. The content survives verbatim where possible — you're "
|
||
"just splitting it into the right buckets, not rewording.",
|
||
"",
|
||
])
|
||
parts.extend([
|
||
"Output ONLY the prompt body — no preamble, no explanations, no "
|
||
"markdown fences, no commentary. Output sectioned plain text in this "
|
||
"exact format:",
|
||
"",
|
||
"// Section Header",
|
||
"<prose paragraph describing this section>",
|
||
"",
|
||
"// Next Section Header",
|
||
"<prose paragraph for that section>",
|
||
"",
|
||
"Each section gets:",
|
||
"- A `// Section: <Name>` header line — use the exact header supplied "
|
||
"in the user message bio block, including any ` from Character: <name>` "
|
||
"or `(signature)` suffix.",
|
||
"- A blank line between sections; one prose body per section "
|
||
"(multi-sentence, descriptive).",
|
||
"- Body is PROSE — flowing sentences, not comma-separated tokens. "
|
||
"Do NOT emit Danbooru-canonical tag forms (`cammy_white`, `1girl`, "
|
||
"`presenting_foot`) in section bodies — write the natural-language "
|
||
"equivalent.",
|
||
"",
|
||
"SECTION STRUCTURE for fresh prompts (only emit a section if the "
|
||
"user or bio supplies content for it; do NOT add empty/speculative "
|
||
"sections):",
|
||
" // Character: <Name> (<Series>)",
|
||
" <prose describing the character — appearance, build, "
|
||
"distinguishing features. Weave from the bio's base_natlang field.>",
|
||
" // Outfit: <Outfit Name> from Character: <Character>",
|
||
" <prose describing what they're wearing. Weave from the bio's "
|
||
"outfit_natlang field.>",
|
||
" // Pose: (or `// Pose: <Name> (signature) from Character: <X>` "
|
||
"when a bio matched a named pose — use that exact header verbatim "
|
||
"from the user message bio block)",
|
||
" <prose describing body position, limb arrangement, gestures, "
|
||
"gaze direction, presented body parts, what they're doing, and any "
|
||
"props they're interacting with.>",
|
||
" // Expression",
|
||
" <one-sentence prose describing facial affect ONLY — what the "
|
||
"face is showing emotionally (smiling, smirking, blushing, neutral). "
|
||
"Gaze direction does NOT belong here; it goes in Pose, Action & Prop. "
|
||
"OMIT this section entirely if the user did not mention an expression "
|
||
"or facial affect — do NOT invent one. Bios do not carry expression "
|
||
"fields, so the only valid trigger is the user's request.>",
|
||
" // Setting / Scene",
|
||
" <prose describing the environment, location, mood, lighting, "
|
||
"atmosphere, time of day, weather. OMIT this section entirely if "
|
||
"the user did not mention an environment, location, lighting, or "
|
||
"mood — do NOT invent a scene (no \"dimly lit rooftop\", no \"sunlit "
|
||
"garden\", no \"misty forest\" unless the user asked for one). Bios "
|
||
"do not carry setting fields, so the only valid trigger is the user's "
|
||
"request.>",
|
||
"",
|
||
"ORDER inside the prompt body (top to bottom): character → outfit "
|
||
"→ pose → expression → setting/scene. These five are the only "
|
||
"positive sections that exist. The server may inject an additional "
|
||
"`// Style: <Name>` section after setting; do not emit one yourself. "
|
||
"Do NOT invent any other section header.",
|
||
"",
|
||
"BIO SOURCE PRIORITY:",
|
||
" 1. If a character bio block is provided in the user message, use "
|
||
"its `base_natlang`, `outfit_natlang`, and `pose_natlang` text as the "
|
||
"source of truth. Reproduce the prose verbatim where possible; weave "
|
||
"in user modifications minimally.",
|
||
" 2. For everything else, write a short descriptive prose phrase. "
|
||
"Do not invent details that contradict the bio.",
|
||
"",
|
||
"NEGATIVE PROMPT:",
|
||
"If a Negative Prompt is needed, place it at the very end on its own "
|
||
"line, starting with `Negative Prompt:` followed by a comma-separated "
|
||
"list of canonical-tag tokens (NOT prose). Negatives describe what to "
|
||
"suppress in latent space and are tag-shaped regardless of positive "
|
||
"shape.",
|
||
"",
|
||
"RULES:",
|
||
"- Never invent character or outfit details you aren't sure about. "
|
||
"If unsure, omit.",
|
||
"- Make the minimum change the user requested. Only emit a section "
|
||
"if the user or bio supplies content for it; never speculatively add "
|
||
"a section the user didn't mention.",
|
||
"- Do not include placeholder tags from examples in this prompt as "
|
||
"content.",
|
||
"- 'wearing only X' / 'just X' / 'only X' is a STRIP instruction, NOT "
|
||
"a confirmation. The user wants to REMOVE every other outfit item "
|
||
"and keep ONLY X. Rewrite the // Outfit prose to describe ONLY the "
|
||
"named item(s); the rest is implicitly nude.",
|
||
" * The new Outfit prose must NOT reference any removed items, "
|
||
"even as location markers or anchors. Wrong: 'red socks visible "
|
||
"above the open tops of the boots' (boots are removed). Right: "
|
||
"'Wearing only red socks; otherwise nude.'",
|
||
" * The new Outfit prose must NOT enumerate what was removed "
|
||
"(\"the leotard, beret, etc. are all removed\"). Just describe the "
|
||
"current state.",
|
||
" * CASCADE: if Outfit was stripped, audit the // Pose, Action & "
|
||
"Prop and // Expression and // Setting / Scene sections. Any "
|
||
"sentence that references a now-removed outfit item (boots, "
|
||
"leotard, beret, gloves, harness, etc.) MUST be rewritten to drop "
|
||
"that reference. Example: pose said 'feet visible through the open "
|
||
"tops of her combat boots'; outfit now has no boots; pose should "
|
||
"be rewritten to 'feet visible' (or whatever the action is) "
|
||
"without the boot anchor. The pose stays — only the removed-item "
|
||
"anchors get stripped.",
|
||
"",
|
||
])
|
||
if has_bio:
|
||
parts.append(
|
||
"- A character bio is attached below. Treat its natlang fields "
|
||
"(`base_natlang`, outfit `natlang`, pose `natlang`) as "
|
||
"authoritative — use the prose verbatim where the user's request "
|
||
"doesn't require modification."
|
||
)
|
||
parts.extend(["", "/no_think"])
|
||
return "\n".join(parts)
|
||
|
||
|
||
def _patch_system_prompt_impl(has_bio: bool,
|
||
has_default_outfit_slots: bool,
|
||
has_override_outfit_slots: bool,
|
||
has_any_outfit_slots: bool,
|
||
has_node_prompt: bool = False,
|
||
multi_char: bool = False) -> str:
|
||
"""Sectioned plain-text output. No JSON, no tools. The patch UX
|
||
(remove/add/keep chips) is derived client-side by diffing the new
|
||
output against existing editor content."""
|
||
parts = [
|
||
"You are writing a Stable Diffusion prompt for the user.",
|
||
"",
|
||
]
|
||
if has_node_prompt:
|
||
# The model's biggest failure mode in patch flow is dropping
|
||
# untouched sections — it reads "only emit a section if the user
|
||
# supplies content" (a build-mode rule) and inverts it into "drop
|
||
# any section the user didn't mention this turn." Lead with an
|
||
# explicit patch-mode rule so untouched sections survive.
|
||
parts.extend([
|
||
"PATCH MODE — a non-empty node_prompt is provided in the user "
|
||
"message. You are PATCHING it, not rebuilding it.",
|
||
"- Reproduce every section that exists in node_prompt VERBATIM. "
|
||
"Sections, headers, ordering, and tags survive unchanged unless the "
|
||
"user's request directly modifies them.",
|
||
"- The ONLY edits you may make are direct consequences of the "
|
||
"user's request (an explicit add, remove, or swap).",
|
||
"- Tags the user did not mention MUST appear unchanged in their "
|
||
"original section. Sections the user did not mention MUST appear "
|
||
"unchanged in your output.",
|
||
"- Do NOT \"clean up\", \"streamline\", or \"focus\" the existing "
|
||
"prompt. Minimum-change is the rule.",
|
||
"- This rule overrides the section-template guidance below — "
|
||
"the templates are for FRESH prompts only.",
|
||
"- AUTO-DECOMPOSE: if node_prompt has NO `// Section:` headers "
|
||
"(it's a flat tag list pasted from somewhere else), distribute "
|
||
"its tokens across the canonical sections (Character / Outfit / "
|
||
"Pose, Action & Prop / Expression / Setting / Scene) in your "
|
||
"output. Tokens survive verbatim — you're just routing each "
|
||
"token to the right section based on what it describes.",
|
||
"",
|
||
])
|
||
else:
|
||
# BUILD MODE symmetric to the PATCH MODE block above. qwen3-vl:8b
|
||
# routinely speculates // Pose, // Setting / Scene, and // Quality
|
||
# on bare requests like "cammy white" or "set up cammy_white",
|
||
# even though the inline "only if supplied" rule says not to.
|
||
# Lead with an explicit no-speculation rule so the inline rule
|
||
# isn't the only line of defense.
|
||
parts.extend([
|
||
"BUILD MODE — node_prompt is empty. You are composing a FRESH "
|
||
"prompt from scratch using the user's request and the character "
|
||
"bio (if provided).",
|
||
"- Emit ONLY sections the user explicitly asked for, plus // "
|
||
"Character and // Outfit when a character bio is attached.",
|
||
"- DO NOT speculate. If the user said \"cammy white\", emit // "
|
||
"Character and // Outfit (from bio) — nothing else. Do not invent "
|
||
"// Pose, // Expression, // Setting / Scene, or // Quality "
|
||
"content the user did not request.",
|
||
"- // Style is server-managed — do not emit a // Style section "
|
||
"unless the user explicitly named a style. The server injects "
|
||
"the model's default style template automatically.",
|
||
"- // Quality is user-owned and OPTIONAL. Emit it only when the "
|
||
"user explicitly named quality tokens (e.g. `add masterpiece, "
|
||
"absurdres`). Generic SD prompting tokens like `highres`, "
|
||
"`detailed`, `sharp focus`, `cinematic lighting` are NOT defaults "
|
||
"you should add on your own — they belong only when the user "
|
||
"asked.",
|
||
"",
|
||
])
|
||
parts.extend([
|
||
"Output ONLY the prompt body — no preamble, no explanations, no markdown "
|
||
"fences, no commentary. Output sectioned plain text in this exact format:",
|
||
"",
|
||
"// Section Header",
|
||
"tag1, tag2, tag3, ...",
|
||
"",
|
||
"// Next Section Header",
|
||
"tag1, tag2, ...",
|
||
"",
|
||
"Each section gets:",
|
||
"- A `// Section: <Name>` header line",
|
||
"- A blank line is fine between sections; one comma-separated tag line per section.",
|
||
"- Use Danbooru-canonical underscored tag forms (lowercase, words joined by `_`). "
|
||
"The output post-processor converts to spaces if the target model wants spaces.",
|
||
"- Weighted form `(tag:1.1)` is a single literal token — preserve verbatim.",
|
||
"",
|
||
"SECTION STRUCTURE for fresh prompts (only emit a section if the user or bio supplies "
|
||
"content for it; do NOT add empty/speculative sections):",
|
||
" // Character: <name from bio>",
|
||
" <subject count, weighted character tag, appearance tags>",
|
||
" // Outfit: <outfit name>",
|
||
" <outfit tags — clothing items, garments, footwear, accessories, leotards, etc>",
|
||
" // Pose, Action & Prop (comma-separated canonical danbooru tags — covers body "
|
||
"position, limb arrangement, gestures, where the head/eyes are directed, presented "
|
||
"body parts, and props the character interacts with. Break the user's natural-language "
|
||
"pose into its component tags; do NOT fuse them into one phrase.)",
|
||
" <pose/action/prop/gaze tags>",
|
||
" // Expression (facial affect ONLY — what the face shows emotionally. Gaze "
|
||
"direction does NOT belong here; it goes in Pose, Action & Prop.)",
|
||
" <facial expression tag>",
|
||
" // Setting / Scene",
|
||
" <scene tags>",
|
||
" // Style: <template name> (optional — user-owned. Carries style "
|
||
"template body like `masterpiece, best quality, very awa` or "
|
||
"`Photorealistic, sharp focus`. Server may inject or override this.)",
|
||
" <style tags>",
|
||
" // Quality (optional — user-owned. Quality/aesthetic tokens "
|
||
"the user wants applied to the whole image.)",
|
||
" <quality tags>",
|
||
"",
|
||
"ORDER inside the prompt body (top to bottom):",
|
||
" character → outfit → pose+action+prop → expression → setting → style → quality.",
|
||
"Do NOT invent sections outside this list. Do NOT emit "
|
||
"negative-prompt-style slop in any positive section.",
|
||
"",
|
||
"PATCH-MODE PRESERVATION for // Style and // Quality:",
|
||
"- These sections are user-owned content. When node_prompt has them "
|
||
"and the user's request doesn't ask to change them, REPRODUCE them "
|
||
"VERBATIM in your output (header + body). Don't strip them.",
|
||
"- Only modify them if the user explicitly asks (`switch style to "
|
||
"hyperrealistic`, `add absurdres`, etc.). The server handles style "
|
||
"alias resolution and template injection separately — your job is "
|
||
"just to preserve the user's existing content.",
|
||
"",
|
||
"TAG SOURCE PRIORITY:",
|
||
" 1. If a character bio is provided in the user message, use its base_tags "
|
||
"and outfit tags VERBATIM. Do not paraphrase, drop, or rename them.",
|
||
" 2. For everything else, use canonical Danbooru tag forms you know "
|
||
"(underscored, lowercase). Prefer the canonical tag over a paraphrase.",
|
||
" 3. If a concept doesn't have a clean canonical form, write it as a short "
|
||
"natural-language phrase. Mixing canonical Danbooru tags with short phrasal "
|
||
"tokens is fine — modern SD models are trained on both styles.",
|
||
"",
|
||
"RULES:",
|
||
"- Never invent character canonical tags you aren't sure about. If unsure, omit.",
|
||
"- A weighted form `(tag:1.1)` is one atomic token. Reproduce it VERBATIM, parens and all. "
|
||
"Never strip the parens to write `tag:1.1`, never split it into `tag, 1.1`, never reweight it.",
|
||
"- Make the minimum change the user requested. Only emit a section if the user or "
|
||
"bio supplies content for it; never speculatively add a section the user didn't "
|
||
"mention. When the user supplies a value for a section, emit only what they said — "
|
||
"do not pad with adjacent details they didn't ask for.",
|
||
"- Do not include placeholder tags from examples in this prompt as content.",
|
||
"",
|
||
])
|
||
if multi_char:
|
||
# Patch flow keeps the `// Character: name` / `// Outfit: name`
|
||
# output structure (no SUBJECT_N labels), so the aggregate count
|
||
# token lives in the FIRST character's section and shared
|
||
# sections live ONCE at the end after all per-character blocks.
|
||
parts.extend([
|
||
"",
|
||
"MULTI-CHARACTER COMPOSITION (this turn has 2+ character bios):",
|
||
"- Subject count: AGGREGATE across characters in the OUTPUT. Each "
|
||
"bio's `subject count:` slot in the user message is THAT character's "
|
||
"individual count (1girl/1boy). The composition needs ONE aggregate "
|
||
"token: 1girl × 2 → `2girls`; 1girl + 1boy → `1boy, 1girl`; "
|
||
"2girls + 1boy → `1boy, 2girls`; 1boy × 2 → `2boys`.",
|
||
"- Place the aggregate count token in the FIRST character's "
|
||
"`// Character` section (replacing the per-character `1girl`/`1boy`). "
|
||
"Do NOT include `1girl`/`1boy` in subsequent characters' "
|
||
"`// Character` sections — the aggregate covers everyone.",
|
||
"- Per-character sections — `// Character` and `// Outfit` are emitted "
|
||
"PER CHARACTER. Each character's `// Character` body uses that "
|
||
"character's `character tag:` + `appearance tags:` slots.",
|
||
"- SHARED sections — emit ONCE at the END after ALL per-character "
|
||
"blocks: ONE `// Pose, Action & Prop` for the interaction, ONE "
|
||
"`// Expression` (or omit entirely if not implied), ONE `// Setting "
|
||
"/ Scene`. Do NOT duplicate these sections per character.",
|
||
"- Output order for multi-character: `// Character: A` → "
|
||
"`// Outfit: A` → `// Character: B` → `// Outfit: B` → "
|
||
"`// Pose, Action & Prop` → `// Expression` → `// Setting / Scene`.",
|
||
"- OUTFIT BORROW (this rule OVERRIDES BOTH the per-character "
|
||
"rule above AND PATCH MODE's verbatim-preservation rule for the "
|
||
"// Outfit section): if a bio is marked `Outfit Source: <name>` "
|
||
"OR the user's request asks for one character to WEAR ANOTHER "
|
||
"CHARACTER'S OUTFIT (e.g. `cammy in chun-li's outfit`, `change "
|
||
"outfit to chun-li's outfit`, `wearing chun-li's clothes`), this "
|
||
"is NOT multi-character. Do ALL of the following:",
|
||
" 1. Keep ONLY the primary character's `// Character` section "
|
||
"unchanged. Do NOT emit the source character's `// Character` "
|
||
"section.",
|
||
" 2. REPLACE the `// Outfit: <old name>` header with "
|
||
"`// Outfit: <source outfit name> from Character: <source "
|
||
"character tag>` — this is a REQUIRED header rename, not "
|
||
"preservation. The old outfit name (e.g. `Delta Red`) MUST be "
|
||
"replaced with the source outfit's name (e.g. `SF2 Classic`).",
|
||
" 3. REPLACE the // Outfit body tokens with the SOURCE "
|
||
"character's outfit slot tags VERBATIM (each slot's tag becomes "
|
||
"a token in // Outfit). The primary character's old outfit "
|
||
"tokens MUST be removed.",
|
||
" 4. Do NOT compute an aggregate count — the prompt is still "
|
||
"single-subject (`1girl`/`1boy`, not `2girls`).",
|
||
" 5. Do NOT use the source's appearance tags (hair, eyes, "
|
||
"build) — only their outfit.",
|
||
])
|
||
parts.extend([
|
||
"",
|
||
"SLOT-AWARE CONFLICT RESOLUTION:",
|
||
"- The user message includes an `Available slot modifiers` block. For each row, "
|
||
"use your own semantic understanding to decide whether the user's request implies "
|
||
"that modifier — match by intent, not by exact phrase. For each modifier you decide applies:",
|
||
" 1. If the row says `ADD <tag> to // Outfit` or `ADD <tag> to // Pose, Action & Prop`, "
|
||
"add that exact canonical_tag to the named section verbatim (do not paraphrase).",
|
||
" 2. If the row says `also ADD <tag> to // Outfit`, additionally add that tag to // Outfit.",
|
||
" 3. Drop every outfit slot phrase whose slot appears in the modifier's `clears` list. "
|
||
"(No-op if there's no slot-decomposed outfit in the bio.)",
|
||
"- For a color/style swap stated as `<color> <item>` in the user request:",
|
||
" 1. Find the outfit slot whose item matches and ADD `<color>_<item>` to // Outfit.",
|
||
" 2. Drop the displaced source_phrase from // Outfit.",
|
||
"- When a decomposed intent is tagged `strip:` (the user wants the named garment to "
|
||
"BE the entire outfit — no accessories, no base layer), the user message will only "
|
||
"show the user's named item(s); the bio's default-outfit slot list is suppressed so "
|
||
"you don't accidentally emit it. Just write the named item(s) into // Outfit. The "
|
||
"server auto-negates the displaced default-outfit phrases.",
|
||
"- When a decomposed intent is tagged `outfit:` and the user's named garment "
|
||
"occupies one slot of the bio's layered outfit (leotard, gloves, boots, etc.), emit "
|
||
"the user's item AND keep the other bio slots that aren't displaced — gloves/boots/"
|
||
"headwear stack on top of a leotard.",
|
||
"- If no conflict touches a filled slot, leave the outfit alone.",
|
||
])
|
||
if has_any_outfit_slots:
|
||
if has_default_outfit_slots:
|
||
parts.extend([
|
||
"",
|
||
"OUTFIT HEADER: keep the // Outfit: <name> section header matching the outfit's "
|
||
"name from the bio. Do NOT rename the header based on user modifications "
|
||
"(color swaps, modifiers, etc.).",
|
||
"(The Negative Prompt section for default-outfit drops is handled deterministically "
|
||
"by the server — you don't need to emit one. Just drop displaced phrases from "
|
||
"// Outfit per the rules above.)",
|
||
])
|
||
parts.extend(["", "/no_think"])
|
||
if has_bio:
|
||
parts.append(
|
||
"- A character bio is attached below. Treat its tags as authoritative — "
|
||
"use them verbatim instead of re-deriving, EXCEPT where slot-aware conflict "
|
||
"resolution requires dropping a tag (see above)."
|
||
)
|
||
return "\n".join(parts)
|
||
|
||
|
||
def _strip_leading_section_header(prose: str) -> str:
|
||
"""Some curated natlang fields begin with a `// Section: ...` line
|
||
from the source format. The server now constructs section headers
|
||
itself in `_patch_user_message_natlang`, so strip the duplicate
|
||
when present to avoid emitting two consecutive header lines."""
|
||
if not prose:
|
||
return prose
|
||
lines = prose.splitlines()
|
||
if lines and lines[0].lstrip().startswith("//"):
|
||
return "\n".join(lines[1:]).lstrip()
|
||
return prose
|
||
|
||
|
||
def _build_natlang_slot_conflicts(user_request: str,
|
||
bios: list[dict] | None,
|
||
node_prompt: str = "") -> str:
|
||
"""Generate a slot-conflict-resolution block for the natlang user
|
||
message. Two directions:
|
||
|
||
Forward: user fires a modifier (e.g. `barefoot` clears [footwear,
|
||
legwear]) → tell model to drop matching outfit slots from // Outfit.
|
||
|
||
Reverse: user fills a slot (e.g. `wearing red socks` fills legwear)
|
||
→ that displaces a modifier WHOSE PHRASES ALREADY APPEAR IN
|
||
`node_prompt`. Only fire when the prior prose actually contains the
|
||
modifier's alias (`barefoot`, `nude`, etc.) — otherwise the alert
|
||
instructs the model to scrub phrases that aren't there, which is
|
||
cheating: the system prompt would tell the model about modifiers
|
||
that have never been part of state.
|
||
|
||
Returns empty string when no conflict applies. Surfaced ONLY in
|
||
natlang mode — tag mode handles both directions at token level via
|
||
`_drop_displaced_modifiers` and `_enforce_applies_modifiers`."""
|
||
if not bios or not user_request:
|
||
return ""
|
||
detected = _detect_modifiers_in_text(user_request)
|
||
displaced_canonicals = _resolve_slot_displacements(
|
||
user_request, node_prompt=node_prompt,
|
||
)
|
||
user_filled_slots = _detect_user_filled_slots(user_request)
|
||
if not detected and not displaced_canonicals:
|
||
return ""
|
||
blocks: list[str] = []
|
||
|
||
# Forward: modifier fired → drop matching outfit slots.
|
||
for b in bios:
|
||
outfit = b.get("user_requested_outfit") or b.get("default_outfit")
|
||
if not outfit:
|
||
continue
|
||
slots = outfit.get("slots") or []
|
||
if not slots:
|
||
continue
|
||
display = (b.get("display") or "").strip() or b.get("tag") or ""
|
||
char_clears: dict[str, list[dict]] = {}
|
||
for mod in detected:
|
||
cleared = [s.strip().lower() for s in (mod.get("clears_slots") or []) if s.strip()]
|
||
if not cleared:
|
||
continue
|
||
for slot_row in slots:
|
||
slot_name = (slot_row.get("slot") or "").strip().lower()
|
||
if slot_name in cleared:
|
||
char_clears.setdefault(mod["canonical_tag"], []).append(slot_row)
|
||
if not char_clears:
|
||
continue
|
||
for canonical_tag, matched_slots in char_clears.items():
|
||
phrase_list = ", ".join(
|
||
f"`{(s.get('source_phrase') or s.get('item') or '').strip()}`"
|
||
+ (f" ({(s.get('color') or '').strip()})"
|
||
if s.get('color') else "")
|
||
for s in matched_slots
|
||
if (s.get('source_phrase') or s.get('item'))
|
||
)
|
||
slot_set = sorted({(s.get("slot") or "").strip().lower()
|
||
for s in matched_slots
|
||
if s.get("slot")})
|
||
blocks.append(
|
||
f"- Character `{display}`: user fired modifier "
|
||
f"`{canonical_tag}` which clears slots [{', '.join(slot_set)}]. "
|
||
f"REMOVE all references to these items from the // Outfit "
|
||
f"prose: {phrase_list}. Keep all other outfit items intact."
|
||
)
|
||
|
||
# Reverse: user filled a slot → modifier displaced. Surface its
|
||
# alias phrases so the model can scrub them from any prose section
|
||
# (typically // Pose where the previous turn may have written
|
||
# 'bare feet prominently displayed' or similar implying barefoot).
|
||
if displaced_canonicals:
|
||
modifiers_by_canon = {
|
||
m["canonical_tag"]: m for m in _load_slot_modifiers()
|
||
}
|
||
for canonical_tag in sorted(displaced_canonicals):
|
||
mod = modifiers_by_canon.get(canonical_tag)
|
||
if not mod:
|
||
continue
|
||
aliases = mod.get("aliases") or []
|
||
if canonical_tag.replace("_", " ") not in aliases:
|
||
aliases = [canonical_tag.replace("_", " ")] + list(aliases)
|
||
phrase_quoted = ", ".join(f"'{a}'" for a in aliases)
|
||
slot_set = sorted(user_filled_slots) if user_filled_slots else []
|
||
blocks.append(
|
||
f"- User added an item filling slot(s) "
|
||
f"[{', '.join(slot_set)}], which displaces modifier "
|
||
f"`{canonical_tag}`. Scan ALL prose sections (especially "
|
||
f"// Pose) and REMOVE any sentence or clause referring to "
|
||
f"{phrase_quoted} — the character is no longer in that "
|
||
f"state. Also remove descriptive phrases like 'feet "
|
||
f"prominently displayed', 'soles visible', 'toes spread' "
|
||
f"that implied the now-displaced modifier."
|
||
)
|
||
|
||
if not blocks:
|
||
return ""
|
||
header = (
|
||
"SLOT CONFLICT ALERT (resolve outfit/pose contradictions before "
|
||
"writing your output):"
|
||
)
|
||
return header + "\n" + "\n".join(blocks)
|
||
|
||
|
||
def _patch_user_message_natlang(node_prompt: str, user_request: str,
|
||
bios: list[dict] | None = None) -> str:
|
||
"""Natlang-mode user message. Surfaces bio fields in the same
|
||
`// Section: <Name>` format the model is expected to mirror back —
|
||
`// Character: <display> (<series>)`, `// Outfit: <name> from
|
||
Character: <display>`, `// Pose: <name>(signature) from Character:
|
||
<display>`. Section bodies are the curated prose from the DB.
|
||
|
||
Format consistency between input bio block and expected model output
|
||
reduces drift; model learns the exact section header by example."""
|
||
sections: list[str] = []
|
||
if bios:
|
||
bio_lines = [
|
||
"Character bios from local database. Each `// Section:` block "
|
||
"below is a header you MUST mirror in your output (verbatim, "
|
||
"including any `from Character:` and `(signature)` suffixes), "
|
||
"with the prose body either reproduced or minimally modified per "
|
||
"the user's request:",
|
||
]
|
||
for b in bios:
|
||
if not b or not b.get("tag"):
|
||
continue
|
||
display = (b.get("display") or "").strip() or b["tag"]
|
||
series = (b.get("series") or "").strip()
|
||
char_header = (
|
||
f"// Character: {display}"
|
||
+ (f" ({series})" if series else "")
|
||
)
|
||
base_nat = _strip_leading_section_header(
|
||
(b.get("base_natlang") or "").strip()
|
||
)
|
||
base_tags = (b.get("base_tags") or "").strip()
|
||
bio_lines.append("")
|
||
bio_lines.append(char_header)
|
||
if base_nat:
|
||
bio_lines.append(base_nat)
|
||
elif base_tags:
|
||
# Fallback: bio has only tag form. Surface tags as a
|
||
# comma-joined English list and trust the model to
|
||
# naturalize. Curate base_natlang to fix.
|
||
bio_lines.append(
|
||
f"(no natlang available, derived from tags: {base_tags})"
|
||
)
|
||
outfit = b.get("user_requested_outfit") or b.get("default_outfit")
|
||
if outfit:
|
||
outfit_name = (outfit.get("name") or "").strip()
|
||
outfit_nat = _strip_leading_section_header(
|
||
(outfit.get("natlang") or "").strip()
|
||
)
|
||
outfit_tags = (outfit.get("tags") or "").strip()
|
||
outfit_header = (
|
||
f"// Outfit: {outfit_name} from Character: {display}"
|
||
if outfit_name
|
||
else f"// Outfit from Character: {display}"
|
||
)
|
||
bio_lines.append("")
|
||
bio_lines.append(outfit_header)
|
||
if outfit_nat:
|
||
bio_lines.append(outfit_nat)
|
||
elif outfit_tags:
|
||
bio_lines.append(
|
||
f"(no natlang available, derived from tags: "
|
||
f"{outfit_tags})"
|
||
)
|
||
pose = b.get("matched_pose")
|
||
if pose:
|
||
pose_name = (pose.get("name") or "").strip()
|
||
pose_nat = _strip_leading_section_header(
|
||
(pose.get("natlang") or "").strip()
|
||
)
|
||
pose_tags = (pose.get("tags") or "").strip()
|
||
signature_suffix = (
|
||
" (signature)" if pose.get("is_signature") else ""
|
||
)
|
||
pose_header = (
|
||
f"// Pose: {pose_name}{signature_suffix} "
|
||
f"from Character: {display}"
|
||
if pose_name
|
||
else f"// Pose from Character: {display}"
|
||
)
|
||
bio_lines.append("")
|
||
bio_lines.append(pose_header)
|
||
if pose_nat:
|
||
bio_lines.append(pose_nat)
|
||
elif pose_tags:
|
||
bio_lines.append(
|
||
f"(no natlang available, derived from tags: "
|
||
f"{pose_tags})"
|
||
)
|
||
sections.append("\n".join(bio_lines))
|
||
|
||
conflict_block = _build_natlang_slot_conflicts(user_request, bios, node_prompt)
|
||
if conflict_block:
|
||
sections.append(conflict_block)
|
||
|
||
if node_prompt:
|
||
sections.append(
|
||
"Existing node_prompt (modify this prose; preserve sentences "
|
||
"you aren't asked to change):\n" + node_prompt
|
||
)
|
||
|
||
sections.append(f"User request:\n{user_request}")
|
||
return "\n\n".join(sections)
|
||
|
||
|
||
# Subject-count tokens (1girl, 2boys, solo, multiple_girls, etc.). When
|
||
# multi-character bios are sent to the patch flow we slice this slot
|
||
# out of base_tags so the LLM sees a labeled `subject count:` line per
|
||
# character and aggregates across them ("1girl × 2 → 2girls", "1girl
|
||
# + 1boy → 1boy, 1girl") instead of emitting per-character `1girl`
|
||
# tokens that bypass the multi-char composition signal in the latent
|
||
# space.
|
||
_SUBJECT_COUNT_RE = re.compile(
|
||
r"^(?:\d+(?:girls?|boys?|others?)|solo|solo_focus|multiple_(?:girls|boys|others))$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _split_base_tags(base_tags: str) -> dict:
|
||
"""Slice `base_tags` into {character, subject, appearance} buckets.
|
||
|
||
- character: weighted form like `(cammy_white:1.1)` — one atomic token
|
||
- subject: subject-count tokens (`1girl`, `2girls`, `solo`, ...)
|
||
- appearance: everything else (hair, eyes, build, etc.)"""
|
||
out = {"character": [], "subject": [], "appearance": []}
|
||
if not base_tags:
|
||
return out
|
||
weighted_re = re.compile(r":\s*\d+(?:\.\d+)?\s*\)$")
|
||
for raw in base_tags.split(","):
|
||
tok = raw.strip()
|
||
if not tok:
|
||
continue
|
||
if tok.startswith("(") and tok.endswith(")") and weighted_re.search(tok):
|
||
out["character"].append(tok)
|
||
continue
|
||
if _SUBJECT_COUNT_RE.match(tok):
|
||
out["subject"].append(tok)
|
||
continue
|
||
out["appearance"].append(tok)
|
||
return out
|
||
|
||
|
||
_NUM_GENDER_COUNT_RE = re.compile(
|
||
r"^(\d+)(girls?|boys?|others?)$", re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _compute_aggregate_count(bios: list[dict]) -> list[str]:
|
||
"""Canonical Danbooru-format aggregate subject count tokens for the
|
||
composition. Sums per-bio counts from each bio's subject slot
|
||
(parsed from base_tags). Order: boys, girls, others — Danbooru
|
||
convention puts boys first. Examples: 1boy + 1girl -> ['1boy',
|
||
'1girl']; 2 girls -> ['2girls']; 1 boy + 2 girls -> ['1boy', '2girls']."""
|
||
girls = 0
|
||
boys = 0
|
||
others = 0
|
||
for b in bios or []:
|
||
if not b or not b.get("tag"):
|
||
continue
|
||
slots = _split_base_tags(b.get("base_tags") or "")
|
||
for tok in slots["subject"]:
|
||
m = _NUM_GENDER_COUNT_RE.match(tok.strip())
|
||
if not m:
|
||
continue
|
||
n = int(m.group(1))
|
||
kind = m.group(2).lower()
|
||
if "girl" in kind:
|
||
girls += n
|
||
elif "boy" in kind:
|
||
boys += n
|
||
elif "other" in kind:
|
||
others += n
|
||
parts: list[str] = []
|
||
for n, sing, plur in (
|
||
(boys, "1boy", f"{boys}boys"),
|
||
(girls, "1girl", f"{girls}girls"),
|
||
(others, "1other", f"{others}others"),
|
||
):
|
||
if n == 1:
|
||
parts.append(sing)
|
||
elif n > 1:
|
||
parts.append(plur)
|
||
return parts
|
||
|
||
|
||
def _strip_franchise_tokens_from_scene_style(
|
||
sections: list[dict], request_id: str,
|
||
) -> list[dict]:
|
||
"""Drop franchise/series/IP tokens from // Setting / Scene and
|
||
// Style section bodies. The patch model frequently emits a
|
||
franchise name from its world knowledge (primed by a bio's
|
||
canonical tag like `(ryu_(street_fighter):1.1)`) and stuffs it
|
||
into the scene/style section even after the user_request line
|
||
has been franchise-stripped. Belt-and-suspenders deterministic
|
||
post-process. Matches franchises >= 6 chars to avoid generic
|
||
short-name false positives."""
|
||
franchises = _load_known_franchise_names()
|
||
if not franchises:
|
||
return sections
|
||
target_keys = {"setting", "scene", "style"}
|
||
long_franchises = [f for f in franchises if f and len(f) >= 6]
|
||
if not long_franchises:
|
||
return sections
|
||
out: list[dict] = []
|
||
dropped: list[str] = []
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
out.append(s)
|
||
continue
|
||
section_key = _section_key_from_header(s.get("header") or "")
|
||
if section_key not in target_keys:
|
||
out.append(s)
|
||
continue
|
||
kept: list[str] = []
|
||
for t in s.get("tokens") or []:
|
||
t_norm = re.sub(r"[\s_]+", " ", (t or "").strip().lower())
|
||
if not t_norm:
|
||
continue
|
||
is_franchise = False
|
||
for f in long_franchises:
|
||
if t_norm == f or f in t_norm:
|
||
is_franchise = True
|
||
break
|
||
if is_franchise:
|
||
dropped.append(f"[{section_key}] {t}")
|
||
else:
|
||
kept.append(t)
|
||
out.append({**s, "tokens": kept})
|
||
if dropped:
|
||
dbg.info(
|
||
"ai-patch[%s] franchise-strip output: %s",
|
||
request_id, ", ".join(dropped),
|
||
)
|
||
return out
|
||
|
||
|
||
_CHAR_HEADER_RE = re.compile(
|
||
r"^\s*//\s*Character\s*:\s*(?P<name>.+?)\s*$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
_WEIGHTED_TAG_RE = re.compile(
|
||
r"\(\s*(?P<inner>[^()]*(?:\([^()]*\)[^()]*)*?)\s*:\s*[0-9.]+\s*\)",
|
||
)
|
||
|
||
|
||
def _bio_weighted_tag_for(canon: str, bios: list[dict]) -> str | None:
|
||
"""Look up the weighted character tag form (`(canon:1.1)`) for a
|
||
character canonical from the bios list. Returns None if the
|
||
canonical doesn't match any bio."""
|
||
canon_lc = (canon or "").strip().lower()
|
||
if not canon_lc:
|
||
return None
|
||
for b in bios or []:
|
||
bio_canon = (b.get("tag") or "").strip().lower()
|
||
if bio_canon != canon_lc:
|
||
continue
|
||
slots = _split_base_tags(b.get("base_tags") or "")
|
||
if slots["character"]:
|
||
return slots["character"][0]
|
||
return None
|
||
|
||
|
||
_CHAR_HEADER_PREFIX_RE = re.compile(r"^\s*//\s*character\s*:", re.IGNORECASE)
|
||
_OUTFIT_HEADER_PREFIX_RE = re.compile(r"^\s*//\s*outfit\s*[:]", re.IGNORECASE)
|
||
|
||
|
||
def _reorder_multi_char_sections(
|
||
sections: list[dict], bios: list[dict], request_id: str,
|
||
) -> list[dict]:
|
||
"""Multi-char structural reorder: ensure each `// Outfit: <canon>`
|
||
immediately follows its `// Character: <canon>` section. The 8B
|
||
patch model occasionally clusters all character sections first,
|
||
then all outfit sections (despite the system prompt's interleave
|
||
rule). Without this fix the downstream `BREAK` insertion in
|
||
`_enforce_multi_char_composition` lands between the two char
|
||
sections but leaves no separator between the second character
|
||
and the first outfit — sagat's chunk pulls in ryu's outfit when
|
||
the prompt is flat-rendered.
|
||
|
||
Pairs by canonical-name substring match first (header normalized
|
||
via `_normalize_separators`, longest-canon first to disambiguate
|
||
`cammy_white` from `cammy`). Then falls back to matching the
|
||
outfit header against each bio's `default_outfit.name` /
|
||
`user_requested_outfit.name` — the system prompt instructs the
|
||
model to emit `// Outfit: <outfit name>` (just the outfit name,
|
||
not the character canon prefix), so canonical-name matching alone
|
||
misses real production output. Outfit sections matching neither
|
||
flow through as "other" sections — partial pairing beats nothing.
|
||
|
||
Also fires for single-char output: when the model emits sections
|
||
in non-canonical order (e.g. // Character at the bottom after a
|
||
swap), this places it first. Other sections retain their relative
|
||
order."""
|
||
if not sections or not bios:
|
||
return sections
|
||
|
||
bio_canons = [
|
||
_normalize_separators(b.get("tag") or "")
|
||
for b in bios if b and b.get("tag")
|
||
]
|
||
bio_canons = sorted({c for c in bio_canons if c}, key=len, reverse=True)
|
||
if not bio_canons:
|
||
return sections
|
||
|
||
# Build outfit-name → bio-canon map. The patch model emits
|
||
# `// Outfit: Classic Gi` (just the outfit name from the system-
|
||
# prompt format), so we need a second matching axis beyond bio
|
||
# canonical. Each bio's outfit name (default or user-requested) is
|
||
# collected; longest-name first to bias toward more specific matches.
|
||
outfit_name_to_canon: list[tuple[str, str]] = []
|
||
for b in bios or []:
|
||
if not b:
|
||
continue
|
||
canon = _normalize_separators(b.get("tag") or "")
|
||
if not canon:
|
||
continue
|
||
for outfit_key in ("user_requested_outfit", "default_outfit"):
|
||
outfit = b.get(outfit_key) or {}
|
||
name = (outfit.get("name") or "").strip()
|
||
if name:
|
||
outfit_name_to_canon.append((_normalize_separators(name), canon))
|
||
outfit_name_to_canon.sort(key=lambda p: len(p[0]), reverse=True)
|
||
|
||
char_sections: list[tuple[dict, str]] = []
|
||
outfit_by_canon: dict[str, dict] = {}
|
||
unmatched_outfits: list[dict] = []
|
||
other_sections: list[dict] = []
|
||
|
||
def _match_canon_in_header(header: str) -> str:
|
||
h_norm = _normalize_separators(header)
|
||
for c in bio_canons:
|
||
if c in h_norm:
|
||
return c
|
||
return ""
|
||
|
||
def _match_outfit_to_canon(header: str) -> str:
|
||
h_norm = _normalize_separators(header)
|
||
for name, canon in outfit_name_to_canon:
|
||
if name and name in h_norm:
|
||
return canon
|
||
return ""
|
||
|
||
for s in sections:
|
||
header = s.get("header") or ""
|
||
if _CHAR_HEADER_PREFIX_RE.match(header):
|
||
char_sections.append((s, _match_canon_in_header(header)))
|
||
continue
|
||
if _OUTFIT_HEADER_PREFIX_RE.match(header):
|
||
canon = _match_canon_in_header(header) or _match_outfit_to_canon(header)
|
||
if canon and canon not in outfit_by_canon:
|
||
outfit_by_canon[canon] = s
|
||
else:
|
||
# Either no canon match (model emitted an outfit name
|
||
# that doesn't appear in any bio's outfit name and lacks
|
||
# a canon prefix in the header) OR duplicate canon
|
||
# (model paraphrased both outfits to similar names).
|
||
# Held aside for emission-order fallback below.
|
||
unmatched_outfits.append(s)
|
||
continue
|
||
other_sections.append(s)
|
||
|
||
if not char_sections:
|
||
return sections
|
||
|
||
out: list[dict] = []
|
||
used: set[str] = set()
|
||
paired = 0
|
||
unpaired_chars: list[dict] = []
|
||
for s, canon in char_sections:
|
||
out.append(s)
|
||
if canon and canon in outfit_by_canon and canon not in used:
|
||
out.append(outfit_by_canon[canon])
|
||
used.add(canon)
|
||
paired += 1
|
||
else:
|
||
unpaired_chars.append(s)
|
||
|
||
# Emission-order fallback: when name-matching couldn't pair every
|
||
# outfit (model paraphrased outfit names away from the bio form,
|
||
# e.g. bio says `Classic Muay Thai (Purple)` but model emits
|
||
# `// Outfit: Muay Thai Shorts`), pair the leftover outfits to
|
||
# characters that didn't get an outfit, in emission order. The
|
||
# 8B model emits outfit-A before outfit-B in the same order it
|
||
# emitted char-A before char-B, so positional pairing is reliable
|
||
# in practice. Without this fallback the cluster shape (char-A,
|
||
# char-B, outfit-A, outfit-B) survives whenever the model
|
||
# paraphrases outfit names.
|
||
if unmatched_outfits and unpaired_chars:
|
||
# Rebuild `out` so each unpaired char gets its positional outfit
|
||
# inserted right after it.
|
||
char_to_outfit_fallback: dict[int, dict] = {}
|
||
for idx, char_s in enumerate(unpaired_chars):
|
||
if idx >= len(unmatched_outfits):
|
||
break
|
||
char_to_outfit_fallback[id(char_s)] = unmatched_outfits[idx]
|
||
rebuilt: list[dict] = []
|
||
consumed_fallbacks: set[int] = set()
|
||
for s in out:
|
||
rebuilt.append(s)
|
||
fb = char_to_outfit_fallback.get(id(s))
|
||
if fb is not None and id(fb) not in consumed_fallbacks:
|
||
rebuilt.append(fb)
|
||
consumed_fallbacks.add(id(fb))
|
||
paired += 1
|
||
out = rebuilt
|
||
# Any unmatched outfits beyond the unpaired-char count still
|
||
# need to flow through somewhere — append at the end.
|
||
for s in unmatched_outfits:
|
||
if id(s) not in consumed_fallbacks:
|
||
out.append(s)
|
||
else:
|
||
# No fallback needed: dump any leftover unmatched outfits at the
|
||
# end (this matches the original behavior).
|
||
out.extend(unmatched_outfits)
|
||
|
||
out.extend(other_sections)
|
||
|
||
if out != sections:
|
||
dbg.info(
|
||
"ai-patch[%s] multi-char reorder: paired %d char/outfit, "
|
||
"char_sections=%d outfit_sections=%d",
|
||
request_id, paired, len(char_sections),
|
||
len(outfit_by_canon) + len(unmatched_outfits),
|
||
)
|
||
return out
|
||
|
||
|
||
def _enforce_multi_char_composition(
|
||
sections: list[dict], bios: list[dict], request_id: str,
|
||
) -> list[dict]:
|
||
"""Deterministic post-process for multi-character output:
|
||
1. Compute the canonical aggregate count from bios (server-side
|
||
truth, not model-emitted).
|
||
2. Drop ALL subject-count tokens (1girl/1boy/2girls/solo/etc.)
|
||
from every // Character section.
|
||
3. Inject the aggregate count as the FIRST token of the FIRST
|
||
// Character section. Subsequent character sections have no
|
||
count tokens (the aggregate covers everyone).
|
||
4. Insert `BREAK` separator at chunk boundaries: prepended to
|
||
each // Character section after the first, and prepended to
|
||
the first non-character/outfit section that follows the last
|
||
character block. The compiler's _smart_join recognizes
|
||
`BREAK` and surrounds it with spaces in the assembled flat
|
||
prompt — text encoders chunk on it and stop attribute bleed
|
||
(char A's hair color leaking onto char B's tags). Bare form
|
||
matches the A1111 / dfl-clip-with-break / asagi4 prompt-
|
||
control convention; bracketed `[BREAK]` is recognized by
|
||
nothing in the wild.
|
||
5. Repair character-tag copy-paste errors: the patch model
|
||
occasionally emits char A's weighted tag (`(ryu:1.1)`) in
|
||
char B's // Character section body. The header is
|
||
authoritative — drop any weighted tag whose canonical
|
||
doesn't match the section's header canonical, and inject
|
||
the correct weighted tag from the matching bio if missing.
|
||
Single-char output (bios < 2): no aggregate to compute, no chunk
|
||
boundary needed. Strip any stale `BREAK` tokens left over from
|
||
a prior multi-char turn — `BREAK` is a chunk separator for
|
||
multi-character text-encoder slicing and has no role in single-
|
||
character output."""
|
||
if not sections or not bios:
|
||
return sections
|
||
|
||
# Count how many // Character sections the model actually emitted.
|
||
# If fewer than bios, the model did NOT compose multi-character —
|
||
# most commonly because the user said something like
|
||
# `change outfit to chun-li's outfit` which matched 2 character
|
||
# tokens (cammy_white + chun-li) in the request but is semantically
|
||
# an outfit-borrow on a single subject. Injecting `2girls` aggregate
|
||
# + BREAK in that case produces a phantom-second-girl prompt.
|
||
# Bail to the single-char scrub path.
|
||
emitted_char_sections = sum(
|
||
1 for s in sections
|
||
if not s.get("is_negative")
|
||
and (s.get("header") or "").lower().startswith("// character")
|
||
)
|
||
single_char_mode = len(bios) < 2 or emitted_char_sections < len(bios)
|
||
if single_char_mode:
|
||
if len(bios) >= 2 and emitted_char_sections < len(bios):
|
||
dbg.info(
|
||
"ai-patch[%s] multi-char composer bailed: bios=%d but only "
|
||
"%d // Character section(s) emitted — treating as single-char "
|
||
"output (likely outfit-borrow, not multi-subject)",
|
||
request_id, len(bios), emitted_char_sections,
|
||
)
|
||
out: list[dict] = []
|
||
scrubbed = 0
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
out.append(s)
|
||
continue
|
||
tokens = s.get("tokens") or []
|
||
kept = [t for t in tokens if t.strip().upper() != "BREAK"]
|
||
if len(kept) != len(tokens):
|
||
scrubbed += len(tokens) - len(kept)
|
||
out.append({**s, "tokens": kept})
|
||
else:
|
||
out.append(s)
|
||
if scrubbed:
|
||
dbg.info(
|
||
"ai-patch[%s] single-char: stripped %d stale BREAK token(s)",
|
||
request_id, scrubbed,
|
||
)
|
||
return out
|
||
|
||
aggregate_tokens = _compute_aggregate_count(bios)
|
||
|
||
out: list[dict] = []
|
||
seen_first_char = False
|
||
inserted_pre_shared = False
|
||
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
out.append(s)
|
||
continue
|
||
header_lc = (s.get("header") or "").lower()
|
||
is_char = header_lc.startswith("// character")
|
||
is_outfit = header_lc.startswith("// outfit")
|
||
is_char_block = is_char or is_outfit
|
||
|
||
new_tokens = list(s.get("tokens") or [])
|
||
|
||
if is_char:
|
||
# Strip subject counts from EVERY character section. They
|
||
# only live in the first one as the aggregate.
|
||
new_tokens = [
|
||
t for t in new_tokens
|
||
if not _SUBJECT_COUNT_RE.match(t.strip())
|
||
]
|
||
|
||
# Repair character-tag copy-paste: the model sometimes
|
||
# emits char A's `(canonA:1.1)` in char B's // Character
|
||
# section. The section HEADER (// Character: <canon>) is
|
||
# authoritative — strip any weighted tag whose canonical
|
||
# doesn't match the header, then inject the correct one
|
||
# from the bio if missing.
|
||
header_match = _CHAR_HEADER_RE.match(s.get("header") or "")
|
||
section_canon = (
|
||
header_match.group("name").strip().lower()
|
||
if header_match else ""
|
||
)
|
||
if section_canon:
|
||
expected_weighted = _bio_weighted_tag_for(section_canon, bios)
|
||
expected_canon_lc = section_canon
|
||
kept_after_repair: list[str] = []
|
||
injected_weighted = False
|
||
for t in new_tokens:
|
||
m = _WEIGHTED_TAG_RE.fullmatch(t.strip())
|
||
if m:
|
||
inner_lc = m.group("inner").strip().lower()
|
||
if inner_lc == expected_canon_lc:
|
||
kept_after_repair.append(t)
|
||
injected_weighted = True
|
||
# else: drop wrong-character weighted tag
|
||
else:
|
||
kept_after_repair.append(t)
|
||
if not injected_weighted and expected_weighted:
|
||
# Re-insert correct weighted tag at the front
|
||
kept_after_repair = (
|
||
[expected_weighted] + kept_after_repair
|
||
)
|
||
new_tokens = kept_after_repair
|
||
|
||
if not seen_first_char:
|
||
new_tokens = aggregate_tokens + new_tokens
|
||
seen_first_char = True
|
||
else:
|
||
if not new_tokens or new_tokens[0] != "BREAK":
|
||
new_tokens = ["BREAK"] + new_tokens
|
||
|
||
elif seen_first_char and not is_char_block and not inserted_pre_shared:
|
||
if not new_tokens or new_tokens[0] != "BREAK":
|
||
new_tokens = ["BREAK"] + new_tokens
|
||
inserted_pre_shared = True
|
||
|
||
new_section = {**s, "tokens": new_tokens}
|
||
out.append(new_section)
|
||
|
||
if aggregate_tokens or out != sections:
|
||
dbg.info(
|
||
"ai-patch[%s] multi-char compose: aggregate=%r "
|
||
"char_sections=%d break_inserted=%s",
|
||
request_id, ", ".join(aggregate_tokens),
|
||
sum(1 for s in out
|
||
if (s.get("header") or "").lower().startswith("// character")),
|
||
inserted_pre_shared,
|
||
)
|
||
return out
|
||
|
||
|
||
def _patch_user_message(node_prompt: str, user_request: str,
|
||
bios: list[dict] | None = None,
|
||
sub_intents: list[dict] | None = None,
|
||
tag_candidates: list[dict] | None = None,
|
||
prompt_style: str = "tags",
|
||
modify_outfit_hint: str = "") -> str:
|
||
if prompt_style == "natural":
|
||
return _patch_user_message_natlang(
|
||
node_prompt, user_request, bios=bios,
|
||
)
|
||
sections: list[str] = []
|
||
# Suppress the bio's default-outfit slot list ONLY when decompose
|
||
# tagged the user's garment as `strip:` (semantic outfit-replace —
|
||
# `wearing only X` or single-piece outfits like nightgown/kimono/
|
||
# hospital gown). For `outfit:` intents (slot replacement — leotard,
|
||
# gloves, boots), the bio default stays visible so the patch model
|
||
# can swap the named slot and keep the rest of the layered outfit.
|
||
# user_requested_outfit (a curated outfit the matcher picked from
|
||
# "killer bee outfit" etc.) is NEVER suppressed — that IS what the
|
||
# user asked for.
|
||
user_strips_outfit = bool(sub_intents) and any(
|
||
(i.get("section") or "").lower() == "strip"
|
||
for i in (sub_intents or [])
|
||
)
|
||
|
||
# Patch-mode outfit-preservation: when node_prompt has an existing
|
||
# // Outfit body AND the user's request has no outfit-related
|
||
# sub-intent (no `outfit:` / `strip:`), suppress the bio's
|
||
# default_outfit so the patch model doesn't see new-character
|
||
# outfit data and overwrite the user's existing outfit. Triggered
|
||
# by character-swap requests like `switch character to mythra` —
|
||
# decompose produces `[clear] character` + `[character] mythra`,
|
||
# no outfit intent, but mythra's bio carries her default outfit
|
||
# which would otherwise replace the user's curated outfit.
|
||
has_outfit_intent = bool(sub_intents) and any(
|
||
(i.get("section") or "").lower() in ("outfit", "strip")
|
||
for i in (sub_intents or [])
|
||
)
|
||
existing_outfit_body = bool(re.search(
|
||
r"^\s*//\s*Outfit\s*:[^\n]*\n[^\n/]+",
|
||
node_prompt or "", re.MULTILINE | re.IGNORECASE,
|
||
))
|
||
patch_preserves_outfit = (
|
||
bool(node_prompt) and existing_outfit_body and not has_outfit_intent
|
||
)
|
||
if bios:
|
||
# Multi-character bios get the slot-split treatment so the LLM can
|
||
# aggregate subject count across characters per the system prompt
|
||
# rule. Single-character keeps the legacy `base_tags: ...` shape
|
||
# — no aggregation to do, no benefit to a slot split that costs
|
||
# tokens for no reason.
|
||
multi_char = sum(1 for b in (bios or []) if b and b.get("tag")) >= 2
|
||
bio_lines = ["Character bios from local database — use these tags VERBATIM:"]
|
||
for b in bios:
|
||
if not b or not b.get("tag"):
|
||
continue
|
||
is_outfit_source = bool(b.get("_outfit_source_only"))
|
||
if is_outfit_source:
|
||
# Outfit-borrow source: render WITHOUT appearance tags so
|
||
# the model can't mistake this character for a subject.
|
||
# Only their outfit slots are useful here. The OUTFIT
|
||
# BORROW system prompt instruction tells the model to
|
||
# use these slots under // Outfit: <name> from Character:
|
||
# <tag> on the primary subject — not as a second
|
||
# // Character section.
|
||
bio_lines.append(
|
||
f"\nOutfit Source: {b['tag']} "
|
||
f"[reference only — use this character's outfit slots "
|
||
f"on the primary subject; do NOT emit a // Character "
|
||
f"section for {b['tag']}]"
|
||
)
|
||
else:
|
||
bio_lines.append(f"\nCharacter: {b['tag']}")
|
||
base = (b.get("base_tags") or "").strip()
|
||
if is_outfit_source:
|
||
# Skip appearance/subject tags entirely — they're not
|
||
# used for borrow.
|
||
pass
|
||
elif base and multi_char:
|
||
split = _split_base_tags(base)
|
||
if split["subject"]:
|
||
bio_lines.append(f" subject count: {', '.join(split['subject'])}")
|
||
if split["character"]:
|
||
bio_lines.append(f" character tag: {', '.join(split['character'])}")
|
||
if split["appearance"]:
|
||
bio_lines.append(f" appearance tags: {', '.join(split['appearance'])}")
|
||
elif base:
|
||
bio_lines.append(f" base_tags: {base}")
|
||
outfit = b.get("user_requested_outfit") or b.get("default_outfit")
|
||
is_user_requested_outfit = bool(b.get("user_requested_outfit"))
|
||
suppress_default_slots = (
|
||
(user_strips_outfit or patch_preserves_outfit)
|
||
and not is_user_requested_outfit
|
||
)
|
||
if suppress_default_slots:
|
||
# Bio's default outfit is the FALLBACK when the user
|
||
# doesn't describe an outfit — but the user IS describing
|
||
# one this turn. Showing the slot list anyway nudges the
|
||
# model to emit those tokens alongside the user's named
|
||
# item, which is exactly the failure we're avoiding.
|
||
outfit = None
|
||
if outfit and outfit.get("tags"):
|
||
outfit_label = "user-requested outfit" if is_user_requested_outfit else "default outfit"
|
||
outfit_name = outfit.get("name") or ""
|
||
slots = outfit.get("slots") or []
|
||
# Slots are authoritative when present — emit them in place of
|
||
# the flat outfit_tags blob to avoid the model cross-checking
|
||
# two representations of the same data.
|
||
if slots:
|
||
bio_lines.append(
|
||
f" {outfit_label}{f' ({outfit_name})' if outfit_name else ''} (slot-decomposed; this IS the outfit):"
|
||
)
|
||
for s in slots:
|
||
slot = s.get("slot") or "?"
|
||
phrase = s.get("source_phrase") or ""
|
||
item = s.get("item") or ""
|
||
color = s.get("color") or ""
|
||
attrs = ", ".join(p for p in (f"color={color}" if color else "", f"item={item}" if item else "") if p)
|
||
bio_lines.append(
|
||
f" {slot}: {phrase}" + (f" [{attrs}]" if attrs else "")
|
||
)
|
||
else:
|
||
bio_lines.append(
|
||
f" {outfit_label}{f' ({outfit_name})' if outfit_name else ''}: "
|
||
f"{outfit['tags']}"
|
||
)
|
||
pose = b.get("matched_pose")
|
||
if pose and pose.get("tags"):
|
||
pose_name = pose.get("name") or ""
|
||
bio_lines.append(
|
||
f" user-requested pose{f' ({pose_name})' if pose_name else ''}: "
|
||
f"{pose['tags']}"
|
||
)
|
||
sections.append("\n".join(bio_lines))
|
||
if node_prompt:
|
||
sections.append("Existing node_prompt (modify this; preserve sections you aren't asked to change):\n" + node_prompt)
|
||
# Send the full modifier table to the AI (not just alias-matched rows)
|
||
# so it can semantically match user intent — e.g. "pointing her feet at
|
||
# viewer" → presenting_foot — without us maintaining an exhaustive alias
|
||
# list. The AI's language understanding bridges paraphrase to canonical.
|
||
# Sent regardless of whether a character bio is present: even freestyle
|
||
# prompts benefit from the substitute-tag mapping (the slot-clearing
|
||
# behavior just no-ops when there's no outfit to clear).
|
||
all_modifiers = _load_slot_modifiers()
|
||
if all_modifiers:
|
||
# Two-stage gating to prevent the "rule-text-as-content" failure
|
||
# mode where listing all 7 modifiers with their full rule blocks
|
||
# caused the model to parrot slot names ("footwear, legwear") and
|
||
# rule keywords ("pose, action, prop, gaze") back as tags.
|
||
#
|
||
# Stage 1 — alias scan (deterministic): word-boundary regex match
|
||
# on each modifier's curated aliases. Hits are [APPLIES]: model
|
||
# MUST follow the rule.
|
||
#
|
||
# Stage 2 — bi-encoder semantic match (bge-small over the wiki
|
||
# gloss): top-k modifiers above cosine 0.65 are [SEMANTIC]
|
||
# candidates. Calibrated against real and adversarial prompts —
|
||
# foot-presenting paraphrases score 0.64-0.83 and distractors
|
||
# ("girl walking through forest") top out at 0.50-0.58.
|
||
#
|
||
# Modifiers that match NEITHER stage are completely omitted from
|
||
# the prompt — no rule text for them at all. Cuts the modifier
|
||
# block from ~21 lines down to 0-6 lines depending on relevance,
|
||
# which is what stopped the parroting.
|
||
detected = _detect_modifiers_in_text(user_request)
|
||
applies_by_tag = {d["canonical_tag"]: d for d in detected}
|
||
if applies_by_tag:
|
||
dbg.info(
|
||
"patch[modifiers] alias-detected: %s",
|
||
", ".join(f"{t} (matched {d['matched_alias']!r})"
|
||
for t, d in applies_by_tag.items()),
|
||
)
|
||
|
||
from . import modifier_search
|
||
# 0.65 was noise-level — bge-small cosine similarity starts being
|
||
# meaningful around 0.75+. Marginal scores like 0.653 surfaced
|
||
# `presenting_foot` for unrelated requests like 'enrich scene
|
||
# with cyberpunk elements', which 8B then dutifully applied.
|
||
# Direct phrase mentions ('presenting her foot') still get caught
|
||
# by alias-detected (the [APPLIES] path), independent of this
|
||
# semantic threshold.
|
||
semantic_hits = modifier_search.search(user_request, top_k=2, threshold=0.75)
|
||
semantic_by_tag = {h["canonical_tag"]: h for h in semantic_hits
|
||
if h["canonical_tag"] not in applies_by_tag}
|
||
if semantic_by_tag:
|
||
dbg.info(
|
||
"patch[modifiers] semantic-matched: %s",
|
||
", ".join(f"{t} ({h['score']:.3f})"
|
||
for t, h in semantic_by_tag.items()),
|
||
)
|
||
|
||
def _action_clause(m: dict) -> str:
|
||
parts: list[str] = []
|
||
if m["is_substitute"]:
|
||
section = "// Pose, Action & Prop" if m["substitute_section"] == "pose" else "// Outfit"
|
||
parts.append(f"ADD `{m['canonical_tag']}` to {section}")
|
||
slots = ", ".join(m["clears_slots"])
|
||
if slots:
|
||
parts.append(f"clears [{slots}]")
|
||
if m["implies_outfit_tag"]:
|
||
parts.append(f"also ADD `{m['implies_outfit_tag']}` to // Outfit")
|
||
if not m["is_substitute"] and not parts:
|
||
parts.append("no substitute")
|
||
return "; ".join(parts)
|
||
|
||
# Preserve the canonical sort_order from the DB while filtering
|
||
# to only the gated subset. Keeps consistent ordering across
|
||
# requests so cache-hit potential is not poisoned by set order.
|
||
shown = [m for m in all_modifiers
|
||
if m["canonical_tag"] in applies_by_tag
|
||
or m["canonical_tag"] in semantic_by_tag]
|
||
if shown:
|
||
mod_lines = [
|
||
"Slot modifiers — entries marked [APPLIES] already matched a phrase "
|
||
"in your request and MUST be followed verbatim. Entries marked "
|
||
"[SEMANTIC] are candidates whose definition is semantically close to "
|
||
"your request — apply only if your request actually implies them. "
|
||
"Modifiers not listed here are not relevant; do not invent them.",
|
||
]
|
||
for m in shown:
|
||
tag = m["canonical_tag"]
|
||
applied = applies_by_tag.get(tag)
|
||
label = "[APPLIES]" if applied else "[SEMANTIC]"
|
||
header = f" {label} {tag}"
|
||
if applied:
|
||
header += f" (matched: \"{applied['matched_alias']}\")"
|
||
mod_lines.append(header)
|
||
gloss = _neutralize_gloss(m.get("definition") or "")
|
||
if gloss:
|
||
mod_lines.append(f" Definition: {gloss}")
|
||
verb = "Action:" if applied else "Action if applies:"
|
||
mod_lines.append(f" {verb} {_action_clause(m)}")
|
||
sections.append("\n".join(mod_lines))
|
||
# Decomposed sub-intents — surface them so the model knows what
|
||
# we already split out. Helps when picking section assignments for
|
||
# individual tags.
|
||
if sub_intents and len(sub_intents) > 1:
|
||
intent_lines = ["Decomposed visual intents from your request:"]
|
||
for si in sub_intents:
|
||
intent_lines.append(f" • [{si['section']}] {si['text']}")
|
||
sections.append("\n".join(intent_lines))
|
||
|
||
# Split the retrieval menu into three visually-distinct blocks:
|
||
# - Canonical resolutions (resolver output — the LLM proposed these
|
||
# Danbooru tags from the user's described concept and they
|
||
# validated against the tag table). The user did NOT type these
|
||
# words verbatim — these are the *canonical form* for what the
|
||
# user described. Highest priority block, listed first.
|
||
# - Anchor candidates (literal substring matches — `anchor`/`alias`).
|
||
# The user actually typed these words. Strong literal evidence.
|
||
# - Semantic candidates (bge-small wiki retrieval). Possibly
|
||
# related; only apply if their definition fits user intent.
|
||
# The split (instead of one merged "Direct text matches" block)
|
||
# exists because the merged label LIED about resolver outputs:
|
||
# `focus on feet` resolves to `foot_focus` but the user never wrote
|
||
# `foot_focus` — when the model parsed "user wrote these words" it
|
||
# disqualified the canonical and emitted the user's literal
|
||
# `focus_on_feet` instead, which trace-check then dropped. Splitting
|
||
# gives each channel a label that's actually true.
|
||
# 3000-char total budget across all three blocks.
|
||
if tag_candidates:
|
||
canonical_block: list[str] = []
|
||
direct_block: list[str] = []
|
||
semantic_block: list[str] = []
|
||
budget = 3000
|
||
used = 0
|
||
for c in tag_candidates:
|
||
tag = c.get("tag") or ""
|
||
if not tag:
|
||
continue
|
||
gloss = _neutralize_gloss(c.get("body_summary") or "")
|
||
line = f" • {tag} — {gloss}" if gloss else f" • {tag}"
|
||
if used + len(line) > budget:
|
||
break
|
||
used += len(line)
|
||
via = c.get("matched_via")
|
||
if via in ("resolved", "resolved-rerank"):
|
||
canonical_block.append(line)
|
||
elif via in ("anchor", "alias"):
|
||
direct_block.append(line)
|
||
else:
|
||
semantic_block.append(line)
|
||
if canonical_block:
|
||
sections.append(
|
||
"Canonical Danbooru tags for your described concepts — "
|
||
"these ARE the tag form for what the user described. Use "
|
||
"these tags VERBATIM even if the user's exact words don't "
|
||
"appear here (e.g. user wrote 'focus on feet' → canonical "
|
||
"tag is `foot_focus`; user wrote 'sitting cross-legged' → "
|
||
"canonical tag is `indian_style`). PREFER these canonical "
|
||
"tags over emitting the user's literal phrasing as a tag:\n"
|
||
+ "\n".join(canonical_block)
|
||
)
|
||
if direct_block:
|
||
sections.append(
|
||
"Direct text matches (the user wrote these words — prefer "
|
||
"these tags when describing the matching concept):\n"
|
||
+ "\n".join(direct_block)
|
||
)
|
||
if semantic_block:
|
||
sections.append(
|
||
"Related candidates (use only if their definition fits the "
|
||
"user's intent — do not pick all of them):\n"
|
||
+ "\n".join(semantic_block)
|
||
)
|
||
|
||
# Strip franchise/series disambiguators from the user_request
|
||
# line before passing to the patch model. The bio resolver
|
||
# already used `from <franchise>` / `(<franchise>)` etc. to
|
||
# match the right canonical character; the patch model only
|
||
# needs to assemble tags. Leaving the franchise reference in
|
||
# primes the model to compound it into synthetic tokens
|
||
# (`<franchise>_battle`, `<franchise>_style`) and emit it as a
|
||
# // Style header. Skipped when the user explicitly asked for
|
||
# franchise visual style (`in the style of X`, `X-style`, etc.)
|
||
# so legitimate franchise-style requests survive.
|
||
cleaned_request = _strip_franchise_for_patch(user_request)
|
||
|
||
# OUTFIT MODIFY MODE: when the user is asking to modify (not replace)
|
||
# the existing // Outfit, surface explicit instructions the model
|
||
# can reason against. Without this hint, patch-mode preservation
|
||
# ('preserve sections you aren't asked to change') wins and the
|
||
# outfit silently no-ops. The hint tells the model: yes, this user
|
||
# IS asking to change // Outfit; here's the base; here's the
|
||
# modifier; emit a modified // Outfit body that keeps the base
|
||
# spirit but applies the modifier.
|
||
if modify_outfit_hint:
|
||
sections.append(modify_outfit_hint)
|
||
|
||
sections.append(f"User request:\n{cleaned_request}")
|
||
return "\n\n".join(sections)
|
||
|
||
|
||
_JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*\n?|\n?```\s*$", re.IGNORECASE)
|
||
|
||
|
||
def _strip_json_fences(text: str) -> str:
|
||
"""Strip optional leading/trailing markdown code fences."""
|
||
text = text.strip()
|
||
text = _JSON_FENCE_RE.sub("", text).strip()
|
||
# Some models still emit a closing fence after the json object.
|
||
if text.endswith("```"):
|
||
text = text[:-3].rstrip()
|
||
return text
|
||
|
||
|
||
# ── Query decomposition ───────────────────────────────────────────
|
||
# User requests like "cammy white in an orange leotard sitting with her
|
||
# legs up bare foot spreading her toes and pointing her feet at viewer,
|
||
# background of a dungeon" pack 8+ visual concepts into one sentence.
|
||
# A single bge-small embedding of the whole sentence smears these
|
||
# together — the resulting vector is the "average meaning", missing
|
||
# the long tail of distinct intents.
|
||
#
|
||
# Decomposition: ask the model itself to split the request into atomic
|
||
# visual concepts before retrieval. Each sub-intent then gets its own
|
||
# focused embedding and retrieval pass, results are unioned. Far better
|
||
# coverage than a single full-prompt vector, especially for compound
|
||
# requests. See _harness/probe_qwen_decompose.py for the validation that
|
||
# both 30B and 8B Qwen models produce equivalent decomposition quality.
|
||
|
||
_DECOMPOSE_SYSTEM_PROMPT = (
|
||
"You decompose Stable Diffusion image-generation requests into "
|
||
"atomic typed action lines. Output ONLY a list with one action per "
|
||
"line in the format `section[-action]: text` — no preamble, no "
|
||
"markdown, no commentary, no JSON, no brackets.\n"
|
||
"\n"
|
||
"section is one of: character, outfit, pose, expression, setting, "
|
||
"style, clear.\n"
|
||
"\n"
|
||
"Optional action suffix commits you to a SPECIFIC type of change. "
|
||
"Use it whenever the action is clear — it removes ambiguity from "
|
||
"downstream processing. Outfit actions in particular:\n"
|
||
" - `outfit-swap: <named_outfit>` — switch to a complete named "
|
||
"outfit (`outfit-swap: killer bee`, `outfit-swap: delta red`).\n"
|
||
" - `outfit-fill: <color> <item>` — add a clothing item to its "
|
||
"slot. The user typed an item they want present (`blue socks`, "
|
||
"`red gloves`, `combat boots`). Default for `outfit:` lines when "
|
||
"no action specified.\n"
|
||
" - `outfit-remove: <item>` — REMOVE a specific clothing item "
|
||
"from its slot. Trigger phrases: `no <item>`, `remove the <item>`, "
|
||
"`without <item>`, `drop the <item>`, `take off the <item>`, "
|
||
"`without her <item>`, `kicks off her <item>`. Examples: "
|
||
"`outfit-remove: socks`, `outfit-remove: boots`, `outfit-remove: "
|
||
"gloves`. ALWAYS prefer outfit-remove for negation-of-clothing "
|
||
"phrasings over generic outfit:.\n"
|
||
" - `outfit-modifier: <canonical>` — apply a body-state modifier. "
|
||
"Canonical names: barefoot, topless, bottomless, nude, "
|
||
"completely_nude, no_bra, presenting_foot. Trigger phrases: "
|
||
"`barefoot`, `bare feet`, `topless`, `naked`, `nude`. Examples: "
|
||
"`outfit-modifier: barefoot`, `outfit-modifier: topless`.\n"
|
||
" - `outfit-strip: <kept_item>` — wear ONLY the kept item; "
|
||
"everything else clears. Trigger phrases: `wearing only X`, `just "
|
||
"X`, `nothing but X`, `in a <full_outfit_garment>` (single-piece "
|
||
"garments like nightgown, kimono, bathrobe, wedding dress, towel). "
|
||
"Examples: `outfit-strip: red socks`, `outfit-strip: pink "
|
||
"nightgown`.\n"
|
||
" - `outfit: <text>` (no action) — fallback when the action "
|
||
"isn't clear; downstream parser figures it out. Avoid when you "
|
||
"can use a specific action.\n"
|
||
"\n"
|
||
"Plain `pose:`, `expression:`, `setting:`, `style:`, `character:` "
|
||
"(no action suffix) are the only forms for those sections — they "
|
||
"have just one action shape per section.\n"
|
||
"\n"
|
||
"Rules:\n"
|
||
"- Each line describes ONE visual element (one pose, one "
|
||
"garment, one body-part action, one setting).\n"
|
||
"- Split lists. When the user joins multiple visual elements with "
|
||
"`and`, `with`, `+`, or commas (`legs up and wide`, `kicking and "
|
||
"punching`, `arms raised, fists clenched`), emit each element as "
|
||
"its own intent line. NEVER output a fused phrase containing `and` "
|
||
"between two distinct tags — those become wrong synthetic tokens "
|
||
"like `legs_up_and_wide` downstream. Exception: keep modifiers "
|
||
"attached to their noun (see next rule).\n"
|
||
"- Keep modifiers attached to their noun (`red dress` stays "
|
||
"together; do not split into `red` and `dress`).\n"
|
||
"- Drop pronouns and articles when they don't add meaning "
|
||
"(`her bare feet` → `bare feet`).\n"
|
||
"- If the request is a single atomic concept, output a single line.\n"
|
||
"- Use `pose` for body position, limb arrangement, gestures, "
|
||
"gaze, body-part actions, AND camera framing/focus phrases like "
|
||
"'focus on feet', 'close-up of hands', 'emphasis on X', 'X to "
|
||
"viewer' (the framing concepts the model treats as composition "
|
||
"rather than facial affect).\n"
|
||
"- Outfit changes use typed actions (see above): `outfit-fill` to "
|
||
"add an item, `outfit-remove` to drop a specific item, "
|
||
"`outfit-modifier` to apply a body-state modifier, `outfit-swap` "
|
||
"to change to a named outfit, `outfit-strip` for "
|
||
"wearing-only-X / single-piece-outfit semantics. Pick the action "
|
||
"that fits the user's intent.\n"
|
||
"- DO NOT use `outfit-strip` for body-state modifier words "
|
||
"(barefoot, topless, bottomless, nude, naked, fully clothed) — "
|
||
"those are modifiers, not strip targets. Use `outfit-modifier: "
|
||
"<canonical>`. The modifier IS the change.\n"
|
||
"- DO NOT use `outfit-fill` for negation phrases like "
|
||
"`no socks`, `without gloves`, `remove the boots`. Those are "
|
||
"removals — use `outfit-remove: <item>`.\n"
|
||
"- Use `character` for subject identity, count (1girl, 1boy), "
|
||
"appearance (hair, eyes, body type), AND anatomy modifications to "
|
||
"specific body parts. Phrases like `bigger feet`, `longer hair`, "
|
||
"`larger eyes`, `broader shoulders`, `narrower waist`, `make her "
|
||
"feet bigger`, `give her longer legs`, `her hair is shorter` "
|
||
"describe the character's PHYSICAL FEATURES and route to "
|
||
"`character`. The body part name alone (e.g. `feet`, `hands`) "
|
||
"WITHOUT a size/shape qualifier and WITHOUT a pose verb is NOT "
|
||
"a character intent -- only emit `character:` when a "
|
||
"modification (bigger/smaller/longer/shorter/etc.) is present.\n"
|
||
"- Use `setting` for location, environment, background. Furniture "
|
||
"the subject interacts with is part of `setting`, but the "
|
||
"interaction verb (`sitting on`, `kneeling on`, `leaning against`, "
|
||
"`standing in front of`) is a separate `pose` intent. Split, "
|
||
"don't fuse: `sitting on a couch` is `pose: sitting` + `setting: "
|
||
"couch`, NOT `setting: sitting on a couch`.\n"
|
||
"- Use `expression` ONLY for facial affect (smiling, frowning, "
|
||
"blushing, glaring). Camera/framing concepts are NOT expression.\n"
|
||
"- Use `style` for art-style or render-look phrases: `change style "
|
||
"to X`, `make it X style`, `in the style of X`, `X anime`, `X look`, "
|
||
"`render it like X`. Output the requested style description as the "
|
||
"text after `style:`.\n"
|
||
"- Series, franchise, IP, game-title, or anime-title names that "
|
||
"appear AS CHARACTER DISAMBIGUATION CONTEXT are NOT settings or "
|
||
"styles. Patterns to recognize: `<character> from <franchise>`, "
|
||
"`<character> of <franchise>`, `<character> in <franchise>`, "
|
||
"`<character> (<franchise>)`. The franchise name is identifying "
|
||
"WHICH character — it is not the scene the character is in nor "
|
||
"the visual style of the render. Skip these mentions entirely: "
|
||
"do NOT emit a `setting:` line and do NOT emit a `style:` line "
|
||
"for the franchise/series/IP/game name itself. Only emit a "
|
||
"setting or style intent for a franchise name when the user is "
|
||
"explicitly asking for that franchise's VISUAL STYLE (`render in "
|
||
"the style of X`, `X-style art`, `the X aesthetic`) or for a "
|
||
"SCENE TYPE drawn from that franchise (`a battle in the X arena`, "
|
||
"`the X hub world`). When the franchise is mentioned only as "
|
||
"character context, drop it.\n"
|
||
"- Use `clear` to REMOVE a section entirely. Phrases like "
|
||
"`remove scene`, `no setting`, `clear the expression`, "
|
||
"`remove pose`, `no style`, `take away the background` map to "
|
||
"`clear: <section_name>`. The section_name is the bare word: "
|
||
"scene / setting / expression / pose / style. Use this for "
|
||
"deletion intent — do NOT classify these as set-the-section-to-"
|
||
"the-string-`remove`. `remove scene` is `clear: scene`, never "
|
||
"`setting: remove scene`.\n"
|
||
"\n"
|
||
"Examples:\n"
|
||
"Input: `cammy white in an orange leotard sitting with her legs "
|
||
"up, bare foot, in a dungeon`\n"
|
||
"Output:\n"
|
||
"character: cammy white\n"
|
||
"outfit-fill: orange leotard\n"
|
||
"pose: sitting\n"
|
||
"pose: legs up\n"
|
||
"outfit-modifier: barefoot\n"
|
||
"setting: dungeon\n"
|
||
"\n"
|
||
"Input: `wearing only red socks`\n"
|
||
"Output:\n"
|
||
"outfit-strip: red socks\n"
|
||
"\n"
|
||
"Input: `cammy white in delta red outfit wearing only red socks`\n"
|
||
"Output:\n"
|
||
"character: cammy white\n"
|
||
"outfit-swap: delta red\n"
|
||
"outfit-strip: red socks\n"
|
||
"\n"
|
||
"Input: `cammy in killer bee outfit with legs up and blue socks at viewer`\n"
|
||
"Output:\n"
|
||
"character: cammy\n"
|
||
"outfit-swap: killer bee\n"
|
||
"pose: legs up\n"
|
||
"outfit-fill: blue socks\n"
|
||
"pose: at viewer\n"
|
||
"\n"
|
||
"Input: `no socks`\n"
|
||
"Output:\n"
|
||
"outfit-remove: socks\n"
|
||
"\n"
|
||
"Input: `remove the boots`\n"
|
||
"Output:\n"
|
||
"outfit-remove: boots\n"
|
||
"\n"
|
||
"Input: `without the gloves`\n"
|
||
"Output:\n"
|
||
"outfit-remove: gloves\n"
|
||
"\n"
|
||
"Input: `drop the dress`\n"
|
||
"Output:\n"
|
||
"outfit-remove: dress\n"
|
||
"\n"
|
||
"Input: `change style to hyper realistic anime`\n"
|
||
"Output:\n"
|
||
"style: hyper realistic anime\n"
|
||
"\n"
|
||
"Input: `change to barefoot`\n"
|
||
"Output:\n"
|
||
"outfit-modifier: barefoot\n"
|
||
"\n"
|
||
"Input: `make her bare foot`\n"
|
||
"Output:\n"
|
||
"outfit-modifier: barefoot\n"
|
||
"\n"
|
||
"Input: `tifa kneeling on a balcony at sunset`\n"
|
||
"Output:\n"
|
||
"character: tifa\n"
|
||
"pose: kneeling\n"
|
||
"setting: balcony at sunset\n"
|
||
"\n"
|
||
"Input: `mythra in a pink nightgown`\n"
|
||
"Output:\n"
|
||
"character: mythra\n"
|
||
"outfit-strip: pink nightgown\n"
|
||
"\n"
|
||
"Input: `cammy in an orange leotard`\n"
|
||
"Output:\n"
|
||
"character: cammy\n"
|
||
"outfit-fill: orange leotard\n"
|
||
"\n"
|
||
"Input: `make her topless`\n"
|
||
"Output:\n"
|
||
"outfit-modifier: topless\n"
|
||
"\n"
|
||
"Input: `remove the scene`\n"
|
||
"Output:\n"
|
||
"clear: scene\n"
|
||
"\n"
|
||
"Input: `no expression`\n"
|
||
"Output:\n"
|
||
"clear: expression\n"
|
||
"\n"
|
||
"Input: `remove pose and clear the setting`\n"
|
||
"Output:\n"
|
||
"clear: pose\n"
|
||
"clear: setting\n"
|
||
"\n"
|
||
"Input: `replace legs up with legs up and wide`\n"
|
||
"Output:\n"
|
||
"pose: legs up\n"
|
||
"pose: legs wide\n"
|
||
"\n"
|
||
"Input: `bigger feet`\n"
|
||
"Output:\n"
|
||
"character: bigger feet\n"
|
||
"\n"
|
||
"Input: `make her feet bigger`\n"
|
||
"Output:\n"
|
||
"character: bigger feet\n"
|
||
"\n"
|
||
"Input: `give her longer hair and broader shoulders`\n"
|
||
"Output:\n"
|
||
"character: longer hair\n"
|
||
"character: broader shoulders\n"
|
||
"\n"
|
||
"Input: `change pose to kicking and punching`\n"
|
||
"Output:\n"
|
||
"pose: kicking\n"
|
||
"pose: punching\n"
|
||
"/no_think"
|
||
)
|
||
|
||
|
||
_INGEST_SYSTEM_PROMPT = (
|
||
"You read an existing Stable Diffusion image prompt (already-written "
|
||
"prose) and do TWO jobs:\n"
|
||
" 1. Extract structured facts so they can be edited incrementally.\n"
|
||
" 2. Segment the prose into per-section bodies (verbatim from the "
|
||
"input — no rewording).\n"
|
||
"\n"
|
||
"Output ONLY a list with one entry per line in the format "
|
||
"`field: text` — no preamble, no markdown, no commentary, no JSON, "
|
||
"no brackets.\n"
|
||
"\n"
|
||
"Fact fields (one entry per atomic concept):\n"
|
||
" character / outfit / modifier / pose / expression / setting / style\n"
|
||
"\n"
|
||
"Section-body fields (one entry, copying the user's prose for that "
|
||
"section verbatim):\n"
|
||
" character_body / outfit_body / pose_body / expression_body / "
|
||
"setting_body / style_body\n"
|
||
"\n"
|
||
"Rules for fact fields:\n"
|
||
"- `character`: subject identity (one line). Lowercased underscored "
|
||
"name token (e.g. `cammy_white`).\n"
|
||
"- `outfit`: ONE clothing/accessory item per line. Examples: "
|
||
"`blue leotard`, `red gloves`, `garrison cap`. Skip body-state "
|
||
"modifiers like barefoot/topless — those go on `modifier:` lines.\n"
|
||
"- `modifier`: body-state modifiers like barefoot, topless, "
|
||
"bottomless, nude, naked. ONE per line.\n"
|
||
"- `pose`: body position / limb arrangement / gestures / gaze / "
|
||
"presented body parts / camera framing. ONE atomic concept per line.\n"
|
||
"- `expression`: facial affect ONLY. ONE per line.\n"
|
||
"- `setting`: location/environment/background. ONE per line. Only "
|
||
"if the prose explicitly mentions an environment scene — `seated on "
|
||
"the floor` is pose, not setting.\n"
|
||
"- `style`: art-style or render-look phrases. ONE summary line.\n"
|
||
"\n"
|
||
"Rules for body fields:\n"
|
||
"- Copy the user's prose VERBATIM, character-for-character. "
|
||
"PRESERVE typos (`garisson`, `Atheltic`, `bost`, `expresson`, "
|
||
"`torwards`, `feed` for `feet`), idiosyncratic capitalization "
|
||
"(`Highleg`), and odd word choices. Do NOT auto-correct, do NOT "
|
||
"normalize spelling, do NOT swap synonyms, do NOT 'tidy up' the "
|
||
"prose. If the user wrote `Atheltic`, the body field has "
|
||
"`Atheltic` (not `Athletic`). Treating the user's text as "
|
||
"untouchable is the WHOLE point of the body fields.\n"
|
||
"- The body fields are ONE-LINE strings — replace newlines inside "
|
||
"with spaces.\n"
|
||
"- Omit a body field if that section has no prose in the input.\n"
|
||
"- Skip the Negative Prompt block entirely (handled separately).\n"
|
||
"- Each clause goes in EXACTLY ONE body. If a sentence mixes pose "
|
||
"+ expression (e.g. `sitting with a sultry expression`), put the "
|
||
"pose part in pose_body and the FACIAL-AFFECT part only in "
|
||
"expression_body. Don't duplicate.\n"
|
||
"- expression_body is ONLY the facial-affect phrase (smiling, "
|
||
"frowning, sultry expression, blushing). Hand gestures, gaze "
|
||
"direction, and limb positions are POSE — never copy them into "
|
||
"expression_body.\n"
|
||
"\n"
|
||
"Example:\n"
|
||
"Input:\n"
|
||
" Cammy White from Street Fighter, blonde with twin braids. She's "
|
||
"wearing a green leotard with red gauntlets, brown boots, barefoot. "
|
||
"Sitting with legs up, smiling. In a dungeon. Photorealistic style.\n"
|
||
"Output:\n"
|
||
"character: cammy_white\n"
|
||
"character_body: Cammy White from Street Fighter, blonde with twin braids.\n"
|
||
"outfit: green leotard\n"
|
||
"outfit: red gauntlets\n"
|
||
"outfit: brown boots\n"
|
||
"modifier: barefoot\n"
|
||
"outfit_body: She's wearing a green leotard with red gauntlets, brown boots, barefoot.\n"
|
||
"pose: sitting\n"
|
||
"pose: legs up\n"
|
||
"pose_body: Sitting with legs up.\n"
|
||
"expression: smiling\n"
|
||
"expression_body: smiling.\n"
|
||
"setting: dungeon\n"
|
||
"setting_body: In a dungeon.\n"
|
||
"style: photorealistic\n"
|
||
"style_body: Photorealistic style.\n"
|
||
"\n"
|
||
"Example with mixed pose + expression in one paragraph:\n"
|
||
"Input:\n"
|
||
" She's seated on the floor with legs up, feet pointed at the "
|
||
"viewer, with a sultry expression on her face. One hand is tugging "
|
||
"at her lips.\n"
|
||
"Output:\n"
|
||
"pose: seated on the floor\n"
|
||
"pose: legs up\n"
|
||
"pose: feet pointed at viewer\n"
|
||
"pose: one hand tugging at lips\n"
|
||
"pose_body: She's seated on the floor with legs up, feet pointed "
|
||
"at the viewer, with a sultry expression on her face. One hand is "
|
||
"tugging at her lips.\n"
|
||
"expression: sultry\n"
|
||
"expression_body: sultry expression\n"
|
||
"(Note: pose_body keeps the user's exact paragraph verbatim. "
|
||
"expression_body extracts ONLY the facial-affect phrase, NOT the "
|
||
"hand-tugging gesture or the rest of the pose paragraph.)\n"
|
||
"/no_think"
|
||
)
|
||
|
||
|
||
_INGEST_LINE_RE = re.compile(
|
||
r"^\s*(?:[-*•·]\s*|\d+[.)]\s*)?"
|
||
r"(character|outfit|modifier|pose|expression|setting|style|"
|
||
r"character_body|outfit_body|pose_body|expression_body|"
|
||
r"setting_body|style_body)"
|
||
r"\s*[:|\-\t]\s*"
|
||
r"(.+?)\s*$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
async def _ingest_node_prompt_to_facts(request_id: str, provider: str,
|
||
config: dict,
|
||
node_prompt: str) -> list[dict] | None:
|
||
"""Call the LLM to extract structured facts from existing prose.
|
||
Returns a list of {field, text} dicts or None on failure.
|
||
|
||
Used when a user has hand-written or pasted prose into the editor and
|
||
pcrPromptState is empty — without this, the next /ai/patch call would
|
||
rebuild state from bios + user_request, losing the user's custom facts."""
|
||
if not node_prompt or not node_prompt.strip():
|
||
return []
|
||
# Strip the negative prompt block — those are tag-shaped, not prose facts.
|
||
body = re.split(r"^\s*Negative\s+Prompt\s*:", node_prompt,
|
||
flags=re.IGNORECASE | re.MULTILINE)[0].strip()
|
||
if not body:
|
||
return []
|
||
try:
|
||
raw = await _run_generation(
|
||
f"{request_id}-ingest", provider, config,
|
||
_INGEST_SYSTEM_PROMPT, body, [],
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.exception("ai-patch[%s] ingest call failed", request_id)
|
||
return None
|
||
raw = (raw or "").strip()
|
||
if not raw:
|
||
return None
|
||
cleaned = _strip_json_fences(raw)
|
||
out: list[dict] = []
|
||
for line in cleaned.splitlines():
|
||
m = _INGEST_LINE_RE.match(line)
|
||
if not m:
|
||
continue
|
||
field = m.group(1).lower()
|
||
text = m.group(2).strip().strip("`'\"").strip()
|
||
if not text:
|
||
continue
|
||
text = text.rstrip(",;.")
|
||
if not text:
|
||
continue
|
||
out.append({"field": field, "text": text})
|
||
if not out:
|
||
dbg.info(
|
||
"ai-patch[%s] ingest: no parseable lines, raw=%s",
|
||
request_id, _trunc(cleaned, 800),
|
||
)
|
||
return None
|
||
return out
|
||
|
||
|
||
_DECOMPOSE_LINE_RE = re.compile(
|
||
# Accepts `section: text`, `section-action: text`, `section | text`,
|
||
# or `section\ttext`. Tolerates leading bullets/numbers like `- `
|
||
# or `1. `. Section must be one of the canonical names; optional
|
||
# action suffix lets decompose commit to a specific typed action
|
||
# (`outfit-remove: socks` for ClearSlotDelta, `outfit-modifier:
|
||
# barefoot` for ApplyModifierDelta) instead of the loose
|
||
# `outfit: <text>` that downstream code has to re-parse.
|
||
#
|
||
# Separator narrowed to `:` and `|` and tab — the hyphen is no
|
||
# longer a valid intent separator since it's now used for the
|
||
# action suffix.
|
||
r"^\s*(?:[-*•·]\s*|\d+[.)]\s*)?"
|
||
r"(character|outfit|strip|pose|expression|setting|style|clear)"
|
||
r"(?:-(swap|fill|remove|modifier|strip|clear|chip|bio|set|add))?"
|
||
r"\s*[:|\t]\s*"
|
||
r"(.+?)\s*$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
_franchise_cache: frozenset[str] | None = None
|
||
|
||
|
||
def _load_known_franchise_names() -> frozenset[str]:
|
||
"""Distinct lowercase franchise/series names from the characters
|
||
table. Used to filter decompose sub-intents whose text matches a
|
||
franchise mention (the user said `X from <franchise>` and decompose
|
||
routed `<franchise>` into setting:/style:). Module-level cache —
|
||
the DB is a static asset."""
|
||
global _franchise_cache
|
||
if _franchise_cache is not None:
|
||
return _franchise_cache
|
||
try:
|
||
from .tag_builder import get_db
|
||
db = get_db()
|
||
rows = db.execute(
|
||
"SELECT DISTINCT LOWER(series) FROM characters "
|
||
"WHERE series IS NOT NULL AND series != ''"
|
||
).fetchall()
|
||
_franchise_cache = frozenset(
|
||
(r[0] or "").strip() for r in rows if r and r[0]
|
||
)
|
||
except Exception:
|
||
logger.warning("franchise-filter: series load failed",
|
||
exc_info=True)
|
||
_franchise_cache = frozenset()
|
||
return _franchise_cache
|
||
|
||
|
||
_EXPLICIT_STYLE_RE = re.compile(
|
||
r"\b(in the style of|style of|art style|aesthetic|"
|
||
r"render (it |this |)(in |as |like )|make it look|"
|
||
r"in the look of|-style\b)",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _strip_franchise_for_patch(user_request: str) -> str:
|
||
"""Remove franchise-context phrases (`from <franchise>`, `of
|
||
<franchise>`, `in <franchise>`, `(<franchise>)`) from the user's
|
||
request text before feeding it to the patch model. Only strips
|
||
when the franchise is from the known characters.series set —
|
||
so generic phrases like 'in a forest' aren't affected. Skipped
|
||
entirely when the user has an explicit style cue, preserving
|
||
legitimate franchise-style requests."""
|
||
if not user_request:
|
||
return user_request
|
||
if _EXPLICIT_STYLE_RE.search(user_request):
|
||
return user_request
|
||
franchises = _load_known_franchise_names()
|
||
if not franchises:
|
||
return user_request
|
||
out = user_request
|
||
# Sort longest-first so multi-word franchise names match before
|
||
# any embedded shorter ones.
|
||
for f in sorted((x for x in franchises if x), key=len, reverse=True):
|
||
f_re = re.escape(f)
|
||
patterns = [
|
||
rf"\s*\(\s*{f_re}\s*\)",
|
||
rf"\s+from\s+{f_re}\b",
|
||
rf"\s+of\s+{f_re}\b",
|
||
rf"\s+in\s+{f_re}\b",
|
||
]
|
||
for p in patterns:
|
||
new = re.sub(p, "", out, flags=re.IGNORECASE)
|
||
if new != out:
|
||
out = new
|
||
# Collapse double spaces that may have been left behind.
|
||
out = re.sub(r"\s{2,}", " ", out).strip()
|
||
return out or user_request
|
||
|
||
|
||
def _filter_franchise_setting_style(
|
||
sub_intents: list[dict] | None, user_request: str, request_id: str,
|
||
) -> list[dict] | None:
|
||
"""Drop setting:/style: sub-intents whose text matches a known
|
||
franchise name. Preserves legitimate franchise-style requests
|
||
(`make it street fighter style`) by keeping a `style:` franchise
|
||
intent when the user's original text has an explicit style cue.
|
||
setting: franchise intents are always dropped — users don't
|
||
describe scenes by naming a franchise alone (they'd say `the X
|
||
arena` or `X-themed environment`, which decomposes differently)."""
|
||
if not sub_intents:
|
||
return sub_intents
|
||
franchises = _load_known_franchise_names()
|
||
if not franchises:
|
||
return sub_intents
|
||
has_explicit_style = bool(
|
||
_EXPLICIT_STYLE_RE.search(user_request or "")
|
||
)
|
||
out: list[dict] = []
|
||
dropped: list[str] = []
|
||
for si in sub_intents:
|
||
section = (si.get("section") or "").lower()
|
||
text = (si.get("text") or "").strip().lower()
|
||
if section not in ("setting", "style") or text not in franchises:
|
||
out.append(si)
|
||
continue
|
||
if section == "style" and has_explicit_style:
|
||
out.append(si)
|
||
continue
|
||
dropped.append(f"[{section}] {text}")
|
||
if dropped:
|
||
dbg.info(
|
||
"ai-patch[%s] franchise-filter dropped: %s",
|
||
request_id, ", ".join(dropped),
|
||
)
|
||
return out
|
||
|
||
|
||
_POSE_CHIP_PICKER_SYSTEM = (
|
||
"You pick which curated pose chip best captures the user's pose "
|
||
"intent. Read the user's full request and the numbered candidate "
|
||
"chips — each candidate has its display name, its taxonomic group, "
|
||
"its canonical tags, and its authored description. The chip whose "
|
||
"tags + description most fully cover what the user is describing "
|
||
"wins; partial-coverage chips lose to fuller-coverage chips. "
|
||
"Reply with ONLY the number (`1`, `2`, `3`, ...), or `none` if no "
|
||
"candidate plausibly captures the user's pose. No explanation.\n\n"
|
||
"/no_think"
|
||
)
|
||
|
||
|
||
async def _llm_pick_pose_chip(request_id: str, provider: str,
|
||
config: dict,
|
||
user_request: str,
|
||
candidates: list[dict]) -> dict | None:
|
||
"""Ask the LLM which of the bge-retrieved chip candidates best
|
||
matches the user's pose intent. The chip's authored natlang then
|
||
renders verbatim — the LLM only chooses, it does not author prose.
|
||
|
||
Mirrors tag mode's "retrieve candidates, LLM picks" pattern: bge is
|
||
a sieve, the LLM is the decider. Returns the chosen candidate dict
|
||
or None if the model declined / output unparseable / call failed.
|
||
|
||
The candidate list is presented numbered with each chip's display
|
||
name and authored natlang body — the LLM sees exactly the prose
|
||
that will render if it picks that chip. Choices stay grounded in
|
||
the curated chip table; no chip names or fixture phrases appear
|
||
in the system prompt."""
|
||
if not candidates:
|
||
return None
|
||
# Reorder so signature-group chips (curator-marked as covering a
|
||
# specific named intent — `presenting_feet`, `top-down_bottom-up`,
|
||
# etc.) appear first within the candidate list. This honors the
|
||
# user's taxonomy: `item_group='signature'` means "this chip captures
|
||
# a specific named pose intent, prefer it for nuanced matches".
|
||
# Generic body-position chips (item_group='legs' / 'hands' / 'feet')
|
||
# still appear, just after the signature picks. Within each group
|
||
# cosine order is preserved.
|
||
ordered = sorted(
|
||
candidates,
|
||
key=lambda c: (
|
||
0 if (c.get("item_group") or "").strip().lower() == "signature" else 1,
|
||
-(c.get("adjusted_score") or 0.0),
|
||
),
|
||
)
|
||
options_lines = []
|
||
for i, c in enumerate(ordered, start=1):
|
||
name = (c.get("display_name") or c.get("item_tag") or "").strip()
|
||
natlang = (c.get("base_natlang") or "").strip()
|
||
tags = (c.get("base_tags") or "").strip()
|
||
group = (c.get("item_group") or "").strip()
|
||
group_line = f"\n group: {group}" if group else ""
|
||
tag_line = f"\n tags: {tags}" if tags else ""
|
||
nat_line = f"\n description: {natlang}" if natlang else ""
|
||
options_lines.append(f"{i}. {name}{group_line}{tag_line}{nat_line}")
|
||
user_prompt = (
|
||
f"User request: {user_request}\n\n"
|
||
f"Candidate chips:\n"
|
||
+ "\n".join(options_lines)
|
||
+ "\n\nChoice (number only, or `none`):"
|
||
)
|
||
try:
|
||
raw = await _run_generation(
|
||
f"{request_id}-chip-pick", provider, config,
|
||
_POSE_CHIP_PICKER_SYSTEM, user_prompt, [],
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.exception("ai-patch[%s] chip pick call failed", request_id)
|
||
return None
|
||
raw = (raw or "").strip().lower()
|
||
if not raw:
|
||
dbg.info("ai-patch[%s] chip pick: empty response", request_id)
|
||
return None
|
||
# Find the LAST numeric token / `none` on its own line — the model
|
||
# may include reasoning before the final answer. Search bottom-up.
|
||
final_answer = None
|
||
for line in reversed(raw.splitlines()):
|
||
line = line.strip().strip("`'\".,;:")
|
||
if not line:
|
||
continue
|
||
if line == "none":
|
||
final_answer = "none"
|
||
break
|
||
m = re.fullmatch(r"([0-9]+)", line)
|
||
if m:
|
||
final_answer = m.group(1)
|
||
break
|
||
# Fallback: any number in the raw text (last occurrence) if no
|
||
# bare-answer line was found.
|
||
if final_answer is None:
|
||
nums = re.findall(r"\b([0-9]+)\b", raw)
|
||
if "none" in raw and not nums:
|
||
final_answer = "none"
|
||
elif nums:
|
||
final_answer = nums[-1]
|
||
if final_answer is None or final_answer == "none":
|
||
dbg.info(
|
||
"ai-patch[%s] chip pick: none (final=%r raw[:80]=%r)",
|
||
request_id, final_answer, raw[:80],
|
||
)
|
||
return None
|
||
idx = int(final_answer) - 1
|
||
if 0 <= idx < len(ordered):
|
||
chosen = ordered[idx]
|
||
dbg.info(
|
||
"ai-patch[%s] chip pick: %d -> %s (cosine=%.3f)",
|
||
request_id, idx + 1,
|
||
chosen.get("item_tag") or chosen.get("display_name"),
|
||
chosen.get("adjusted_score", 0.0),
|
||
)
|
||
return chosen
|
||
dbg.info(
|
||
"ai-patch[%s] chip pick: out-of-range idx=%d (have %d)",
|
||
request_id, idx, len(ordered),
|
||
)
|
||
return None
|
||
|
||
|
||
async def _decompose_user_request(request_id: str, provider: str,
|
||
config: dict,
|
||
user_request: str) -> list[dict] | None:
|
||
"""Call the configured AI provider to split the user request into
|
||
atomic visual sub-intents. Returns list of {text, section} dicts on
|
||
success, None on any failure mode (provider down, empty response,
|
||
no parseable lines).
|
||
|
||
Caller is expected to surface None as a hard failure to the user —
|
||
the v2 architecture requires the LLM to function. We do NOT return
|
||
a synthesized fallback because that would silently produce wrong
|
||
output."""
|
||
if not user_request:
|
||
return []
|
||
try:
|
||
raw = await _run_generation(
|
||
f"{request_id}-decompose", provider, config,
|
||
_DECOMPOSE_SYSTEM_PROMPT, user_request, [],
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.exception("ai-patch[%s] decomposition call failed", request_id)
|
||
return None
|
||
raw = (raw or "").strip()
|
||
if not raw:
|
||
return None
|
||
cleaned = _strip_json_fences(raw)
|
||
|
||
# Line-based parser (replaces the JSON parser we previously used).
|
||
# The model kept emitting the JSON in subtly-different malformed
|
||
# ways every run — `{ {obj}, {obj} }` one time, `[text, section]`
|
||
# array pairs the next. Each parser strategy chased the previous
|
||
# bug, never the next one. Pivot: ask the model for a format that's
|
||
# genuinely hard to mangle — `section: text` one-per-line, no
|
||
# brackets, no quotes, no schema to mess up. Then accept any line
|
||
# matching `<section> <delimiter> <text>` where section is one of
|
||
# the canonical names.
|
||
out: list[dict] = []
|
||
for line in cleaned.splitlines():
|
||
m = _DECOMPOSE_LINE_RE.match(line)
|
||
if not m:
|
||
continue
|
||
section = m.group(1).lower()
|
||
action = (m.group(2) or "").lower() or None
|
||
text = m.group(3).strip().strip("`'\"").strip()
|
||
if not text:
|
||
continue
|
||
# Strip trailing punctuation the model sometimes adds.
|
||
text = text.rstrip(",;.")
|
||
if not text:
|
||
continue
|
||
sub = {"text": text, "section": section}
|
||
if action:
|
||
sub["action"] = action
|
||
out.append(sub)
|
||
|
||
if not out:
|
||
dbg.info(
|
||
"ai-patch[%s] decompose: no parseable lines, falling back. Raw=%s",
|
||
request_id, _trunc(cleaned, 800),
|
||
)
|
||
return None
|
||
out = _split_conjoined_intents(out, request_id)
|
||
return out
|
||
|
||
|
||
# Split " and "/" + "/", " patterns inside a single decomposed intent so
|
||
# multi-tag phrases like `legs up and wide` become two intents
|
||
# (`legs up`, `legs wide`) rather than one fused phrase that the patch
|
||
# model later turns into a synthetic `legs_up_and_wide` token.
|
||
#
|
||
# Only applies to sections whose tags are atomic Danbooru forms (pose,
|
||
# expression, setting). Character / outfit / strip intents stay intact —
|
||
# multi-character names ('cammy and chun-li') are handled by
|
||
# match-characters preflight, and outfit phrases like 'red dress and
|
||
# black socks' are already split by commas at decompose time.
|
||
|
||
_AND_SPLIT_RE = re.compile(r"\s+(?:and|,|\+|;)\s+", re.IGNORECASE)
|
||
_AND_SPLITTABLE_SECTIONS = {"pose", "expression", "setting"}
|
||
|
||
|
||
def _distribute_implicit_noun(parts: list[str]) -> list[str]:
|
||
"""When the user writes `legs up and wide`, the simple `and` splitter
|
||
yields `['legs up', 'wide']`. The right side `wide` is a bare
|
||
modifier — it lost the implicit subject `legs`. We re-attach the
|
||
leading noun from the previous part so the result is
|
||
`['legs up', 'legs wide']`.
|
||
|
||
Heuristic: a part is bare if it's a single word AND the previous
|
||
part has 2+ words AND the previous part's first word is not already
|
||
present in this part. Conservative — won't fire on `kicking and
|
||
punching` (both single-word), `arms up and legs apart` (both
|
||
multi-word), or `red dress and black socks` (multi/multi)."""
|
||
if len(parts) <= 1:
|
||
return parts
|
||
out = list(parts)
|
||
for i in range(1, len(out)):
|
||
cur_words = out[i].split()
|
||
prev_words = out[i - 1].split()
|
||
if len(cur_words) == 1 and len(prev_words) >= 2:
|
||
leading_noun = prev_words[0]
|
||
if leading_noun.lower() not in (w.lower() for w in cur_words):
|
||
out[i] = f"{leading_noun} {out[i]}"
|
||
return out
|
||
|
||
|
||
# Pattern signaling the user is naming an outfit AESTHETIC ('in a
|
||
# steampunk outfit' / 'wearing a goth outfit' / 'change to a victorian
|
||
# outfit'). When the generic-outfit scan misses (novel aesthetic, no DB
|
||
# entry), we still need to set the strip flag so bio.default_outfit
|
||
# stops dominating — the model vibes the named aesthetic from world
|
||
# knowledge instead of stacking it on top of the bio's stock outfit.
|
||
# The actual outfit-name extraction is the scan's job; this regex only
|
||
# answers "did the user name SOMETHING outfit-shaped".
|
||
_NAMED_OUTFIT_STRIP_FLAG_RE = re.compile(
|
||
r"\b(?:in|wearing|dressed\s+in|change(?:d)?\s+to|switch(?:ed)?\s+to|put\s+on)"
|
||
r"\s+(?:a|an|the)\s+[\w\s\-]+?\s+outfit\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
# Adjective set that signals an outfit MODIFICATION rather than a stock
|
||
# replacement. 'bikini version of the maid outfit' / 'make it skimpier'
|
||
# / 'gothic variant' — these describe a delta to apply to the existing
|
||
# outfit, not a fresh aesthetic to vibe from scratch. Detection routes
|
||
# to MODIFY mode: skip tier-3 DB lookup, skip rewrite, hand the patch
|
||
# model the base outfit + user's modifier and let it compute the delta.
|
||
_MODIFY_OUTFIT_ADJECTIVES = (
|
||
r"bikini|skimpy|skimpier|topless|nude|naked|casual|formal|fancy|"
|
||
r"fancier|simpler|sexy|kinky|slutty|cute|edgy|gothic|punk|tomboy|"
|
||
r"girly|lewd|lewder|alternate|alt|alternative|wet|sheer|see-through|"
|
||
r"transparent|leather|latex|short|shorter|longer|tighter|looser"
|
||
)
|
||
_MODIFY_OUTFIT_PHRASE_RE = re.compile(
|
||
rf"\b(?:"
|
||
rf"(?:{_MODIFY_OUTFIT_ADJECTIVES})\s+(?:version|variant|outfit)\b"
|
||
rf"|(?:version|variant)\s+of\s+(?:the|a|an|my|her|his|their)\b"
|
||
rf"|make\s+it\s+(?:a\s+|an\s+|more\s+)?(?:{_MODIFY_OUTFIT_ADJECTIVES})\b"
|
||
# 'make it [adjective]er' — comparative form catches sexier /
|
||
# tighter / longer / shorter / cuter / edgier without enumerating.
|
||
rf"|make\s+it\s+(?:a\s+|an\s+|more\s+)?\w{{3,}}er\b"
|
||
rf"|(?:variant|alt|alternative)\s+of\b"
|
||
rf")",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
# Suffixes / words that identify a token as a BODY GARMENT — the main
|
||
# coverage piece(s) of an outfit. Body garments are the slots a MODIFY
|
||
# operation replaces ('bikini version' swaps the dress/shirt/pants for
|
||
# a bikini). Everything NOT matching this list is treated as
|
||
# preservable accessory/headwear/footwear/ornament.
|
||
_BODY_GARMENT_SUFFIXES = (
|
||
"dress", "gown", "robe", "kimono", "yukata", "qipao", "uniform",
|
||
"shirt", "blouse", "tank_top", "tee", "tshirt", "vest", "tunic",
|
||
"sweater", "hoodie", "cardigan",
|
||
"leotard", "bodysuit", "swimsuit", "swimwear", "onepiece",
|
||
"pants", "jeans", "trousers", "skirt", "miniskirt", "shorts",
|
||
"minishorts", "leggings", "joggers",
|
||
"coat", "jacket", "blazer",
|
||
)
|
||
|
||
_BODY_GARMENT_TOKEN_RE = re.compile(
|
||
r"(?:^|_)(?:"
|
||
+ "|".join(_BODY_GARMENT_SUFFIXES)
|
||
+ r")$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _is_body_garment_token(token: str) -> bool:
|
||
"""True if the token is a body garment (main coverage piece) rather
|
||
than an accessory/headwear/footwear/ornament. Used by MODIFY mode
|
||
to decide which slot(s) the modifier replaces vs which to preserve."""
|
||
if not token:
|
||
return False
|
||
t = token.strip().strip("(").strip(")").strip()
|
||
# Strip weight suffix '(token:1.1)' or trailing ':1.1'
|
||
t = re.sub(r"^\\?\(\s*", "", t)
|
||
t = re.sub(r"\s*:\s*[\d.]+\s*\\?\)?\s*$", "", t).strip()
|
||
return bool(_BODY_GARMENT_TOKEN_RE.search(t))
|
||
|
||
|
||
def _classify_outfit_tokens_for_modify(
|
||
outfit_body_tokens: list[str],
|
||
) -> tuple[list[str], list[str]]:
|
||
"""Split an outfit body's tokens into (body_garments, preserved).
|
||
Body garments are MODIFY-replaceable; preserved are the accessories,
|
||
headwear, footwear, and ornaments that survive a modifier swap."""
|
||
body: list[str] = []
|
||
preserve: list[str] = []
|
||
for t in outfit_body_tokens:
|
||
if _is_body_garment_token(t):
|
||
body.append(t)
|
||
else:
|
||
preserve.append(t)
|
||
return body, preserve
|
||
|
||
|
||
def _detect_modify_outfit_intent(current_user_text: str) -> dict | None:
|
||
"""Return {'phrase': matched_substring} when the user's current
|
||
message names an outfit MODIFICATION rather than a stock replace.
|
||
Triggers MODIFY mode in the patch flow: skip tier-3 DB lookup,
|
||
skip node_prompt outfit rewrite, surface base + modifier to the
|
||
patch model, and let it reason about the delta.
|
||
|
||
Examples that match:
|
||
- 'make it a bikini version of the french maid outfit'
|
||
- 'goth variant of her current dress'
|
||
- 'skimpier version of this'
|
||
- 'make it sexier'
|
||
|
||
Examples that do NOT match (these are stock replaces):
|
||
- 'in a cowboy outfit'
|
||
- 'change outfit to french maid'
|
||
- 'wearing a pirate outfit'"""
|
||
if not current_user_text:
|
||
return None
|
||
m = _MODIFY_OUTFIT_PHRASE_RE.search(current_user_text)
|
||
if not m:
|
||
return None
|
||
return {"phrase": m.group(0)}
|
||
|
||
|
||
def _ensure_strip_for_named_outfit_phrase(
|
||
sub_intents: list[dict] | None, current_user_text: str, request_id: str,
|
||
) -> list[dict] | None:
|
||
"""Inject a `[strip]` sub_intent when the user's current message
|
||
matches the named-outfit phrase pattern but no [strip] intent
|
||
already exists. Lets `user_strips_outfit` fire downstream so
|
||
bio.default_outfit slots get suppressed for novel aesthetics that
|
||
the generic-outfits DB doesn't have an entry for."""
|
||
if not current_user_text:
|
||
return sub_intents
|
||
if not _NAMED_OUTFIT_STRIP_FLAG_RE.search(current_user_text):
|
||
return sub_intents
|
||
sub_intents = list(sub_intents or [])
|
||
if any((si.get("section") or "").lower() == "strip" for si in sub_intents):
|
||
return sub_intents
|
||
sub_intents.append({"section": "strip", "text": "novel outfit (named via phrase)"})
|
||
dbg.info(
|
||
"ai-patch[%s] named-outfit phrase detected -> strip flag (for default-outfit suppression)",
|
||
request_id,
|
||
)
|
||
return sub_intents
|
||
|
||
|
||
_NODE_OUTFIT_HEADER_RE = re.compile(r"^\s*//\s*Outfit\s*:\s*([^\n]*)$", re.IGNORECASE | re.MULTILINE)
|
||
|
||
|
||
# Map decompose's `[clear] X` text values to the section header prefix
|
||
# the server should remove from node_prompt. Without this map, [clear]
|
||
# intents emitted by decompose are silently dropped — the patch model
|
||
# preserves the section per patch-mode rules and the user's 'reset
|
||
# scene' has no effect. Character clears are handled separately by the
|
||
# character-swap enforcer; outfit clears handled by the outfit rewrite
|
||
# / strip flag flow.
|
||
_CLEAR_TARGET_TO_HEADER_PREFIX = {
|
||
"scene": "// setting",
|
||
"setting": "// setting",
|
||
"background": "// setting",
|
||
"pose": "// pose",
|
||
"action": "// pose",
|
||
"stance": "// pose",
|
||
"expression": "// expression",
|
||
"face": "// expression",
|
||
"style": "// style",
|
||
"quality": "// quality",
|
||
}
|
||
|
||
|
||
def _apply_clear_intents_to_node_prompt(
|
||
node_prompt: str, sub_intents: list[dict] | None, request_id: str,
|
||
) -> str:
|
||
"""Remove `// Section` blocks from node_prompt for each `[clear] X`
|
||
sub_intent. The patch system prompt preserves existing sections
|
||
verbatim — server-side removal is the only way to honor the user's
|
||
explicit 'reset scene' / 'clear pose' / etc. intent. New same-section
|
||
intents on the same turn (if any) drive the model to emit a fresh
|
||
section in place of the cleared one."""
|
||
if not node_prompt or not sub_intents:
|
||
return node_prompt
|
||
clear_prefixes: list[str] = []
|
||
cleared_targets: list[str] = []
|
||
for si in sub_intents:
|
||
if (si.get("section") or "").lower() != "clear":
|
||
continue
|
||
target = (si.get("text") or "").strip().lower()
|
||
prefix = _CLEAR_TARGET_TO_HEADER_PREFIX.get(target)
|
||
if prefix and prefix not in clear_prefixes:
|
||
clear_prefixes.append(prefix)
|
||
cleared_targets.append(target)
|
||
if not clear_prefixes:
|
||
return node_prompt
|
||
lines = node_prompt.splitlines(keepends=False)
|
||
out_lines: list[str] = []
|
||
skipping = False
|
||
for line in lines:
|
||
stripped = line.strip().lower()
|
||
if stripped.startswith("//"):
|
||
skipping = any(stripped.startswith(p) for p in clear_prefixes)
|
||
if skipping:
|
||
continue
|
||
if re.match(r"^\s*Negative\s+Prompt\s*:", line, re.IGNORECASE):
|
||
# Negative Prompt is its own section — never skip it.
|
||
skipping = False
|
||
if skipping:
|
||
continue
|
||
out_lines.append(line)
|
||
dbg.info(
|
||
"ai-patch[%s] clear-intent removed sections: %s",
|
||
request_id, ", ".join(cleared_targets),
|
||
)
|
||
return "\n".join(out_lines)
|
||
|
||
|
||
def _rewrite_node_prompt_outfit_for_user_requested(
|
||
node_prompt: str, bios: list[dict], request_id: str,
|
||
) -> str:
|
||
"""When a bio has `user_requested_outfit` set (matcher attached a
|
||
character outfit, or tier-3 generic-outfit scan attached an
|
||
archetype) AND node_prompt has an existing `// Outfit: <X>` section
|
||
with a DIFFERENT name, rewrite node_prompt's // Outfit body to use
|
||
the requested outfit's slot phrases.
|
||
|
||
Why: the patch system prompt tells the model to preserve existing
|
||
sections verbatim. Without this rewrite, the model sees the
|
||
user_requested_outfit as a bio hint AND the prior outfit as a
|
||
'preserved' section — and chooses to keep the preserved one, so
|
||
the swap silently no-ops. By rewriting node_prompt server-side,
|
||
the 'preserved' content already matches the user's request and
|
||
patch-mode preservation works in our favor."""
|
||
if not node_prompt or not bios:
|
||
return node_prompt
|
||
target = None
|
||
for b in bios:
|
||
uro = b.get("user_requested_outfit") or {}
|
||
if uro and uro.get("name"):
|
||
target = uro
|
||
break
|
||
if not target:
|
||
return node_prompt
|
||
new_name = (target.get("name") or "").strip()
|
||
slots = target.get("slots") or []
|
||
new_body = ", ".join(
|
||
(s.get("source_phrase") or "").strip()
|
||
for s in slots if s.get("source_phrase")
|
||
)
|
||
if not new_name or not new_body:
|
||
return node_prompt
|
||
m = _NODE_OUTFIT_HEADER_RE.search(node_prompt)
|
||
if not m:
|
||
return node_prompt
|
||
existing_name = (m.group(1) or "").strip()
|
||
if existing_name.lower() == new_name.lower():
|
||
return node_prompt
|
||
lines = node_prompt.splitlines(keepends=False)
|
||
out_lines: list[str] = []
|
||
i = 0
|
||
rewrote = False
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
if not rewrote and re.match(r"^\s*//\s*Outfit\s*:", line, re.IGNORECASE):
|
||
out_lines.append(f"// Outfit: {new_name}")
|
||
out_lines.append(new_body)
|
||
i += 1
|
||
# Skip the prior body lines until the next `//` header or
|
||
# blank-then-EOF or the Negative Prompt marker.
|
||
while i < len(lines):
|
||
stripped = lines[i].strip()
|
||
if stripped.startswith("//"):
|
||
break
|
||
if re.match(r"^\s*Negative\s+Prompt\s*:", lines[i], re.IGNORECASE):
|
||
break
|
||
i += 1
|
||
rewrote = True
|
||
continue
|
||
out_lines.append(line)
|
||
i += 1
|
||
if not rewrote:
|
||
return node_prompt
|
||
dbg.info(
|
||
"ai-patch[%s] node_prompt outfit rewrite: %r -> %r (per "
|
||
"user_requested_outfit)", request_id, existing_name, new_name,
|
||
)
|
||
return "\n".join(out_lines)
|
||
|
||
|
||
def _apply_generic_outfit_to_bios(
|
||
sub_intents: list[dict] | None, bios: list[dict],
|
||
current_user_text: str, request_id: str,
|
||
) -> tuple[list[dict] | None, list[dict]]:
|
||
"""Tier 3 of outfit resolution: scan the user's CURRENT message for
|
||
any generic-outfit name/alias from the tag_builder.generic_outfits
|
||
KB. On a hit, attach the curated slot decomposition as
|
||
user_requested_outfit on the (sole) bio. The patch flow then renders
|
||
those slots verbatim instead of expanding the user's outfit phrase
|
||
via bge retrieval (which leaks scene props like horse/gun).
|
||
|
||
Scanning the CURRENT message (not multi-turn extract) avoids a
|
||
common failure: previous turn 'in a cowboy outfit' phrase polluting
|
||
this turn's 'change outfit to french maid' classification.
|
||
|
||
Only fires when:
|
||
- exactly one bio (multi-char generic-outfit attribution is
|
||
ambiguous; skip and let canonical_resolver handle it)
|
||
- that bio has no user_requested_outfit yet (tier 1/2 already
|
||
resolved a more specific match — don't override)
|
||
|
||
Marks any [strip]/[outfit] sub-intents with `pre_resolved=True` so
|
||
canonical_resolver skips them (otherwise the bge expansion of
|
||
'cowboy outfit' still pulls horse/gun candidates into patch_user)."""
|
||
if not bios or not current_user_text:
|
||
return sub_intents, bios
|
||
real_bios = [b for b in bios if b and b.get("tag")]
|
||
if len(real_bios) != 1:
|
||
return sub_intents, bios
|
||
target = real_bios[0]
|
||
if target.get("user_requested_outfit"):
|
||
return sub_intents, bios
|
||
from . import tag_builder as _tb
|
||
matched = _tb.scan_for_generic_outfit(current_user_text)
|
||
if not matched:
|
||
return sub_intents, bios
|
||
target["user_requested_outfit"] = {
|
||
"name": matched["name"],
|
||
"tags": matched["tags"],
|
||
"natlang": matched["natlang"],
|
||
"slots": matched["slots"],
|
||
}
|
||
# Suppress canonical_resolver / bge expansion of any [strip]/[outfit]
|
||
# intent — we've already attached the authoritative outfit data.
|
||
if sub_intents:
|
||
for si in sub_intents:
|
||
section = (si.get("section") or "").lower()
|
||
if section in ("strip", "outfit"):
|
||
si["pre_resolved"] = True
|
||
dbg.info(
|
||
"ai-patch[%s] generic-outfit: scanned %r -> %s (%d slots) on bio=%s",
|
||
request_id, current_user_text[:80], matched["name"],
|
||
len(matched["slots"]), target.get("tag"),
|
||
)
|
||
return sub_intents, bios
|
||
|
||
|
||
def _split_conjoined_intents(intents: list[dict], request_id: str) -> list[dict]:
|
||
out: list[dict] = []
|
||
split_log: list[tuple[str, list[str]]] = []
|
||
for intent in intents:
|
||
section = (intent.get("section") or "").strip().lower()
|
||
text = (intent.get("text") or "").strip()
|
||
if section not in _AND_SPLITTABLE_SECTIONS or not text:
|
||
out.append(intent)
|
||
continue
|
||
raw_parts = [p.strip() for p in _AND_SPLIT_RE.split(text) if p.strip()]
|
||
# Single part => no split happened; keep original.
|
||
if len(raw_parts) <= 1:
|
||
out.append(intent)
|
||
continue
|
||
# Reject splits where any part is too short (likely a modifier
|
||
# like `bare and dirty foot` shouldn't split into `bare`/`dirty
|
||
# foot`). 3+ chars per part is a safe floor — Danbooru tags rarely
|
||
# have 1-2 char canonical forms.
|
||
if any(len(p) < 3 for p in raw_parts):
|
||
out.append(intent)
|
||
continue
|
||
parts = _distribute_implicit_noun(raw_parts)
|
||
for p in parts:
|
||
out.append({"text": p, "section": section})
|
||
split_log.append((text, parts))
|
||
if split_log:
|
||
for original, parts in split_log:
|
||
dbg.info(
|
||
"ai-patch[%s] decompose: split conjoined intent %r -> %s",
|
||
request_id, original, parts,
|
||
)
|
||
return out
|
||
|
||
|
||
# When a modifier with this canonical_tag is in [APPLIES], retrieval
|
||
# candidates whose tag-group is in the listed groups get filtered out.
|
||
# Stops the bge-small noise where a foot-presenting modifier is firing
|
||
# but the menu also surfaces gesture-domain tags like `pointing_down`.
|
||
# Uses tag-group data we already have indexed.
|
||
_MODIFIER_CONFLICT_GROUPS: dict[str, list[str]] = {
|
||
"presenting_foot": ["gestures"],
|
||
}
|
||
|
||
# Sections that match-characters preflight already handles. No need to
|
||
# run retrieval for character-domain sub-intents — bio is authoritative.
|
||
_RETRIEVAL_SKIP_SECTIONS = {"character"}
|
||
|
||
|
||
_LITERAL_NGRAM_RE = re.compile(r"[a-z0-9']+")
|
||
|
||
# Pronouns / articles / aux verbs that interrupt n-gram compounds without
|
||
# adding meaning. "spreading her toes" -> drop "her" -> we can build
|
||
# `spreading_toes` (then -ing-strip to `spread_toes`).
|
||
_LITERAL_STOP_WORDS = frozenset({
|
||
"a", "an", "the", "her", "his", "their", "its", "is", "are",
|
||
"was", "were", "be", "to", "of", "and", "or",
|
||
})
|
||
|
||
|
||
def _literal_anchor_candidates(text: str, applies_by_tag: dict,
|
||
modifier_canon: set[str],
|
||
bio_known: set[str],
|
||
conflict_tags: set[str] | None = None
|
||
) -> list[dict]:
|
||
"""Substring lookup: for any 1-4-word substring of `text` that
|
||
matches a real Danbooru general tag (ranking >= 200), return it as
|
||
a candidate. Used to anchor literal user words like `sitting` or
|
||
`dungeon` that bge-small ranks below specific variants. Filters out
|
||
bio-known and modifier-fired tags so we don't double-surface them.
|
||
|
||
Two enrichments to handle gerund / pronoun phrasings the user types
|
||
naturally:
|
||
- Drop _LITERAL_STOP_WORDS from the word list before n-gramming.
|
||
"spreading her toes" -> ["spreading", "toes"] -> can build
|
||
`spreading_toes` (then strip -ing -> `spread_toes`).
|
||
- For each n-gram, also try a variant with -ing stripped from
|
||
the first word (gerund -> bare verb). Catches `spread_toes`
|
||
from user's "spreading toes" phrasing.
|
||
|
||
Conflict_tags is a set of tag names blocked by a fired modifier's
|
||
declared conflict groups (e.g. gestures-group when presenting_foot
|
||
fires). Anchor honors this so `pointing` isn't anchored alongside
|
||
[APPLIES] presenting_foot.
|
||
|
||
Score is set to a sentinel high value so anchored candidates always
|
||
survive the global cap downstream — the user explicitly typed these
|
||
words, they should not be evicted by random-cosine competition."""
|
||
if not text:
|
||
return []
|
||
raw_words = _LITERAL_NGRAM_RE.findall(text.lower())
|
||
words = [w for w in raw_words if w not in _LITERAL_STOP_WORDS]
|
||
if not words:
|
||
return []
|
||
candidates: set[str] = set()
|
||
for n in range(1, 5):
|
||
for i in range(len(words) - n + 1):
|
||
slice_ = words[i:i + n]
|
||
candidates.add("_".join(slice_))
|
||
# Gerund -> bare-verb variant: `spreading_toes` -> `spread_toes`,
|
||
# `pointing_feet` -> `point_feet` (latter isn't a tag, harmless).
|
||
first = slice_[0]
|
||
if len(first) > 4 and first.endswith("ing"):
|
||
candidates.add("_".join([first[:-3]] + slice_[1:]))
|
||
if not candidates:
|
||
return []
|
||
try:
|
||
from .tag_builder import get_db
|
||
db = get_db()
|
||
placeholders = ",".join("?" for _ in candidates)
|
||
rows = db.execute(
|
||
f"SELECT t.tag, t.ranking, w.body_summary, w.body_full "
|
||
f"FROM danbooru_tags t "
|
||
f"LEFT JOIN danbooru_tag_wikis w ON w.tag = t.tag "
|
||
f"WHERE t.tag IN ({placeholders}) "
|
||
f"AND t.category = 'general' AND t.ranking >= 200",
|
||
list(candidates),
|
||
).fetchall()
|
||
except Exception:
|
||
logger.exception("literal-anchor lookup failed for %r", text)
|
||
return []
|
||
out: list[dict] = []
|
||
conflict_tags = conflict_tags or set()
|
||
for r in rows:
|
||
tag = (r["tag"] or "").lower()
|
||
if not tag or tag in bio_known or tag in modifier_canon:
|
||
continue
|
||
if tag in conflict_tags:
|
||
continue # blocked by fired modifier's conflict-group rule
|
||
out.append({
|
||
"tag": r["tag"],
|
||
"ranking": int(r["ranking"] or 0),
|
||
"body_summary": r["body_summary"] or "",
|
||
"body_full": r["body_full"] or "",
|
||
"score": 0.99, # sentinel — anchored tags always survive cap
|
||
})
|
||
return out
|
||
|
||
|
||
def _retrieve_tag_candidates(sub_intents: list[dict],
|
||
bio_known: set[str],
|
||
modifier_canon: set[str],
|
||
applies_by_tag: dict | None = None,
|
||
top_per: int = 8,
|
||
threshold: float = 0.55,
|
||
total_cap: int = 24,
|
||
on_status: Callable[[str], None] | None = None) -> list[dict]:
|
||
"""For each sub-intent, retrieve candidate tags via two paths:
|
||
|
||
1. Literal-word anchor: any 1-4-word substring of the sub-intent
|
||
that's a real Danbooru tag is included with sentinel score.
|
||
Catches direct hits like `sitting`, `dungeon` that semantic
|
||
retrieval ranks below specific variants.
|
||
2. Bi-encoder semantic search: top-K tag wikis above threshold.
|
||
|
||
Filters applied per sub-intent:
|
||
- Skip character-section sub-intents entirely (match-characters
|
||
preflight covers character identity).
|
||
- Section-aware: when sub-intent has section X, drop semantic
|
||
hits whose preferred section per tag-group index is a different
|
||
section. Tags with no indexed section pass through (ambiguous).
|
||
- Modifier-conflict: if [APPLIES] modifier is in the conflict map,
|
||
drop semantic hits whose tag is a member of the conflicting
|
||
tag_group. Stops gestures-pollution when presenting_foot fires.
|
||
|
||
Per-sub-intent slot cap (round-robin) ensures each sub-intent gets
|
||
representation in the final menu without global-cosine competition
|
||
evicting niche-but-relevant matches.
|
||
|
||
Returns list of dicts with `matched_intent` field for debugging."""
|
||
if not sub_intents:
|
||
return []
|
||
try:
|
||
from . import tag_search
|
||
except Exception:
|
||
logger.exception("ai-patch: tag_search import failed")
|
||
return []
|
||
|
||
applies_by_tag = applies_by_tag or {}
|
||
section_index = _build_tag_section_index()
|
||
|
||
# Build the union of conflict-tag-groups based on which modifiers
|
||
# fired. E.g. presenting_foot fired → gestures group becomes
|
||
# forbidden-domain for retrieval candidates.
|
||
conflict_groups: set[str] = set()
|
||
for canon in applies_by_tag:
|
||
for g in _MODIFIER_CONFLICT_GROUPS.get(canon, []):
|
||
conflict_groups.add(g)
|
||
conflict_tags: set[str] = set()
|
||
for g in conflict_groups:
|
||
conflict_tags.update(_load_tag_group(g))
|
||
|
||
# Allocate slots per sub-intent so each gets representation in the
|
||
# final menu. With 8 sub-intents and total_cap=24, that's 3 each.
|
||
active_intents = [
|
||
si for si in sub_intents
|
||
if isinstance(si, dict) and (si.get("text") or "").strip()
|
||
and (si.get("section") or "").lower() not in _RETRIEVAL_SKIP_SECTIONS
|
||
]
|
||
if not active_intents:
|
||
return []
|
||
slots_per = max(2, total_cap // max(1, len(active_intents)))
|
||
|
||
seen_tags: set[str] = set()
|
||
per_intent_results: list[list[dict]] = []
|
||
for si in active_intents:
|
||
text = si["text"].strip()
|
||
section = (si.get("section") or "").lower()
|
||
candidates: list[dict] = []
|
||
|
||
# Curator-authored alias scan first — deterministic, encoder-
|
||
# independent. Solves the "close up on feet" -> foot_focus
|
||
# failure where bge-small's bag-of-words can't bridge
|
||
# definitional paraphrase. Aliases sourced from
|
||
# data/tag-builder/tag-aliases-seed.json + curator edits.
|
||
for h in tag_search.alias_scan(text):
|
||
tag = (h.get("tag") or "").lower()
|
||
if not tag or tag in seen_tags:
|
||
continue
|
||
if tag in bio_known or tag in modifier_canon:
|
||
continue
|
||
if tag in conflict_tags:
|
||
continue
|
||
entry = dict(h)
|
||
entry["matched_intent"] = text
|
||
entry["matched_via"] = "alias"
|
||
candidates.append(entry)
|
||
seen_tags.add(tag)
|
||
|
||
# Literal anchor (substring against danbooru_tags) — high-priority,
|
||
# always survives. Catches direct word matches like `sitting`.
|
||
for h in _literal_anchor_candidates(
|
||
text, applies_by_tag, modifier_canon, bio_known,
|
||
conflict_tags=conflict_tags,
|
||
):
|
||
tag = h["tag"].lower()
|
||
if tag in seen_tags:
|
||
continue
|
||
entry = dict(h)
|
||
entry["matched_intent"] = text
|
||
entry["matched_via"] = "anchor"
|
||
candidates.append(entry)
|
||
seen_tags.add(tag)
|
||
|
||
# Semantic retrieval — wider, filtered by section + modifier conflicts.
|
||
try:
|
||
hits = tag_search.search(text, top_k=top_per, threshold=threshold, on_status=on_status)
|
||
except Exception:
|
||
logger.exception("ai-patch: tag_search.search failed for %r", text)
|
||
hits = []
|
||
for h in hits:
|
||
tag = (h.get("tag") or "").lower()
|
||
if not tag or tag in seen_tags:
|
||
continue
|
||
if tag in bio_known or tag in modifier_canon:
|
||
continue
|
||
# Section-aware: drop if preferred section disagrees with
|
||
# the sub-intent's section. Tags without an indexed section
|
||
# are allowed through (genuinely ambiguous).
|
||
preferred = section_index.get(tag)
|
||
if preferred and section in {"character", "outfit", "pose",
|
||
"expression", "setting"} \
|
||
and preferred != section:
|
||
continue
|
||
# Modifier-conflict: drop if a fired modifier owns this domain.
|
||
if tag in conflict_tags:
|
||
continue
|
||
entry = dict(h)
|
||
entry["matched_intent"] = text
|
||
entry["matched_via"] = "semantic"
|
||
candidates.append(entry)
|
||
seen_tags.add(tag)
|
||
if len(candidates) >= slots_per:
|
||
break
|
||
|
||
per_intent_results.append(candidates)
|
||
|
||
# Flatten while preserving sub-intent diversity (round-robin order).
|
||
out: list[dict] = []
|
||
max_len = max((len(r) for r in per_intent_results), default=0)
|
||
for i in range(max_len):
|
||
for r in per_intent_results:
|
||
if i < len(r):
|
||
out.append(r[i])
|
||
if len(out) >= total_cap:
|
||
return out
|
||
return out
|
||
|
||
|
||
_WEIGHTED_TOKEN_RE = re.compile(r"^\(\s*(.+?)\s*:\s*[\d.]+\s*\)$")
|
||
|
||
|
||
# SDXL/LAION-style captioning drops English articles inside descriptive
|
||
# phrases — `pointing gun at viewer` renders cleaner than the verbose
|
||
# `pointing a gun at viewer`. The AI emits articles inconsistently
|
||
# regardless of system prompt because it's drawing on training data
|
||
# that has both forms. Strip them here as a deterministic post-pass on
|
||
# phrasal tokens (tokens containing whitespace). Tag-form tokens like
|
||
# `looking_at_viewer` have no whitespace so they're untouched.
|
||
_ARTICLE_STRIP_RE = re.compile(r"\b(?:a|an|the)\s+", re.IGNORECASE)
|
||
|
||
|
||
def _strip_articles_from_phrasal(token: str) -> str:
|
||
"""Drop bare a/an/the followed by whitespace from phrasal tokens.
|
||
Returns input unchanged if the token has no whitespace (tag form),
|
||
or if the result would be empty (e.g. a token that's just 'the')."""
|
||
if " " not in token:
|
||
return token
|
||
cleaned = _ARTICLE_STRIP_RE.sub("", token)
|
||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||
return cleaned if cleaned else token
|
||
|
||
|
||
# ── DB-driven modifier detection ──────────────────────────────────
|
||
# Loaded from slot_modifiers table — DB-driven so adding a new modifier
|
||
# (e.g. a fresh slang phrasing) is an INSERT, not a code change.
|
||
|
||
_SLOT_MODIFIERS_CACHE: list[dict] | None = None
|
||
|
||
|
||
def _load_slot_modifiers() -> list[dict]:
|
||
global _SLOT_MODIFIERS_CACHE
|
||
if _SLOT_MODIFIERS_CACHE is not None:
|
||
return _SLOT_MODIFIERS_CACHE
|
||
try:
|
||
from .tag_builder import get_db
|
||
db = get_db()
|
||
rows = db.execute(
|
||
"SELECT canonical_tag, aliases, clears_slots, is_substitute, "
|
||
"substitute_section, implies_outfit_tag, definition "
|
||
"FROM slot_modifiers ORDER BY sort_order"
|
||
).fetchall()
|
||
out: list[dict] = []
|
||
for r in rows:
|
||
aliases = [a.strip().lower() for a in (r["aliases"] or "").split("|") if a.strip()]
|
||
canon_phrase = (r["canonical_tag"] or "").replace("_", " ").lower()
|
||
if canon_phrase and canon_phrase not in aliases:
|
||
aliases.append(canon_phrase)
|
||
slots = [s.strip() for s in (r["clears_slots"] or "").split(",") if s.strip()]
|
||
sub_section = (r["substitute_section"] or "outfit").strip().lower()
|
||
implies = (r["implies_outfit_tag"] or "").strip()
|
||
definition = (r["definition"] or "").strip()
|
||
out.append({
|
||
"canonical_tag": r["canonical_tag"],
|
||
"aliases": aliases,
|
||
"clears_slots": slots,
|
||
"is_substitute": bool(r["is_substitute"]),
|
||
"substitute_section": sub_section,
|
||
"implies_outfit_tag": implies or None,
|
||
"definition": definition,
|
||
})
|
||
_SLOT_MODIFIERS_CACHE = out
|
||
except Exception:
|
||
logger.warning("could not load slot_modifiers — using empty list", exc_info=True)
|
||
_SLOT_MODIFIERS_CACHE = []
|
||
return _SLOT_MODIFIERS_CACHE
|
||
|
||
|
||
# ── Danbooru tag-group data ────────────────────────────────────────
|
||
# Lists of tags per Danbooru wiki tag_group (gestures, posture, attire,
|
||
# eyes_tags, locations). Used by `_drop_misplaced_tokens` to enforce
|
||
# section assignment — a tag in tag_group:gestures emitted under //
|
||
# Character is a misassignment regardless of intent.
|
||
#
|
||
# The hand-curated foot/gestures input/output filters that used to
|
||
# dispatch off these lists were retired in Phase 5 of the wiki-RAG
|
||
# rollout (semantic retrieval handles polysemy via gloss meaning).
|
||
|
||
_TAG_GROUPS_DIR = Path(__file__).parent.parent / "data" / "tag-builder" / "danbooru-tag-groups"
|
||
_TAG_GROUP_CACHE: dict[str, set[str]] = {}
|
||
|
||
|
||
def _load_tag_group(name: str) -> set[str]:
|
||
if name in _TAG_GROUP_CACHE:
|
||
return _TAG_GROUP_CACHE[name]
|
||
path = _TAG_GROUPS_DIR / f"{name}.json"
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
tags = set(data.get("tags") or [])
|
||
except FileNotFoundError:
|
||
logger.info("tag_group %s not found at %s", name, path)
|
||
tags = set()
|
||
except Exception:
|
||
logger.warning("tag_group %s load failed", name, exc_info=True)
|
||
tags = set()
|
||
_TAG_GROUP_CACHE[name] = tags
|
||
return tags
|
||
|
||
|
||
# Negation cues that, if they precede a matched alias within a small
|
||
# window, mean the user is asking to *remove* the modifier — not apply
|
||
# it. "remove barefoot" / "no socks" / "without the leotard" should NOT
|
||
# fire the [APPLIES] action.
|
||
_NEGATION_PREFIX_RE = re.compile(
|
||
r"\b(?:remove|drop|delete|take\s+off|get\s+rid\s+of|"
|
||
r"no(?:t)?|without|don'?t\s+(?:want|use|have))"
|
||
r"(?:\s+\w+){0,2}\s+$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _is_negated_match(text: str, match_start: int) -> bool:
|
||
"""True if the alias match at `match_start` is preceded by a negation
|
||
cue within a 4-word window. Lets 'remove barefoot' skip the
|
||
[APPLIES] action without false-positiving on 'kneeling' (no
|
||
negation), 'wearing barefoot leotard' (no negation cue), etc."""
|
||
window = text[max(0, match_start - 40):match_start]
|
||
return bool(_NEGATION_PREFIX_RE.search(window))
|
||
|
||
|
||
def _detect_modifiers_in_text(text: str) -> list[dict]:
|
||
"""Word-boundary alias scan; returns matched modifiers deduped by canonical_tag.
|
||
Each result has canonical_tag, matched_alias, clears_slots, is_substitute,
|
||
substitute_section, implies_outfit_tag, definition.
|
||
|
||
Negation-aware: 'remove X' / 'no X' / 'without X' suppress the match
|
||
so the modifier doesn't fire as [APPLIES] when the user is asking
|
||
to drop the concept."""
|
||
if not text:
|
||
return []
|
||
matched: dict[str, dict] = {}
|
||
for mod in _load_slot_modifiers():
|
||
for alias in mod["aliases"]:
|
||
if not alias:
|
||
continue
|
||
m = re.search(r"(?<!\w)" + re.escape(alias) + r"(?!\w)", text, re.IGNORECASE)
|
||
if not m:
|
||
continue
|
||
if _is_negated_match(text, m.start()):
|
||
continue
|
||
if mod["canonical_tag"] not in matched:
|
||
matched[mod["canonical_tag"]] = {
|
||
"canonical_tag": mod["canonical_tag"],
|
||
"matched_alias": alias,
|
||
"clears_slots": mod["clears_slots"],
|
||
"is_substitute": mod["is_substitute"],
|
||
"substitute_section": mod["substitute_section"],
|
||
"implies_outfit_tag": mod["implies_outfit_tag"],
|
||
"definition": mod.get("definition", ""),
|
||
}
|
||
break
|
||
return list(matched.values())
|
||
|
||
|
||
# ── slot resolver + modifier conflict map ────────────────────────
|
||
# The TB schema has slot data — clothing_items / pose_items / appearance_items
|
||
# all have an item_group column that IS the slot. But outfit_tags are stored
|
||
# as flat strings, so we have to classify each token at apply time. Hierarchy:
|
||
# 1. Direct lookup in clothing_items / appearance_items by item_tag
|
||
# 2. Color/material prefix strip (`brown_pantyhose` → `pantyhose` → legwear)
|
||
# 3. Suffix pattern fallback (`white_boots` → footwear via *_boots rule)
|
||
# Returns a slot like "clothing:footwear", "appearance:hair_color", etc., or None.
|
||
|
||
# Suffix → group map for clothing terms not directly in clothing_items.
|
||
# Covers the common case where outfit_tags reference colored / styled
|
||
# variants that don't have their own entry. Hand-curated; can be expanded.
|
||
_CLOTHING_SUFFIX_GROUPS = {
|
||
# footwear
|
||
"boots": "footwear", "shoes": "footwear", "sandals": "footwear",
|
||
"heels": "footwear", "sneakers": "footwear", "loafers": "footwear",
|
||
"slippers": "footwear", "geta": "footwear", "flats": "footwear",
|
||
# tops
|
||
"shirt": "tops", "blouse": "tops", "tank_top": "tops", "tanktop": "tops",
|
||
"tube_top": "tops", "crop_top": "tops", "halter_top": "tops",
|
||
"sweater": "tops", "hoodie": "tops", "jacket": "tops", "vest": "tops",
|
||
"cardigan": "tops", "coat": "tops",
|
||
# bottoms
|
||
"skirt": "bottoms", "pants": "bottoms", "shorts": "bottoms",
|
||
"jeans": "bottoms", "leggings": "bottoms", "trousers": "bottoms",
|
||
# dresses (full-body garments — a single one of these covers the
|
||
# whole body, so the strip-pass treats them as outfit replacements)
|
||
"dress": "dresses", "gown": "dresses", "robe": "dresses",
|
||
"kimono": "dresses", "qipao": "dresses",
|
||
"nightie": "dresses", "nightgown": "dresses", "nightdress": "dresses",
|
||
"negligee": "dresses", "chemise": "dresses", "babydoll": "dresses",
|
||
"sundress": "dresses", "jumpsuit": "dresses", "romper": "dresses",
|
||
"leotard": "dresses", "bodysuit": "dresses", "uniform": "dresses",
|
||
# legwear
|
||
"pantyhose": "legwear", "stockings": "legwear", "socks": "legwear",
|
||
"tights": "legwear", "thighhighs": "legwear", "kneehighs": "legwear",
|
||
# underwear / lingerie
|
||
"panties": "underwear", "bra": "lingerie", "underwear": "underwear",
|
||
"thong": "underwear",
|
||
# swimwear
|
||
"bikini": "swimwear", "swimsuit": "swimwear", "one-piece_swimsuit": "swimwear",
|
||
# handwear
|
||
"gloves": "handwear", "mittens": "handwear", "gauntlets": "handwear",
|
||
# headwear
|
||
"hat": "headwear", "helmet": "headwear", "cap": "headwear", "beret": "headwear",
|
||
"headband": "headwear", "tiara": "headwear", "crown": "headwear", "hood": "headwear",
|
||
# neckwear
|
||
"collar": "neckwear", "choker": "neckwear", "necklace": "neckwear",
|
||
"scarf": "neckwear", "tie": "neckwear", "ribbon": "neckwear",
|
||
# accessories
|
||
"ring": "accessories", "bracelet": "accessories", "earring": "accessories",
|
||
"earrings": "accessories", "harness": "accessories", "holster": "accessories",
|
||
"belt": "accessories",
|
||
}
|
||
|
||
# Modifier tokens that REMOVE other tokens by group when added. E.g. adding
|
||
# `barefoot` means existing footwear should go. Keys are canonical (space form,
|
||
# lower case), values are clothing group names from clothing_groups.
|
||
_MODIFIER_CONFLICTS: dict[str, list[str]] = {
|
||
"barefoot": ["footwear"],
|
||
"no_shoes": ["footwear"],
|
||
"shoes_removed": ["footwear"],
|
||
"unworn_footwear": ["footwear"],
|
||
"bottomless": ["bottoms", "underwear", "dresses"],
|
||
"no_panties": ["underwear"],
|
||
"topless": ["tops", "dresses", "swimwear"],
|
||
"no_bra": ["lingerie"],
|
||
"shirtless": ["tops"],
|
||
"nude": ["tops", "bottoms", "dresses", "underwear", "lingerie",
|
||
"swimwear", "footwear", "legwear", "handwear"],
|
||
"completely_nude": ["tops", "bottoms", "dresses", "underwear", "lingerie",
|
||
"swimwear", "footwear", "legwear", "handwear",
|
||
"headwear", "neckwear", "accessories"],
|
||
"naked": ["tops", "bottoms", "dresses", "underwear", "lingerie",
|
||
"swimwear", "footwear", "legwear", "handwear"],
|
||
}
|
||
|
||
# Cached color/material prefixes loaded from clothing_colors / clothing_materials.
|
||
# Lazily built on first classify call so module import doesn't hit the DB.
|
||
_CLOTHING_PREFIX_CACHE: list[str] | None = None
|
||
|
||
|
||
def _get_clothing_prefixes() -> list[str]:
|
||
global _CLOTHING_PREFIX_CACHE
|
||
if _CLOTHING_PREFIX_CACHE is not None:
|
||
return _CLOTHING_PREFIX_CACHE
|
||
try:
|
||
from .tag_builder import get_db
|
||
db = get_db()
|
||
prefixes: set[str] = set()
|
||
for table in ("clothing_colors", "clothing_materials", "clothing_patterns"):
|
||
try:
|
||
for r in db.execute(f"SELECT tag FROM {table}").fetchall():
|
||
if r["tag"]:
|
||
prefixes.add(r["tag"])
|
||
except Exception:
|
||
pass
|
||
# Sort longest-first so `dark_red` strips before `red`.
|
||
_CLOTHING_PREFIX_CACHE = sorted(prefixes, key=len, reverse=True)
|
||
except Exception:
|
||
logger.warning("could not preload clothing prefixes — using empty list", exc_info=True)
|
||
_CLOTHING_PREFIX_CACHE = []
|
||
return _CLOTHING_PREFIX_CACHE
|
||
|
||
|
||
def _classify_token(token: str) -> str | None:
|
||
"""Return a slot id like 'footwear', 'tops', 'hair_color', or None.
|
||
Uses underscore form internally — caller should pass canonicalize-then-
|
||
underscore-substitute form (e.g. 'white_boots', not 'white boots')."""
|
||
if not token:
|
||
return None
|
||
needle = token.strip().lower()
|
||
if not needle:
|
||
return None
|
||
try:
|
||
from .tag_builder import get_db
|
||
db = get_db()
|
||
except Exception:
|
||
return None
|
||
# 1. Direct hit in clothing_items
|
||
row = db.execute(
|
||
"SELECT item_group FROM clothing_items WHERE item_tag = ?", [needle]
|
||
).fetchone()
|
||
if row and row["item_group"]:
|
||
return row["item_group"]
|
||
# 2. Direct hit in appearance_items / pose_items (returned with prefix to
|
||
# avoid colliding with clothing slots if we add cross-slot logic later).
|
||
row = db.execute(
|
||
"SELECT item_group FROM appearance_items WHERE item_tag = ?", [needle]
|
||
).fetchone()
|
||
if row and row["item_group"]:
|
||
return f"appearance:{row['item_group']}"
|
||
row = db.execute(
|
||
"SELECT item_group FROM pose_items WHERE item_tag = ?", [needle]
|
||
).fetchone()
|
||
if row and row["item_group"]:
|
||
return f"pose:{row['item_group']}"
|
||
# 3. Color/material prefix strip — `brown_pantyhose` → strip `brown` →
|
||
# `pantyhose` → legwear. Try clothing_items first since most prefixed
|
||
# forms are clothing.
|
||
for prefix in _get_clothing_prefixes():
|
||
if needle.startswith(prefix + "_"):
|
||
stripped = needle[len(prefix) + 1:]
|
||
row = db.execute(
|
||
"SELECT item_group FROM clothing_items WHERE item_tag = ?", [stripped]
|
||
).fetchone()
|
||
if row and row["item_group"]:
|
||
return row["item_group"]
|
||
# Also try suffix on stripped
|
||
for suffix, group in _CLOTHING_SUFFIX_GROUPS.items():
|
||
if stripped == suffix or stripped.endswith("_" + suffix):
|
||
return group
|
||
break # only try one prefix strip
|
||
# 4. Suffix pattern — `white_boots` → footwear via `_boots`
|
||
for suffix, group in _CLOTHING_SUFFIX_GROUPS.items():
|
||
if needle == suffix or needle.endswith("_" + suffix):
|
||
return group
|
||
return None
|
||
|
||
|
||
def _expand_patch_with_slot_logic(patch: dict, node_prompt: str, request_id: str) -> dict:
|
||
"""Augment the AI's `remove` list with auto-detected conflicts from slot
|
||
classification. Two cases:
|
||
|
||
1. Modifier add (barefoot, bottomless, etc.) — pulled from
|
||
_MODIFIER_CONFLICTS. Removes ALL source tokens whose slot is in the
|
||
modifier's declared groups.
|
||
2. Within-slot replacement (red_dress when source has blue_dress) —
|
||
classify the add token's slot, remove existing source tokens in the
|
||
same slot.
|
||
|
||
Detected auto-removes are added to patch['remove'], deduplicated. Every
|
||
decision is logged so failures are diagnosable from comfyui.log."""
|
||
if not isinstance(patch, dict):
|
||
return patch
|
||
adds = patch.get("add") or []
|
||
removes = list(patch.get("remove") or [])
|
||
explicit_remove_canon = {_canonicalize_token(t) for t in removes}
|
||
|
||
src_tokens = _split_prompt_tokens(node_prompt)
|
||
src_with_slot: list[tuple[str, str | None]] = []
|
||
for src in src_tokens:
|
||
canon = _canonicalize_token(src)
|
||
# Strip weighted wrapper for classification: `(red_gi:1.3)` → `red_gi`
|
||
m = _WEIGHTED_TOKEN_RE.match(canon)
|
||
bare = m.group(1) if m else canon
|
||
slot = _classify_token(bare.replace(" ", "_"))
|
||
src_with_slot.append((src, slot))
|
||
|
||
auto_removes: list[str] = []
|
||
decisions: list[str] = []
|
||
|
||
for add_raw in adds:
|
||
add_canon = _canonicalize_token(add_raw)
|
||
underscored = add_canon.replace(" ", "_")
|
||
# Modifier conflict — remove anything in declared groups
|
||
if underscored in _MODIFIER_CONFLICTS:
|
||
target_groups = _MODIFIER_CONFLICTS[underscored]
|
||
for src, slot in src_with_slot:
|
||
if slot in target_groups and _canonicalize_token(src) not in explicit_remove_canon:
|
||
if _canonicalize_token(src) == add_canon:
|
||
continue # don't remove the add itself
|
||
auto_removes.append(src)
|
||
decisions.append(
|
||
f" modifier {add_canon!r} ({target_groups}) → auto-remove {src!r} (slot={slot})"
|
||
)
|
||
continue
|
||
# Within-slot replacement
|
||
add_slot = _classify_token(underscored)
|
||
if not add_slot:
|
||
decisions.append(f" add {add_canon!r}: no slot classification, no auto-remove")
|
||
continue
|
||
for src, slot in src_with_slot:
|
||
if slot == add_slot and _canonicalize_token(src) != add_canon \
|
||
and _canonicalize_token(src) not in explicit_remove_canon:
|
||
auto_removes.append(src)
|
||
decisions.append(
|
||
f" add {add_canon!r} (slot={add_slot}) → auto-remove {src!r} (same slot)"
|
||
)
|
||
|
||
# Dedupe preserving order; explicit-removes first, auto-removes after.
|
||
deduped: list[str] = []
|
||
seen: set[str] = set()
|
||
for t in removes + auto_removes:
|
||
c = _canonicalize_token(t)
|
||
if c in seen:
|
||
continue
|
||
seen.add(c)
|
||
deduped.append(t)
|
||
|
||
if decisions:
|
||
dbg.info("ai-patch[%s] slot-aware expansion:\n%s",
|
||
request_id, "\n".join(decisions))
|
||
else:
|
||
dbg.info("ai-patch[%s] slot-aware expansion: no auto-removes triggered", request_id)
|
||
|
||
return {**patch, "remove": deduped, "auto_removed": auto_removes}
|
||
|
||
|
||
def _canonicalize_token(s: str) -> str:
|
||
"""Normalize a token for matching: strip whitespace, drop backslash
|
||
escapes used by SD to literalize parens, swap underscores to spaces,
|
||
collapse internal whitespace. Case is preserved (capitalization can
|
||
carry meaning in tags)."""
|
||
s = (s or "").strip()
|
||
if not s:
|
||
return ""
|
||
s = s.replace("\\(", "(").replace("\\)", ")")
|
||
s = s.replace("_", " ")
|
||
s = re.sub(r"\s+", " ", s)
|
||
return s
|
||
|
||
|
||
def _split_prompt_tokens(prompt: str) -> list[str]:
|
||
"""Split a prompt body on commas while respecting paren/bracket depth
|
||
so weighted forms like '(red, blue:1.2)' survive as one token."""
|
||
tokens: list[str] = []
|
||
depth = 0
|
||
buf: list[str] = []
|
||
for ch in prompt:
|
||
if ch in "([":
|
||
depth += 1
|
||
elif ch in ")]":
|
||
depth = max(0, depth - 1)
|
||
if ch == "," and depth == 0:
|
||
t = "".join(buf).strip()
|
||
if t:
|
||
tokens.append(t)
|
||
buf = []
|
||
else:
|
||
buf.append(ch)
|
||
t = "".join(buf).strip()
|
||
if t:
|
||
tokens.append(t)
|
||
return tokens
|
||
|
||
|
||
def _node_prompt_token_sets(node_prompt: str) -> tuple[set[str], list[str]]:
|
||
"""Section-aware parse of node_prompt. Returns (positive_lc_tokens,
|
||
negative_tokens_verbatim). The positive set is canonical-form
|
||
lowercase + weighted-inner (so `(cammy_white:1.1)` matches
|
||
`cammy white`). The negative list preserves original token strings
|
||
so we can carry them forward verbatim in patch-mode output."""
|
||
pos: set[str] = set()
|
||
neg_tokens: list[str] = []
|
||
if not node_prompt or not node_prompt.strip():
|
||
return pos, neg_tokens
|
||
for sec in _parse_sectioned_output(node_prompt):
|
||
if sec.get("is_negative"):
|
||
neg_tokens.extend(sec.get("tokens") or [])
|
||
continue
|
||
for tok in sec.get("tokens") or []:
|
||
canon = _canonicalize_token(tok).lower()
|
||
if canon:
|
||
pos.add(canon)
|
||
m = _WEIGHTED_TOKEN_RE.match(tok)
|
||
if m:
|
||
inner = _canonicalize_token(m.group(1)).lower()
|
||
if inner:
|
||
pos.add(inner)
|
||
return pos, neg_tokens
|
||
|
||
|
||
def _drop_unchanged_positives(sections: list[dict], node_prompt: str) -> list[dict]:
|
||
"""Diff section tokens against the existing node_prompt so the chip UI
|
||
shows only what *changed*. Tokens that already exist in node_prompt —
|
||
in any equivalent form (weighted/unweighted, underscore/space) — are
|
||
dropped from the diff. Positive-section tokens diff against source
|
||
positives (chip = "added"); negative-section tokens diff against
|
||
source negatives (chip = "newly removed").
|
||
|
||
Caller must build output_text BEFORE invoking this; output_text needs
|
||
the full new prompt (for paste/apply), the chip UI needs the diff."""
|
||
if not node_prompt or not node_prompt.strip():
|
||
return sections
|
||
existing_pos_lc, src_negs = _node_prompt_token_sets(node_prompt)
|
||
existing_neg_lc = {_canonicalize_token(t).lower() for t in src_negs}
|
||
existing_neg_lc.discard("")
|
||
if not existing_pos_lc and not existing_neg_lc:
|
||
return sections
|
||
out: list[dict] = []
|
||
for sec in sections:
|
||
is_neg = bool(sec.get("is_negative"))
|
||
existing_lc = existing_neg_lc if is_neg else existing_pos_lc
|
||
if not existing_lc:
|
||
out.append(sec)
|
||
continue
|
||
kept: list[str] = []
|
||
for tok in sec.get("tokens") or []:
|
||
canon = _canonicalize_token(tok).lower()
|
||
if canon and canon in existing_lc:
|
||
continue
|
||
m = _WEIGHTED_TOKEN_RE.match(tok)
|
||
if m:
|
||
inner = _canonicalize_token(m.group(1)).lower()
|
||
if inner and inner in existing_lc:
|
||
continue
|
||
kept.append(tok)
|
||
if kept:
|
||
out.append({**sec, "tokens": kept})
|
||
return out
|
||
|
||
|
||
def _add_positive_removal_chips(diffed: list[dict],
|
||
full_sections: list[dict],
|
||
node_prompt: str) -> list[dict]:
|
||
"""Surface positive-section tokens that were *dropped* from
|
||
node_prompt as red `(removed)` chips, so a swap like 'barefoot →
|
||
white_socks' shows both adds and removes in the diff UI.
|
||
|
||
Pure UI aid — output_text was built from full_sections BEFORE this
|
||
runs and already contains the correct end-state prompt; this only
|
||
appends synthetic chip rows for the remove side. Per-section
|
||
headers preserved so visual order matches the source."""
|
||
if not node_prompt or not node_prompt.strip():
|
||
return diffed
|
||
src_sections = _parse_sectioned_output(node_prompt)
|
||
if not src_sections:
|
||
return diffed
|
||
|
||
new_pos_lc: set[str] = set()
|
||
for s in full_sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
for t in s.get("tokens") or []:
|
||
canon = _canonicalize_token(t).lower()
|
||
if canon:
|
||
new_pos_lc.add(canon)
|
||
m = _WEIGHTED_TOKEN_RE.match(t)
|
||
if m:
|
||
inner = _canonicalize_token(m.group(1)).lower()
|
||
if inner:
|
||
new_pos_lc.add(inner)
|
||
|
||
out = list(diffed)
|
||
for src_sec in src_sections:
|
||
if src_sec.get("is_negative"):
|
||
continue
|
||
removed: list[str] = []
|
||
for tok in src_sec.get("tokens") or []:
|
||
canon = _canonicalize_token(tok).lower()
|
||
if not canon:
|
||
continue
|
||
if canon in new_pos_lc:
|
||
continue
|
||
m = _WEIGHTED_TOKEN_RE.match(tok)
|
||
if m:
|
||
inner = _canonicalize_token(m.group(1)).lower()
|
||
if inner and inner in new_pos_lc:
|
||
continue
|
||
removed.append(tok)
|
||
if removed:
|
||
# is_negative reflects polarity (these were positive tags in
|
||
# the source). is_removal flags the action so the frontend can
|
||
# render strike-through + X separately from neg-text-color.
|
||
out.append({
|
||
"header": f"{src_sec['header']} (removed)",
|
||
"tokens": removed,
|
||
"is_negative": False,
|
||
"is_removal": True,
|
||
})
|
||
return out
|
||
|
||
|
||
# Top-level body-position posture groups that contradict harshly. A
|
||
# token matched into one group is mutually exclusive with tokens matched
|
||
# into a different group; within-group is fine (e.g. `sitting` and `seiza`
|
||
# can co-occur). Pattern matched against the canonical underscore form
|
||
# of the bare token (no parens, lowercase, spaces -> underscores).
|
||
#
|
||
# User's request was minimal: only the things that contradict harshly.
|
||
# sit / stand / (lay | lying | on_*) / (crouch | bent_*). Everything
|
||
# else (legs_up, looking_at_viewer, etc.) layers freely with these.
|
||
_POSE_CONFLICT_GROUPS: list[tuple[str, re.Pattern]] = [
|
||
("sitting", re.compile(r"^sit", re.IGNORECASE)),
|
||
("standing", re.compile(r"^stand", re.IGNORECASE)),
|
||
("lying", re.compile(r"^(lay|lying|on_back|on_side|on_stomach|reclin|prone|supine)",
|
||
re.IGNORECASE)),
|
||
("crouching", re.compile(r"^(crouch|bent_|squat)", re.IGNORECASE)),
|
||
("kneeling", re.compile(r"^kneel", re.IGNORECASE)),
|
||
# Locomotion verbs — exclusive with each other AND with stance groups
|
||
("running", re.compile(r"^(run|sprint|sprinting)", re.IGNORECASE)),
|
||
("walking", re.compile(r"^(walk|striding|stroll)", re.IGNORECASE)),
|
||
("jumping", re.compile(r"^(jump|leap|airborne)", re.IGNORECASE)),
|
||
]
|
||
|
||
|
||
def _classify_posture(token: str) -> str | None:
|
||
"""Return the posture group name for a token, or None if it doesn't
|
||
match any conflict-tracked posture."""
|
||
bare = _bare_form(token).replace(" ", "_")
|
||
if not bare:
|
||
return None
|
||
for name, pat in _POSE_CONFLICT_GROUPS:
|
||
if pat.match(bare):
|
||
return name
|
||
return None
|
||
|
||
|
||
def _postures_in_text(text: str) -> set[str]:
|
||
"""Find all posture groups represented in a free-text string by
|
||
word-walk (not whole-token classification). Used on user_request
|
||
so phrasings like 'switch to sitting' identify the sitting group
|
||
even though the request isn't a single Danbooru tag."""
|
||
found: set[str] = set()
|
||
for word in re.findall(r"[A-Za-z_]+", text or ""):
|
||
for name, pat in _POSE_CONFLICT_GROUPS:
|
||
if pat.match(word):
|
||
found.add(name)
|
||
break
|
||
return found
|
||
|
||
|
||
def _resolve_posture_conflicts(sections: list[dict],
|
||
user_request: str,
|
||
node_prompt: str,
|
||
request_id: str) -> list[dict]:
|
||
"""Drop posture tokens whose group conflicts with the user's
|
||
posture intent for THIS turn.
|
||
|
||
Intent comes from two sources:
|
||
1. user_request — any posture word the user typed this turn
|
||
("sitting with legs up" -> sitting group is the intent).
|
||
2. NEW tokens in the model output — postures emitted that
|
||
weren't in node_prompt (model-introduced intent, e.g. when
|
||
the user describes a scene that decomposes to a posture).
|
||
|
||
The first signal is essential: when the user iterates on a prompt
|
||
that already accumulated contradictions across previous turns
|
||
(`sitting, (standing:1.3)` both preserved), the model preserves
|
||
both per PATCH MODE — and we need to drop the conflicting one
|
||
based on what the user just SAID, not what the model emitted.
|
||
|
||
Within-group preservation is fine; only cross-group conflicts
|
||
get resolved."""
|
||
intent_groups = _postures_in_text(user_request)
|
||
|
||
node_pos, _ = _node_prompt_token_sets(node_prompt)
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
for t in s.get("tokens") or []:
|
||
group = _classify_posture(t)
|
||
if not group:
|
||
continue
|
||
canon = _canonicalize_token(t).lower()
|
||
if canon and canon in node_pos:
|
||
continue
|
||
intent_groups.add(group)
|
||
|
||
if not intent_groups:
|
||
return sections
|
||
|
||
dropped: list[str] = []
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
kept: list[str] = []
|
||
for t in s.get("tokens") or []:
|
||
group = _classify_posture(t)
|
||
if group and group not in intent_groups:
|
||
dropped.append(f"{t} (group={group})")
|
||
continue
|
||
kept.append(t)
|
||
s["tokens"] = kept
|
||
|
||
if dropped:
|
||
dbg.info(
|
||
"ai-patch[%s] posture-conflict resolved (intent=%s): dropped %s",
|
||
request_id, ", ".join(sorted(intent_groups)), "; ".join(dropped),
|
||
)
|
||
|
||
return [s for s in sections if s.get("tokens") or s.get("is_negative")]
|
||
|
||
|
||
|
||
|
||
def _preserve_existing_style_section(sections: list[dict], node_prompt: str) -> list[dict]:
|
||
"""Patch-mode: when no fresh style alias hit fires, the user's
|
||
existing // Style: section in node_prompt should survive Apply.
|
||
Pulls the prior Style block from node_prompt and inserts it into
|
||
sections (between Setting and Negative) if no Style is already
|
||
present.
|
||
|
||
Mirrors _preserve_existing_negatives — both protect against an
|
||
iterative-edit turn silently losing user-authored content."""
|
||
if not node_prompt:
|
||
return sections
|
||
has_style = any(
|
||
(s.get("header") or "").lower().startswith("// style")
|
||
for s in sections
|
||
)
|
||
if has_style:
|
||
return sections
|
||
prior = _parse_sectioned_output(node_prompt) or []
|
||
prior_style = next(
|
||
(s for s in prior if (s.get("header") or "").lower().startswith("// style")),
|
||
None,
|
||
)
|
||
if not prior_style:
|
||
return sections
|
||
# Insert before the Negative Prompt (if any), else append.
|
||
neg_idx = next(
|
||
(i for i, s in enumerate(sections) if s.get("is_negative")),
|
||
len(sections),
|
||
)
|
||
return sections[:neg_idx] + [prior_style] + sections[neg_idx:]
|
||
|
||
|
||
def _preserve_existing_negatives(sections: list[dict], node_prompt: str) -> list[dict]:
|
||
"""Patch-mode: never let a model with no Negative Prompt section
|
||
silently wipe the user's existing negs on Apply. Take node_prompt's
|
||
original negative tokens and union them into the output's Negative
|
||
Prompt section. New negs from the model (or from
|
||
_enforce_default_outfit_negation) are preserved on top."""
|
||
_, src_negs = _node_prompt_token_sets(node_prompt)
|
||
if not src_negs:
|
||
return sections
|
||
neg_section = next((s for s in sections if s.get("is_negative")), None)
|
||
if neg_section is None:
|
||
neg_section = {"header": "Negative Prompt:", "tokens": [], "is_negative": True}
|
||
sections.append(neg_section)
|
||
seen_lc = {_canonicalize_token(t).lower() for t in neg_section["tokens"]}
|
||
for tok in src_negs:
|
||
key = _canonicalize_token(tok).lower()
|
||
if key and key not in seen_lc:
|
||
neg_section["tokens"].append(tok)
|
||
seen_lc.add(key)
|
||
return sections
|
||
|
||
|
||
def _build_source_token_set(prompt: str) -> set[str]:
|
||
"""Canonical token set used by the hallucination check. Includes the
|
||
inner content of any (tag:weight) wrapper as a separate entry so an
|
||
AI that says `remove: ["red gi"]` against a source `(red gi:1.3)`
|
||
doesn't trip a false-positive 'not in source' warning."""
|
||
out: set[str] = set()
|
||
for raw in _split_prompt_tokens(prompt):
|
||
canon = _canonicalize_token(raw)
|
||
if canon:
|
||
out.add(canon)
|
||
m = _WEIGHTED_TOKEN_RE.match(raw)
|
||
if m:
|
||
inner = _canonicalize_token(m.group(1))
|
||
if inner:
|
||
out.add(inner)
|
||
return out
|
||
|
||
|
||
_SECTION_HEADER_RE = re.compile(r"^\s*//\s*(.+?)\s*$")
|
||
_NEGATIVE_PROMPT_RE = re.compile(r"^\s*negative\s+prompt\s*:\s*(.*)$", re.IGNORECASE)
|
||
_BARE_WEIGHTED_RE = re.compile(r"^([A-Za-z0-9_\-\\\(\)]+):(\d+\.?\d*)$")
|
||
|
||
|
||
_ALLOWED_SECTION_PREFIXES = (
|
||
"character", "outfit", "pose", "action", "prop",
|
||
"expression", "setting", "scene", "style",
|
||
# Quality / aesthetic tokens (`masterpiece`, `best quality`, `very awa`,
|
||
# `highres`, etc.) — admitted so users' existing quality tags from
|
||
# prior node_prompt survive the patch round-trip. Without this, the
|
||
# filter silently dropped any // Quality section the model emitted.
|
||
"quality", "aesthetic",
|
||
)
|
||
|
||
|
||
def _enforce_applies_modifiers(sections: list[dict],
|
||
applies_by_tag: dict | None,
|
||
request_id: str) -> list[dict]:
|
||
"""Server-side enforcement of [APPLIES] modifier rules. After the
|
||
model emits its sections, check that every fired [APPLIES] modifier
|
||
actually got its required tag into the right section. If not,
|
||
server adds it.
|
||
|
||
The model is given the rule in the prompt with explicit "MUST be
|
||
followed verbatim" framing — but it sometimes ignores that in favor
|
||
of options from the retrieval menu (e.g. picking `pointing_at_viewer`
|
||
instead of the [APPLIES]-required `presenting_foot`). This backstop
|
||
closes that gap deterministically.
|
||
|
||
Mirrors the design of `_enforce_default_outfit_negation` — we already
|
||
trust the server to handle slot-specific output for one class of
|
||
rule; this extends to the broader [APPLIES] class.
|
||
|
||
NOTE: do not early-return when applies_by_tag is empty — the conflict
|
||
scrub at the end of this function also fires when a conflict-anchor
|
||
tag arrives via [SEMANTIC] / literal-anchor / model-knowledge paths,
|
||
which never populate applies_by_tag."""
|
||
applies_by_tag = applies_by_tag or {}
|
||
|
||
def _find_or_create_section(target_section: str) -> dict:
|
||
# target_section is "pose" or "outfit"
|
||
prefix = "// pose" if target_section == "pose" else "// outfit"
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
header_lc = (s.get("header") or "").lower().strip()
|
||
if header_lc.startswith(prefix):
|
||
return s
|
||
# Section not present — create it with a default header.
|
||
new_header = (
|
||
"// Pose, Action & Prop" if target_section == "pose"
|
||
else "// Outfit"
|
||
)
|
||
new_section = {"header": new_header, "tokens": []}
|
||
sections.append(new_section)
|
||
return new_section
|
||
|
||
forced: list[str] = []
|
||
for canon, mod_data in applies_by_tag.items():
|
||
# The substitute (e.g. `presenting_foot` -> // Pose).
|
||
if mod_data.get("is_substitute"):
|
||
target = mod_data.get("substitute_section") or "outfit"
|
||
sect = _find_or_create_section(target)
|
||
existing = {_bare_form(t).replace(" ", "_") for t in sect["tokens"]}
|
||
if canon not in existing:
|
||
sect["tokens"].append(canon)
|
||
forced.append(f"{canon} -> {sect['header']}")
|
||
# Implies cascade (e.g. presenting_foot.implies = barefoot -> // Outfit).
|
||
implies = (mod_data.get("implies_outfit_tag") or "").strip()
|
||
if implies:
|
||
sect = _find_or_create_section("outfit")
|
||
existing = {_bare_form(t).replace(" ", "_") for t in sect["tokens"]}
|
||
if implies not in existing:
|
||
sect["tokens"].append(implies)
|
||
forced.append(f"{implies} -> {sect['header']} (cascade)")
|
||
|
||
if forced:
|
||
dbg.info(
|
||
"ai-patch[%s] [APPLIES]-enforce added: %s",
|
||
request_id, "; ".join(forced),
|
||
)
|
||
|
||
# Output-stage conflict scrub: when a tag from _MODIFIER_CONFLICT_GROUPS
|
||
# is in play (either an [APPLIES] modifier fired, or the model emitted
|
||
# the canonical tag itself via [SEMANTIC] / literal anchor / its own
|
||
# knowledge), the conflicting tag groups are mutually exclusive with it.
|
||
# Retrieval already drops conflicts from the *menu*, but the model can
|
||
# still emit a conflicting tag from any path that doesn't pass through
|
||
# the menu filter. Scrub them here so the output mirrors the curated
|
||
# invariant regardless of how the trigger got into the output.
|
||
emitted_pos_tags: set[str] = set()
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
for t in s.get("tokens") or []:
|
||
emitted_pos_tags.add(_bare_form(t).replace(" ", "_").lower())
|
||
conflict_groups: set[str] = set()
|
||
for canon in applies_by_tag:
|
||
for g in _MODIFIER_CONFLICT_GROUPS.get(canon, []):
|
||
conflict_groups.add(g)
|
||
for trigger_tag, groups in _MODIFIER_CONFLICT_GROUPS.items():
|
||
if trigger_tag in emitted_pos_tags:
|
||
conflict_groups.update(groups)
|
||
if conflict_groups:
|
||
conflict_tags: set[str] = set()
|
||
for g in conflict_groups:
|
||
conflict_tags.update(_load_tag_group(g))
|
||
scrubbed: list[str] = []
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
kept: list[str] = []
|
||
for t in s.get("tokens") or []:
|
||
tag = _bare_form(t).replace(" ", "_").lower()
|
||
if tag in conflict_tags:
|
||
scrubbed.append(t)
|
||
continue
|
||
kept.append(t)
|
||
s["tokens"] = kept
|
||
if scrubbed:
|
||
dbg.info(
|
||
"ai-patch[%s] [APPLIES]-conflict scrubbed: %s",
|
||
request_id, ", ".join(scrubbed),
|
||
)
|
||
return sections
|
||
|
||
|
||
def _section_has_node_prompt_token(section: dict, node_pos: set[str]) -> bool:
|
||
for tok in section.get("tokens") or []:
|
||
canon = _canonicalize_token(tok).lower()
|
||
if canon and canon in node_pos:
|
||
return True
|
||
m = _WEIGHTED_TOKEN_RE.match(tok)
|
||
if m:
|
||
inner = _canonicalize_token(m.group(1)).lower()
|
||
if inner and inner in node_pos:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _filter_allowed_sections(sections: list[dict],
|
||
node_pos: set[str] | None = None) -> list[dict]:
|
||
"""Drop sections that are model-invented slop. A section is kept if EITHER:
|
||
- its header matches the canonical schema (character/outfit/pose/...), OR
|
||
- it contains at least one token that came from node_prompt (user-
|
||
authored content under a freeform // comment label).
|
||
|
||
Negative Prompt section is always preserved (has its own marker).
|
||
The whitelist defends against the original failure mode (model
|
||
inventing `// Quality: masterpiece, best quality` and dumping slop) —
|
||
but a section the user wrote is the user's authority, not ours."""
|
||
out: list[dict] = []
|
||
node_pos = node_pos or set()
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
out.append(s)
|
||
continue
|
||
header = (s.get("header") or "").lstrip("/").strip().lower()
|
||
first = re.split(r"[\s,:]", header, maxsplit=1)[0].strip()
|
||
if first and any(first.startswith(p) for p in _ALLOWED_SECTION_PREFIXES):
|
||
out.append(s)
|
||
continue
|
||
if node_pos and _section_has_node_prompt_token(s, node_pos):
|
||
out.append(s)
|
||
continue
|
||
logger.info("ai-patch: dropped spurious section %r", s.get("header"))
|
||
return out
|
||
|
||
|
||
_SAME_CHAR_OUTFIT_PATTERN = re.compile(
|
||
r"([A-Za-z][\w\s\-]*?)\s+(?:in|wearing)\s+([\w\s\-]+?)\s+outfit\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _expand_bios_for_same_char_multi_outfit(
|
||
bios: list[dict], user_request: str,
|
||
) -> list[dict]:
|
||
"""Detect `<char_name> in <outfit_phrase> outfit` mentioned MULTIPLE
|
||
times for the same character (e.g. `cammy in killer_bee outfit
|
||
fighting cammy in shadaloo outfit`). For each character with >1
|
||
distinct outfit phrase, duplicate the bio with each outfit picked
|
||
from the DB and assign it as user_requested_outfit. Returns the
|
||
expanded bios list.
|
||
|
||
No-op when the pattern matches at most once per character, or when
|
||
no bio is loaded for the named character. Narrow regex so the
|
||
false-positive risk is low — must explicitly say `in <X> outfit`."""
|
||
if not bios or not user_request:
|
||
return bios
|
||
matches = list(_SAME_CHAR_OUTFIT_PATTERN.finditer(user_request))
|
||
if len(matches) < 2:
|
||
return bios
|
||
|
||
# Build (char_candidate_token_string, outfit_keyword) per match,
|
||
# take the last 1-2 tokens of the char-phrase as the candidate
|
||
# name (same suffix-window approach as outfit-borrow detection).
|
||
per_char: dict[str, list[str]] = {}
|
||
for m in matches:
|
||
char_phrase = m.group(1).strip().lower()
|
||
outfit_phrase = m.group(2).strip().lower()
|
||
char_tokens = re.findall(r"[\w\-]+", char_phrase)
|
||
if not char_tokens:
|
||
continue
|
||
candidates: list[str] = []
|
||
for w in (1, 2, 3):
|
||
if w > len(char_tokens):
|
||
break
|
||
candidates.append(" ".join(char_tokens[-w:]))
|
||
# Match against bios
|
||
for b in bios:
|
||
if not b or b.get("_outfit_source_only"):
|
||
continue
|
||
tag = (b.get("tag") or "").lower()
|
||
display = (b.get("display") or "").lower()
|
||
tag_norm = re.sub(r"[\s_\-]+", " ", tag).strip()
|
||
display_norm = re.sub(r"[\s_\-]+", " ", display).strip()
|
||
for cand in candidates:
|
||
cand_norm = re.sub(r"[\s_\-]+", " ", cand).strip()
|
||
if (cand_norm == tag_norm or cand_norm == display_norm or
|
||
(len(cand_norm) >= 3 and
|
||
(cand_norm in tag_norm or cand_norm in display_norm))):
|
||
per_char.setdefault(tag, [])
|
||
if outfit_phrase not in per_char[tag]:
|
||
per_char[tag].append(outfit_phrase)
|
||
break
|
||
|
||
# For each character with >1 distinct outfit phrase, look up
|
||
# outfits in DB and build duplicate bios
|
||
try:
|
||
from .tag_builder import get_db
|
||
except Exception:
|
||
return bios
|
||
|
||
expanded = list(bios)
|
||
for tag, outfit_phrases in per_char.items():
|
||
if len(outfit_phrases) < 2:
|
||
continue
|
||
# Skip if upstream already supplied multiple bios for this tag
|
||
# (e.g. agent-side detection or a test harness pre-loading).
|
||
existing_count = sum(
|
||
1 for b in expanded
|
||
if (b.get("tag") or "").lower() == tag
|
||
and not b.get("_outfit_source_only")
|
||
)
|
||
if existing_count >= len(outfit_phrases):
|
||
continue
|
||
# Locate the existing bio for this tag (first non-source one)
|
||
existing = next(
|
||
(b for b in expanded
|
||
if (b.get("tag") or "").lower() == tag
|
||
and not b.get("_outfit_source_only")), None,
|
||
)
|
||
if not existing:
|
||
continue
|
||
|
||
# Pick outfit rows by keyword, in order. First match keeps the
|
||
# existing bio (updated with the first outfit); subsequent
|
||
# matches get a duplicate.
|
||
conn = get_db()
|
||
outfit_data_per_phrase: list[dict] = []
|
||
for phrase in outfit_phrases:
|
||
kw = phrase.replace("_", " ").replace("-", " ").strip().lower()
|
||
if not kw:
|
||
continue
|
||
row = conn.execute(
|
||
"SELECT id, outfit_name, outfit_tags, outfit_natlang FROM outfits "
|
||
"WHERE character_tag = ? AND "
|
||
"LOWER(REPLACE(REPLACE(outfit_name, '_', ' '), '-', ' ')) LIKE ? "
|
||
"ORDER BY is_default DESC, sort_order LIMIT 1",
|
||
(existing.get("tag") or tag, f"%{kw}%"),
|
||
).fetchone()
|
||
if not row:
|
||
continue
|
||
outfit_id = row["id"] if hasattr(row, "keys") else row[0]
|
||
outfit_name = row["outfit_name"] if hasattr(row, "keys") else row[1]
|
||
outfit_tags = row["outfit_tags"] if hasattr(row, "keys") else row[2]
|
||
outfit_natlang = (row["outfit_natlang"] if hasattr(row, "keys")
|
||
else row[3])
|
||
slot_rows = conn.execute(
|
||
"SELECT slot, item, color, source_phrase FROM outfit_tag_slots "
|
||
"WHERE outfit_id = ? ORDER BY sort_order",
|
||
(outfit_id,),
|
||
).fetchall()
|
||
slots = [
|
||
{
|
||
"slot": (s["slot"] if hasattr(s, "keys") else s[0]) or "",
|
||
"item": (s["item"] if hasattr(s, "keys") else s[1]) or "",
|
||
"color": (s["color"] if hasattr(s, "keys") else s[2]) or "",
|
||
"source_phrase": (s["source_phrase"]
|
||
if hasattr(s, "keys") else s[3]) or "",
|
||
}
|
||
for s in slot_rows
|
||
]
|
||
outfit_data_per_phrase.append({
|
||
"id": outfit_id, "name": outfit_name or "",
|
||
"natlang": outfit_natlang or "",
|
||
"tags": outfit_tags or "", "slots": slots,
|
||
})
|
||
if len(outfit_data_per_phrase) < 2:
|
||
continue
|
||
|
||
# Update existing bio with first outfit as user_requested_outfit
|
||
existing["user_requested_outfit"] = outfit_data_per_phrase[0]
|
||
existing["outfit_overridden"] = True
|
||
# Insert duplicate bios after `existing` for each additional outfit
|
||
idx = expanded.index(existing)
|
||
for od in outfit_data_per_phrase[1:]:
|
||
dup = dict(existing)
|
||
dup["user_requested_outfit"] = od
|
||
dup["outfit_overridden"] = True
|
||
# Don't copy default_outfit slot mutations — each instance
|
||
# is its own outfit pick. Strip _displaced_phrases too
|
||
# since they're populated by per-bio modifier scrubs.
|
||
dup.pop("_displaced_phrases", None)
|
||
idx += 1
|
||
expanded.insert(idx, dup)
|
||
|
||
logger.info(
|
||
"ai-patch: same-char-multi-outfit expanded bio for tag=%r "
|
||
"across outfits=%s",
|
||
tag, [o["name"] for o in outfit_data_per_phrase],
|
||
)
|
||
|
||
return expanded
|
||
|
||
|
||
def _bio_outfit_tokens(bio: dict) -> list[str]:
|
||
"""Extract canonical tokens from bio outfit. Prefers slot
|
||
source_phrases; falls back to the flat outfit.tags string when
|
||
slots aren't decomposed (some outfits in the DB carry only the
|
||
comma-separated tag blob, no per-slot rows)."""
|
||
outfit = bio.get("user_requested_outfit") or bio.get("default_outfit") or {}
|
||
tokens: list[str] = []
|
||
seen: set[str] = set()
|
||
slots = outfit.get("slots") or []
|
||
for s in slots:
|
||
phrase = (s.get("source_phrase") or "").strip()
|
||
if not phrase:
|
||
continue
|
||
canon = phrase.lower().replace(" ", "_")
|
||
if canon in seen:
|
||
continue
|
||
seen.add(canon)
|
||
tokens.append(canon)
|
||
if tokens:
|
||
return tokens
|
||
# Fallback: flat outfit_tags blob, comma-split.
|
||
flat = (outfit.get("tags") or "").strip()
|
||
if flat:
|
||
for raw in flat.split(","):
|
||
t = raw.strip().lower().replace(" ", "_")
|
||
if not t or t in seen:
|
||
continue
|
||
seen.add(t)
|
||
tokens.append(t)
|
||
return tokens
|
||
|
||
|
||
def _ensure_same_char_multi_outfit_sections(
|
||
sections: list[dict], bios: list[dict], request_id: str,
|
||
) -> list[dict]:
|
||
"""Two-of-the-same-character-in-different-outfits scenario. If bios
|
||
has duplicate `tag` entries (same canonical character, different
|
||
user_requested_outfit), the patch model usually emits only one
|
||
// Character + // Outfit section regardless — because the two bios
|
||
look identical at the header level. Server-side: detect duplicate
|
||
tags, duplicate the existing // Character section per instance,
|
||
and pair each with the matching outfit slots. Headers get
|
||
disambiguated with the outfit name (e.g.
|
||
`// Character: cammy_white (Killer Bee)`) so the downstream
|
||
`_dedup_section_headers` doesn't collapse them.
|
||
|
||
Skips bios with `_outfit_source_only=True` (outfit-borrow case).
|
||
"""
|
||
if not bios or not sections:
|
||
return sections
|
||
from collections import defaultdict
|
||
by_tag: dict[str, list[dict]] = defaultdict(list)
|
||
for b in bios:
|
||
if not b or not b.get("tag"):
|
||
continue
|
||
if b.get("_outfit_source_only"):
|
||
continue
|
||
by_tag[(b.get("tag") or "").lower()].append(b)
|
||
dup_tags = {t: bs for t, bs in by_tag.items() if len(bs) > 1}
|
||
if not dup_tags:
|
||
return sections
|
||
|
||
out = list(sections)
|
||
for tag, dup_bios in dup_tags.items():
|
||
# Find existing // Character section for this tag
|
||
char_idx = None
|
||
for i, s in enumerate(out):
|
||
if s.get("is_negative"):
|
||
continue
|
||
header_lc = (s.get("header") or "").lower()
|
||
if header_lc.startswith("// character") and tag in header_lc:
|
||
char_idx = i
|
||
break
|
||
if char_idx is None:
|
||
# No existing section to clone — skip (a totally different
|
||
# failure mode that _ensure_bio_outfits_emitted will catch)
|
||
continue
|
||
|
||
first_char_section = out[char_idx]
|
||
char_body_tokens = list(first_char_section.get("tokens") or [])
|
||
|
||
first_outfit = (dup_bios[0].get("user_requested_outfit")
|
||
or dup_bios[0].get("default_outfit") or {})
|
||
first_outfit_name = (first_outfit.get("name") or "").strip()
|
||
|
||
# Rename existing header with outfit suffix to prevent dedup
|
||
# collapse with the duplicate(s) we're about to insert
|
||
first_char_section["header"] = (
|
||
f"// Character: {tag} ({first_outfit_name})"
|
||
if first_outfit_name else f"// Character: {tag}"
|
||
)
|
||
|
||
# Replace/insert outfit section for bios[0]
|
||
outfit_idx_first: int | None = None
|
||
for j in range(char_idx + 1, len(out)):
|
||
s = out[j]
|
||
if s.get("is_negative"):
|
||
continue
|
||
jh = (s.get("header") or "").lower()
|
||
if jh.startswith("// outfit"):
|
||
outfit_idx_first = j
|
||
break
|
||
if jh.startswith("// character"):
|
||
break
|
||
|
||
first_outfit_tokens = _bio_outfit_tokens(dup_bios[0])
|
||
first_outfit_header = (
|
||
f"// Outfit: {first_outfit_name}"
|
||
if first_outfit_name else "// Outfit"
|
||
)
|
||
if outfit_idx_first is not None and first_outfit_tokens:
|
||
out[outfit_idx_first]["header"] = first_outfit_header
|
||
out[outfit_idx_first]["tokens"] = list(first_outfit_tokens)
|
||
out[outfit_idx_first]["body_text"] = ""
|
||
insert_after = outfit_idx_first
|
||
elif first_outfit_tokens:
|
||
new_outfit = {
|
||
"header": first_outfit_header,
|
||
"tokens": list(first_outfit_tokens),
|
||
"body_text": "",
|
||
"is_negative": False,
|
||
}
|
||
out.insert(char_idx + 1, new_outfit)
|
||
insert_after = char_idx + 1
|
||
else:
|
||
insert_after = char_idx
|
||
|
||
# Insert duplicate char + outfit sections for bios[1:]
|
||
for b in dup_bios[1:]:
|
||
outfit = (b.get("user_requested_outfit")
|
||
or b.get("default_outfit") or {})
|
||
outfit_name = (outfit.get("name") or "variant").strip()
|
||
new_char = {
|
||
"header": f"// Character: {tag} ({outfit_name})",
|
||
"tokens": list(char_body_tokens),
|
||
"body_text": "",
|
||
"is_negative": False,
|
||
}
|
||
tokens = _bio_outfit_tokens(b)
|
||
new_outfit = {
|
||
"header": (f"// Outfit: {outfit_name}"
|
||
if outfit_name else "// Outfit"),
|
||
"tokens": tokens,
|
||
"body_text": "",
|
||
"is_negative": False,
|
||
}
|
||
insert_after += 1
|
||
out.insert(insert_after, new_char)
|
||
insert_after += 1
|
||
out.insert(insert_after, new_outfit)
|
||
|
||
dbg.info(
|
||
"ai-patch[%s] same-char-multi-outfit: tag=%r expanded to %d "
|
||
"instances with outfits=%s",
|
||
request_id, tag, len(dup_bios),
|
||
[(b.get("user_requested_outfit")
|
||
or b.get("default_outfit") or {}).get("name")
|
||
for b in dup_bios],
|
||
)
|
||
return out
|
||
|
||
|
||
def _ensure_bio_outfits_emitted(
|
||
sections: list[dict], bios: list[dict], request_id: str,
|
||
) -> list[dict]:
|
||
"""When a character bio has a default outfit and the model emitted
|
||
a `// Character: <tag>` section but NO matching `// Outfit:` section
|
||
after it, inject the outfit from the bio's slot data. Catches the
|
||
multi-char-build failure mode where qwen3-vl:8b emits both
|
||
`// Character: cammy_white` and `// Character: chun-li` but skips
|
||
BOTH outfit sections — leaving SDXL to freelance clothing.
|
||
|
||
Skips:
|
||
- bios with `_outfit_source_only=True` (handled by
|
||
`_apply_outfit_borrow_overwrite`)
|
||
- bios with no `default_outfit` or no slots
|
||
- characters who DO have a matching `// Outfit:` section already
|
||
"""
|
||
if not bios:
|
||
return sections
|
||
# Track which character sections have a following outfit section
|
||
# in the current output ordering. Walk sections and pair char→outfit
|
||
# by adjacency until the next character or end.
|
||
char_indices: list[tuple[int, str]] = [] # (index_in_sections, char_tag_lc)
|
||
for i, s in enumerate(sections):
|
||
if s.get("is_negative"):
|
||
continue
|
||
header = (s.get("header") or "")
|
||
header_lc = header.lower()
|
||
if header_lc.startswith("// character"):
|
||
# Match the character tag from the header AND from token body.
|
||
# Header form is `// Character: <tag>` or `// Character: <display>`.
|
||
after_colon = header.split(":", 1)[1].strip().lower() if ":" in header else ""
|
||
char_indices.append((i, after_colon))
|
||
|
||
# For each char index, find the next char (or end) and look for
|
||
# any outfit section in between.
|
||
has_outfit_for_char: dict[int, bool] = {}
|
||
for idx, (ci, _ctag) in enumerate(char_indices):
|
||
next_ci = char_indices[idx + 1][0] if idx + 1 < len(char_indices) else len(sections)
|
||
found = False
|
||
for j in range(ci + 1, next_ci):
|
||
sj = sections[j]
|
||
if sj.get("is_negative"):
|
||
continue
|
||
if (sj.get("header") or "").lower().startswith("// outfit"):
|
||
found = True
|
||
break
|
||
has_outfit_for_char[ci] = found
|
||
|
||
def _slot_tokens_for_bio(bio: dict) -> list[str]:
|
||
outfit = bio.get("default_outfit") or {}
|
||
slots = outfit.get("slots") or []
|
||
tokens: list[str] = []
|
||
seen: set[str] = set()
|
||
for s in slots:
|
||
phrase = (s.get("source_phrase") or "").strip()
|
||
if not phrase:
|
||
continue
|
||
canon = phrase.lower().replace(" ", "_")
|
||
if canon in seen:
|
||
continue
|
||
seen.add(canon)
|
||
tokens.append(canon)
|
||
return tokens
|
||
|
||
# Find a bio matching the header text for each char section, then
|
||
# inject if outfit missing. Iterate in REVERSE so insertion indices
|
||
# don't shift for later sections.
|
||
out = list(sections)
|
||
injections = 0
|
||
for ci, ctag_lc in reversed(char_indices):
|
||
if has_outfit_for_char.get(ci, False):
|
||
continue
|
||
# Match bio by tag or display, skip outfit-source-only bios
|
||
bio = None
|
||
for b in bios:
|
||
if not b or b.get("_outfit_source_only"):
|
||
continue
|
||
tag_lc = (b.get("tag") or "").lower()
|
||
display_lc = (b.get("display") or "").lower()
|
||
if (tag_lc and (tag_lc == ctag_lc or tag_lc in ctag_lc
|
||
or ctag_lc in tag_lc)):
|
||
bio = b
|
||
break
|
||
if (display_lc and (display_lc == ctag_lc
|
||
or display_lc in ctag_lc
|
||
or ctag_lc in display_lc)):
|
||
bio = b
|
||
break
|
||
if not bio:
|
||
continue
|
||
outfit = bio.get("default_outfit") or {}
|
||
tokens = _slot_tokens_for_bio(bio)
|
||
if not tokens:
|
||
continue
|
||
outfit_name = (outfit.get("name") or "").strip()
|
||
new_header = (f"// Outfit: {outfit_name}" if outfit_name
|
||
else "// Outfit")
|
||
new_section = {
|
||
"header": new_header,
|
||
"tokens": list(tokens),
|
||
"body_text": "",
|
||
"is_negative": False,
|
||
}
|
||
out.insert(ci + 1, new_section)
|
||
injections += 1
|
||
dbg.info(
|
||
"ai-patch[%s] auto-injected missing // Outfit for char=%r "
|
||
"(name=%s, tokens=%d)",
|
||
request_id, ctag_lc, outfit_name, len(tokens),
|
||
)
|
||
return out
|
||
|
||
|
||
def _apply_outfit_borrow_overwrite(
|
||
sections: list[dict], bios: list[dict], request_id: str,
|
||
) -> list[dict]:
|
||
"""When a bio is marked `_outfit_source_only`, the LLM is supposed
|
||
to rewrite the // Outfit section using that bio's outfit slots. The
|
||
PATCH MODE verbatim-preservation rule + the OUTFIT HEADER rule (keep
|
||
name from bio) collude to make qwen3-vl:8b leave the header as-is
|
||
even when it swaps the body tokens. Server-side overwrite the header
|
||
and body deterministically so the borrow always lands.
|
||
|
||
Header: `// Outfit: <source outfit name> from Character: <source tag>`
|
||
Body: tokens from source bio's outfit.slots (source_phrase per slot,
|
||
underscored, lowercased) plus any modifier slot canonicals that
|
||
survived from the prior turn (e.g. `barefoot` if user didn't ask to
|
||
fill legwear). The model's body-token guess is discarded — server
|
||
is authoritative."""
|
||
source_bio = next(
|
||
(b for b in (bios or []) if b and b.get("_outfit_source_only")),
|
||
None,
|
||
)
|
||
if not source_bio:
|
||
return sections
|
||
outfit = source_bio.get("default_outfit") or {}
|
||
slots = outfit.get("slots") or []
|
||
if not slots:
|
||
return sections
|
||
source_name = (outfit.get("name") or "").strip()
|
||
source_tag = (source_bio.get("tag") or "").strip()
|
||
new_header = f"// Outfit: {source_name} from Character: {source_tag}".strip()
|
||
new_tokens: list[str] = []
|
||
seen: set[str] = set()
|
||
for s in slots:
|
||
phrase = (s.get("source_phrase") or "").strip()
|
||
if not phrase:
|
||
continue
|
||
canon = phrase.lower().replace(" ", "_")
|
||
if canon in seen:
|
||
continue
|
||
seen.add(canon)
|
||
new_tokens.append(canon)
|
||
if not new_tokens:
|
||
return sections
|
||
out: list[dict] = []
|
||
overwritten = False
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
out.append(s)
|
||
continue
|
||
if (s.get("header") or "").strip().lower().startswith("// outfit"):
|
||
new_section = dict(s)
|
||
new_section["header"] = new_header
|
||
new_section["tokens"] = list(new_tokens)
|
||
new_section["body_text"] = ""
|
||
out.append(new_section)
|
||
overwritten = True
|
||
continue
|
||
out.append(s)
|
||
if not overwritten:
|
||
# No // Outfit section emitted — insert one after the first
|
||
# // Character section.
|
||
new_section = {
|
||
"header": new_header,
|
||
"tokens": list(new_tokens),
|
||
"body_text": "",
|
||
"is_negative": False,
|
||
}
|
||
insertion_idx = 0
|
||
for i, s in enumerate(out):
|
||
if (s.get("header") or "").lower().startswith("// character"):
|
||
insertion_idx = i + 1
|
||
break
|
||
out.insert(insertion_idx, new_section)
|
||
overwritten = True
|
||
if overwritten:
|
||
dbg.info(
|
||
"ai-patch[%s] outfit-borrow overwrite: header=%r tokens=%d "
|
||
"from source=%s outfit=%s",
|
||
request_id, new_header, len(new_tokens), source_tag, source_name,
|
||
)
|
||
return out
|
||
|
||
|
||
# Maps the first word of a // section header to a canonical concept.
|
||
# Used by `_filter_unrequested_sections` to drop sections the user did
|
||
# not ask for in build/fresh mode (no node_prompt anchor) — qwen3-vl:8b
|
||
# routinely speculates `// Pose, Action & Prop`, `// Setting / Scene`,
|
||
# and `// Quality` on bare requests like `set up cammy_white`, even
|
||
# though the build-mode prompt instructs it not to.
|
||
_SECTION_HEADER_TO_CONCEPT = {
|
||
"character": "character",
|
||
"outfit": "outfit",
|
||
"pose": "pose",
|
||
"action": "pose",
|
||
"prop": "pose",
|
||
"expression": "expression",
|
||
"setting": "setting",
|
||
"scene": "setting",
|
||
"style": "style",
|
||
"quality": "quality",
|
||
"aesthetic": "quality",
|
||
}
|
||
|
||
# Sub-intent section labels → same canonical concept space.
|
||
_SUB_INTENT_TO_CONCEPT = {
|
||
"character": "character",
|
||
"outfit": "outfit",
|
||
"strip": "outfit",
|
||
"pose": "pose",
|
||
"expression": "expression",
|
||
"setting": "setting",
|
||
"scene": "setting",
|
||
"style": "style",
|
||
"quality": "quality",
|
||
}
|
||
|
||
|
||
def _section_concept(header: str) -> str | None:
|
||
h = (header or "").lstrip("/").strip().lower()
|
||
first = re.split(r"[\s,:]", h, maxsplit=1)[0].strip()
|
||
return _SECTION_HEADER_TO_CONCEPT.get(first)
|
||
|
||
|
||
def _filter_unrequested_sections(sections: list[dict],
|
||
sub_intents: list[dict] | None,
|
||
node_prompt: str,
|
||
bios: list[dict],
|
||
request_id: str) -> list[dict]:
|
||
"""Drop sections the user did not ask for AND that did not exist in
|
||
node_prompt. Defends against build-mode speculation where the model
|
||
invents // Pose/// Setting/// Quality on a request like
|
||
`cammy white` whose decompose only emitted [character].
|
||
|
||
A section is KEPT when any of these are true:
|
||
- it's the Negative Prompt section (server-managed)
|
||
- its concept is 'style' (server-managed via auto-seed + injection)
|
||
- its concept is 'character' AND bios is non-empty (bio implies it)
|
||
- its concept is 'outfit' AND some bio has default_outfit (bio implies it)
|
||
- its concept appears in the decomposed sub_intents
|
||
- a section with that concept existed in node_prompt (preserve patch-mode)
|
||
|
||
Tag-mode only. Natlang has its own decompose-driven section gate.
|
||
"""
|
||
if not sections:
|
||
return sections
|
||
intent_concepts: set[str] = set()
|
||
for si in sub_intents or []:
|
||
c = _SUB_INTENT_TO_CONCEPT.get((si.get("section") or "").lower())
|
||
if c:
|
||
intent_concepts.add(c)
|
||
|
||
node_concepts: set[str] = set()
|
||
for s in _parse_sectioned_output(node_prompt) or []:
|
||
c = _section_concept(s.get("header") or "")
|
||
if c:
|
||
node_concepts.add(c)
|
||
|
||
bio_has_character = bool(bios)
|
||
bio_has_outfit = any(
|
||
(b.get("default_outfit") or {}).get("name") or (b.get("default_outfit") or {}).get("slots")
|
||
for b in (bios or [])
|
||
)
|
||
|
||
out: list[dict] = []
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
out.append(s)
|
||
continue
|
||
concept = _section_concept(s.get("header") or "")
|
||
if concept is None:
|
||
out.append(s)
|
||
continue
|
||
if concept == "style":
|
||
out.append(s)
|
||
continue
|
||
if concept == "character" and bio_has_character:
|
||
out.append(s)
|
||
continue
|
||
if concept == "outfit" and bio_has_outfit:
|
||
out.append(s)
|
||
continue
|
||
if concept in intent_concepts:
|
||
out.append(s)
|
||
continue
|
||
if concept in node_concepts:
|
||
out.append(s)
|
||
continue
|
||
logger.info(
|
||
"ai-patch[%s] dropped unrequested section %r (concept=%s, "
|
||
"intents=%s, node_concepts=%s)",
|
||
request_id, s.get("header"), concept,
|
||
sorted(intent_concepts), sorted(node_concepts),
|
||
)
|
||
return out
|
||
|
||
|
||
def _restore_weighted_parens(sections: list[dict]) -> list[dict]:
|
||
"""Wrap any `name:weight` token (no parens) in parens. Bare `name:weight`
|
||
is not a valid SD token form — this is always safe, regardless of bio."""
|
||
for s in sections:
|
||
new_tokens: list[str] = []
|
||
for t in s.get("tokens", []):
|
||
if t.startswith("(") or t.startswith("\\("):
|
||
new_tokens.append(t)
|
||
continue
|
||
m = _BARE_WEIGHTED_RE.match(t)
|
||
if m:
|
||
new_tokens.append(f"({m.group(1)}:{m.group(2)})")
|
||
else:
|
||
new_tokens.append(t)
|
||
s["tokens"] = new_tokens
|
||
return sections
|
||
|
||
|
||
# Color words used by the slot-swap pre-pass below. Curated for
|
||
# SD-prompt-relevant colors; not exhaustive. Compound colors like
|
||
# "dark green" or "navy blue" aren't handled — extend if needed.
|
||
_SLOT_SWAP_COLORS: frozenset[str] = frozenset({
|
||
"red", "orange", "yellow", "green", "blue", "purple", "pink",
|
||
"white", "black", "brown", "gray", "grey", "tan", "beige",
|
||
"navy", "teal", "cyan", "magenta", "violet", "gold", "silver",
|
||
})
|
||
|
||
|
||
def _resolve_color_swaps(bios: list[dict], user_request: str) -> list[dict]:
|
||
"""Server-side pre-pass for the slot-aware color swap rule. Detects
|
||
`<color> <slot-item>` patterns in user_request, mutates the matching
|
||
bio default_outfit slot to the user's color, and records the
|
||
displaced source_phrase so the negation backstop can negate it later.
|
||
|
||
The smaller backing models (qwen3-vl:8b-instruct etc.) often skip
|
||
the system-prompt rule that asks them to perform this swap. Doing
|
||
it deterministically here means the LLM never sees a stale outfit
|
||
and the negation chain (displacement -> neg) doesn't depend on the
|
||
model emitting the right tokens.
|
||
|
||
Mutates bios in place; returns same list."""
|
||
if not bios or not user_request:
|
||
return bios
|
||
user_lc = user_request.lower()
|
||
for b in bios:
|
||
default = b.get("default_outfit")
|
||
if not default:
|
||
continue
|
||
slots = default.get("slots") or []
|
||
if not slots:
|
||
continue
|
||
displaced: list[str] = []
|
||
for i, slot in enumerate(slots):
|
||
item_lc = (slot.get("item") or "").lower().replace("_", " ").strip()
|
||
current_color = (slot.get("color") or "").lower().strip()
|
||
current_phrase = (slot.get("source_phrase") or "").strip()
|
||
if not item_lc:
|
||
continue
|
||
# Try the full item (`combat boots`) first, then fall back to
|
||
# the tail word (`boots`) so `black boots` matches `combat_boots`.
|
||
item_variants = [item_lc]
|
||
tail = item_lc.rsplit(" ", 1)[-1]
|
||
if tail and tail != item_lc:
|
||
item_variants.append(tail)
|
||
for color in _SLOT_SWAP_COLORS:
|
||
if color == current_color:
|
||
continue
|
||
if not any(f"{color} {v}" in user_lc for v in item_variants):
|
||
continue
|
||
new_phrase = f"{color}_{item_lc.replace(' ', '_')}"
|
||
slots[i] = {**slot, "color": color, "source_phrase": new_phrase}
|
||
if current_phrase:
|
||
displaced.append(current_phrase)
|
||
break
|
||
if displaced:
|
||
existing = list(b.get("_displaced_phrases") or [])
|
||
for p in displaced:
|
||
if p not in existing:
|
||
existing.append(p)
|
||
b["_displaced_phrases"] = existing
|
||
return bios
|
||
|
||
|
||
def _detect_user_filled_slots(user_request: str) -> set[str]:
|
||
"""Return slot names the user is explicitly filling with a real item
|
||
in their request. Suffix-based detection via _CLOTHING_SUFFIX_GROUPS
|
||
— 'wearing white socks' -> {'legwear'}, 'red boots' -> {'footwear'}."""
|
||
if not user_request:
|
||
return set()
|
||
text_lc = user_request.lower()
|
||
slots: set[str] = set()
|
||
for suffix, slot in _CLOTHING_SUFFIX_GROUPS.items():
|
||
suffix_word = suffix.replace("_", " ")
|
||
if re.search(r"(?<!\w)" + re.escape(suffix_word) + r"(?!\w)", text_lc):
|
||
slots.add(slot)
|
||
return slots
|
||
|
||
|
||
# Tag-mode structural post-passes — strip narrowing + modifier slot-clear.
|
||
# Mirrors the natlang side's typed-delta determinism for two specific
|
||
# operations the LLM is unreliable at in patch mode (the "preserve
|
||
# verbatim" rule outranks structural rules in its reasoning):
|
||
# 1. `wearing only X` → narrow // Outfit to X tokens, drop the rest
|
||
# 2. modifier alias detected (barefoot, topless, …) → drop // Outfit
|
||
# tokens whose slot is in modifier.clears_slots
|
||
# The natlang side does this via PromptState slot states + apply_deltas.
|
||
# Tag mode reuses the LLM's emitted // Outfit tokens, mapping each back
|
||
# to its slot via the bio's outfit.slots taxonomy (authoritative when
|
||
# present) with a fallback to natlang_facts._resolve_slot_from_phrase
|
||
# for user-added tokens not in the bio (e.g. user added `red_socks` last
|
||
# turn and now says `barefoot`; legwear must drop even though the bio
|
||
# never had legwear).
|
||
|
||
|
||
def _build_token_to_slot_map_from_bios(bios: list[dict]) -> dict[str, str]:
|
||
"""Return {source_phrase: slot_name} for every slot row across all
|
||
bios. Authoritative — bio outfits are slot-decomposed in the DB."""
|
||
out: dict[str, str] = {}
|
||
for b in bios or []:
|
||
for outfit_key in ("default_outfit", "user_requested_outfit"):
|
||
outfit = b.get(outfit_key) or {}
|
||
for slot_row in outfit.get("slots") or []:
|
||
phrase = (slot_row.get("source_phrase") or "").strip()
|
||
slot = (slot_row.get("slot") or "").strip()
|
||
if phrase and slot:
|
||
out.setdefault(phrase, slot)
|
||
return out
|
||
|
||
|
||
def _slot_for_outfit_token(token: str,
|
||
bio_token_to_slot: dict[str, str]) -> str:
|
||
"""Resolve a // Outfit token to its slot. Tries the bio's
|
||
slot-decomposed outfit first (authoritative for default/named
|
||
outfits); falls back to the natlang side's
|
||
`_resolve_slot_from_phrase` for user-added items the bio doesn't
|
||
know about (e.g. red_socks added last turn). Returns "" when no
|
||
confident slot can be inferred — caller treats unknown-slot tokens
|
||
as un-droppable to avoid false positives."""
|
||
direct = bio_token_to_slot.get(token)
|
||
if direct:
|
||
return direct
|
||
try:
|
||
from .natlang_facts import _resolve_slot_from_phrase
|
||
except Exception:
|
||
return ""
|
||
inferred = _resolve_slot_from_phrase(token.replace("_", " "))
|
||
return inferred or ""
|
||
|
||
|
||
# Legacy strip post-pass deleted — the patch system prompt now owns
|
||
# `wearing only X` semantics directly (emit only the named item; let
|
||
# `_enforce_default_outfit_negation` auto-push dropped default-outfit
|
||
# slots to negatives). The previous implementation classified the strip
|
||
# target via a hand-curated `_SLOT_KEYWORDS` dict; any garment word not
|
||
# in the dict (nightgown, negligee, etc.) silently dropped the user's
|
||
# named item along with everything else. Replaced with model reasoning.
|
||
|
||
|
||
def _apply_modifier_clear_post_pass(sections: list[dict],
|
||
user_request: str,
|
||
bios: list[dict],
|
||
request_id: str) -> list[dict]:
|
||
"""When the user_request alias-matches a slot_modifier with
|
||
`clears_slots`, drop // Outfit tokens whose slot is in that set.
|
||
Mirrors the natlang side's `update_active_modifiers_from_slots`
|
||
propagation for tag mode.
|
||
|
||
Pose-domain modifiers (`is_substitute_section=pose`) are NOT a
|
||
drop trigger here — `_enforce_applies_modifiers` already adds
|
||
those to // Pose; outfit slots are unaffected by pose-only
|
||
modifiers.
|
||
|
||
Alias-only by design — `modifier_search` semantic hits are used
|
||
upstream as hints to the LLM (`[SEMANTIC]` modifier rows in the
|
||
user message), never as deterministic clear triggers. e.g. user
|
||
typing `wearing only red socks` cosine-hits `barefoot` at ~0.7
|
||
because "socks" is footwear-adjacent vocabulary; treating that
|
||
as a clear trigger would drop the user's just-added red_socks.
|
||
`_enforce_applies_modifiers` follows the same alias-only rule for
|
||
the same reason.
|
||
|
||
Skip cases:
|
||
- No alias-detected modifier
|
||
- No clears_slots in any detected modifier"""
|
||
if not user_request:
|
||
return sections
|
||
|
||
detected = _detect_modifiers_in_text(user_request) or []
|
||
all_mods = list(detected)
|
||
|
||
cleared_slots: set[str] = set()
|
||
for m in all_mods:
|
||
# Only outfit-domain modifiers drive outfit clears. Pose-domain
|
||
# modifiers (is_substitute=True, substitute_section=pose) like
|
||
# `presenting_foot` list legwear/footwear in their clears_slots
|
||
# purely as correlation hints; they don't actually require the
|
||
# legs/feet to be bare. Treating them as drop triggers would
|
||
# remove user-added socks every time they're presented to the
|
||
# camera. Same guard as `_resolve_slot_displacements`.
|
||
if m.get("is_substitute") and m.get("substitute_section") == "pose":
|
||
continue
|
||
for slot in m.get("clears_slots") or []:
|
||
slot_norm = (slot or "").strip()
|
||
if slot_norm:
|
||
cleared_slots.add(slot_norm)
|
||
|
||
if not cleared_slots:
|
||
return sections
|
||
|
||
bio_map = _build_token_to_slot_map_from_bios(bios)
|
||
|
||
dropped: list[str] = []
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
header_lc = (s.get("header") or "").lower().strip()
|
||
if not header_lc.startswith("// outfit"):
|
||
continue
|
||
kept: list[str] = []
|
||
for t in s.get("tokens") or []:
|
||
t_slot = _slot_for_outfit_token(t, bio_map)
|
||
if t_slot and t_slot in cleared_slots:
|
||
# Don't drop the modifier's own canonical tag if the
|
||
# LLM placed it in // Outfit (barefoot is a footwear-
|
||
# slot canonical that lives in outfit; we want to keep
|
||
# it as the substitute). Same idea: any outfit token
|
||
# whose value matches a detected modifier's canonical
|
||
# is the modifier itself, not a slot fill.
|
||
if t in {(m.get("canonical_tag") or "") for m in all_mods}:
|
||
kept.append(t)
|
||
continue
|
||
dropped.append(t)
|
||
continue
|
||
kept.append(t)
|
||
s["tokens"] = kept
|
||
|
||
if dropped:
|
||
dbg.info(
|
||
"ai-patch[%s] modifier-clear post-pass dropped from // Outfit: "
|
||
"%s (cleared_slots=%s)",
|
||
request_id, ", ".join(dropped),
|
||
", ".join(sorted(cleared_slots)),
|
||
)
|
||
return sections
|
||
|
||
|
||
# Phrases that signal the user wants to ADD onto existing pose, not
|
||
# replace it. When any of these prefix the request, the pose-anchor-
|
||
# override post-pass skips entirely. Distinct from posture-verb
|
||
# detection — the user can still type a posture verb, but `include
|
||
# sitting with legs up` is structurally additive.
|
||
_ADDITIVE_PREFIX_RE = re.compile(
|
||
r"^\s*(?:include|also|add(?:itionally)?|plus|and)\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _apply_pose_anchor_override_post_pass(sections: list[dict],
|
||
user_request: str,
|
||
node_prompt: str,
|
||
request_id: str) -> list[dict]:
|
||
"""When the user_request contains a posture verb (`standing up`,
|
||
`kneeling`, `sitting`, …), drop bio-framing pose tokens that were
|
||
preserved from node_prompt. Mirrors natlang's `is_anchor_override=
|
||
True` + `replaces_all=True` on PoseChangeDelta — but with two
|
||
important nuances borrowed from the natlang state model:
|
||
|
||
1. Pose-domain slot_modifier canonicals (`presenting_foot`,
|
||
`foot_focus`, …) survive. The natlang side keeps these in
|
||
`pose_modifiers` which is NOT wiped by `replaces_all=True`
|
||
(only `descriptive_facts` is). They describe persistent pose
|
||
facts — `presenting_foot` is true regardless of whether the
|
||
character is sitting, standing, or kneeling. Wiping them on
|
||
any posture change destroys legitimate cross-posture pose
|
||
state.
|
||
|
||
2. Additive prefixes (`include X`, `also X`, `add X`) skip the
|
||
override entirely. Even with a posture verb, the user is
|
||
explicitly asking to ADD, not REPLACE.
|
||
|
||
Tokens NOT in node_prompt survive in any case — those are the
|
||
LLM's new emissions for this turn (the user's posture verb,
|
||
pose modifiers added by `_enforce_applies_modifiers` this turn).
|
||
|
||
Skip cases:
|
||
- No posture verb in user_request
|
||
- User_request starts with an additive prefix
|
||
- No node_prompt (build mode — nothing to override)"""
|
||
if not user_request or not node_prompt:
|
||
return sections
|
||
if _ADDITIVE_PREFIX_RE.search(user_request):
|
||
return sections
|
||
try:
|
||
from .ai_request_parser import _POSTURE_VERBS
|
||
except Exception:
|
||
return sections
|
||
|
||
user_lc = user_request.lower()
|
||
has_posture_verb = any(
|
||
re.search(r"(?<!\w)" + re.escape(v) + r"(?!\w)", user_lc)
|
||
for v in _POSTURE_VERBS
|
||
)
|
||
if not has_posture_verb:
|
||
return sections
|
||
|
||
node_pos_set, _ = _node_prompt_token_sets(node_prompt)
|
||
if not node_pos_set:
|
||
return sections
|
||
|
||
# Pose-domain slot_modifier canonicals survive the override.
|
||
pose_modifier_canonicals: set[str] = set()
|
||
try:
|
||
for m in _load_slot_modifiers() or []:
|
||
if m.get("is_substitute") and m.get("substitute_section") == "pose":
|
||
canon = (m.get("canonical_tag") or "").strip()
|
||
if canon:
|
||
pose_modifier_canonicals.add(canon)
|
||
except Exception:
|
||
pass
|
||
|
||
# Tokens the user explicitly re-typed this turn must survive even
|
||
# if they happen to also be in node_prompt. `sitting with legs up`
|
||
# on a prompt that already had `sitting` in pose should not drop
|
||
# `sitting` — the user just confirmed it.
|
||
user_lc_compact = re.sub(r"[\s_\-]+", " ", user_lc).strip()
|
||
|
||
def _user_mentioned(token: str) -> bool:
|
||
tc = re.sub(r"[\s_\-]+", " ", token.lower()).strip()
|
||
if not tc:
|
||
return False
|
||
return tc in user_lc_compact
|
||
|
||
dropped: list[str] = []
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
header_lc = (s.get("header") or "").lower().strip()
|
||
if not header_lc.startswith("// pose"):
|
||
continue
|
||
kept: list[str] = []
|
||
for t in s.get("tokens") or []:
|
||
t_canon = _canonicalize_token(t).lower()
|
||
# Pose modifier canonicals always survive — cross-posture
|
||
# facts the user established in a prior turn.
|
||
if (t in pose_modifier_canonicals
|
||
or t_canon in pose_modifier_canonicals):
|
||
kept.append(t)
|
||
continue
|
||
# User-mentioned-this-turn survives too — handles repeats
|
||
# like `sitting with legs up` over an existing prompt that
|
||
# already had `sitting`.
|
||
if _user_mentioned(t) or _user_mentioned(t_canon):
|
||
kept.append(t)
|
||
continue
|
||
if t_canon and t_canon in node_pos_set:
|
||
dropped.append(t)
|
||
continue
|
||
kept.append(t)
|
||
s["tokens"] = kept
|
||
|
||
if dropped:
|
||
dbg.info(
|
||
"ai-patch[%s] pose-anchor-override dropped preserved pose "
|
||
"tokens: %s",
|
||
request_id, ", ".join(dropped),
|
||
)
|
||
return sections
|
||
|
||
|
||
def _resolve_slot_displacements(user_request: str,
|
||
node_prompt: str = "") -> set[str]:
|
||
"""Return the canonical_tags of modifiers displaced by the user
|
||
explicitly filling a slot the modifier covers. Inverse of
|
||
_resolve_modifier_clears: 'wearing white socks' fills legwear, and
|
||
`barefoot` (clears_slots=[footwear, legwear], substitute_section=
|
||
outfit) is now displaced — but ONLY if `barefoot` was actually
|
||
active before this turn. The signal we have for that is the
|
||
modifier's alias phrases (`barefoot`, `bare feet`, `no shoes`...)
|
||
appearing in `node_prompt`.
|
||
|
||
Without the node_prompt gate, this function listed every modifier
|
||
whose clears_slots intersects the user's filled slot — for a
|
||
fresh-build "blue socks" request that produced three alerts
|
||
(`barefoot`, `nude`, `completely_nude`) telling the LLM to scrub
|
||
phrases that never existed in any prior state. That's stuffing the
|
||
system prompt with hypotheticals, which the user explicitly flagged
|
||
as cheating.
|
||
|
||
Only outfit-section modifiers count. `presenting_foot` is a pose
|
||
that *implies* legwear/footwear are empty and lists those in
|
||
clears_slots, but the pose itself is compatible with the user
|
||
adding white_socks — the character can present a sock-clad foot.
|
||
Pose-domain modifiers must not be stripped just because the user
|
||
added an item in a slot the pose merely correlates with.
|
||
|
||
Self-collision guard: if the user explicitly invoked the modifier
|
||
(e.g. `remove brown boots, add barefoot`), the suffix scanner sees
|
||
`boots` and flags footwear as filled, then the displacement loop
|
||
would scrub `barefoot` itself because barefoot.clears includes
|
||
footwear. The fix is to skip any modifier the user named directly
|
||
via `_detect_modifiers_in_text` — that match means the modifier IS
|
||
the user's intent, not a victim of displacement."""
|
||
user_slots = _detect_user_filled_slots(user_request)
|
||
if not user_slots:
|
||
return set()
|
||
explicit_modifier_tags = {
|
||
(m.get("canonical_tag") or "").lower()
|
||
for m in _detect_modifiers_in_text(user_request)
|
||
}
|
||
node_prompt_lc = (node_prompt or "").lower()
|
||
out: set[str] = set()
|
||
for mod in _load_slot_modifiers():
|
||
section = (mod.get("substitute_section") or "").strip().lower()
|
||
if section != "outfit":
|
||
continue
|
||
canon = (mod.get("canonical_tag") or "").lower()
|
||
if canon and canon in explicit_modifier_tags:
|
||
continue
|
||
clears = {(s or "").lower() for s in (mod.get("clears_slots") or [])}
|
||
if not (clears & user_slots):
|
||
continue
|
||
# Real-displacement gate: only count the modifier as displaced
|
||
# if its alias phrases actually appear in node_prompt. Without
|
||
# node_prompt (build mode) there's nothing to displace.
|
||
if not node_prompt_lc:
|
||
continue
|
||
aliases = list(mod.get("aliases") or [])
|
||
if canon:
|
||
aliases = [canon.replace("_", " "), canon] + aliases
|
||
present = False
|
||
for alias in aliases:
|
||
alias_lc = (alias or "").strip().lower()
|
||
if not alias_lc:
|
||
continue
|
||
if re.search(
|
||
rf"(?<!\w){re.escape(alias_lc)}(?!\w)", node_prompt_lc,
|
||
):
|
||
present = True
|
||
break
|
||
if present and canon:
|
||
out.add(canon)
|
||
return out
|
||
|
||
|
||
def _resolve_modifier_clears(bios: list[dict], user_request: str) -> list[dict]:
|
||
"""Apply [APPLIES] modifier `clears_slots` server-side: when a modifier
|
||
like `barefoot` fires with clears=[footwear, legwear], drop matching
|
||
bio outfit slots BEFORE the LLM sees them and record their source
|
||
phrases as displaced so negation can negate them.
|
||
|
||
Smaller backing models (qwen3-vl:8b-instruct) often skip the slot-
|
||
clear rule; doing it deterministically here makes the modifier
|
||
contract self-enforcing regardless of model capability.
|
||
|
||
Mutates bios in place; returns same list."""
|
||
if not bios or not user_request:
|
||
return bios
|
||
detected = _detect_modifiers_in_text(user_request)
|
||
if not detected:
|
||
return bios
|
||
cleared_slot_names: set[str] = set()
|
||
for d in detected:
|
||
for s in (d.get("clears_slots") or []):
|
||
s = (s or "").strip().lower()
|
||
if s:
|
||
cleared_slot_names.add(s)
|
||
if not cleared_slot_names:
|
||
return bios
|
||
|
||
for b in bios:
|
||
default = b.get("default_outfit")
|
||
if not default:
|
||
continue
|
||
slots = default.get("slots") or []
|
||
if not slots:
|
||
continue
|
||
kept: list[dict] = []
|
||
displaced: list[str] = []
|
||
for slot in slots:
|
||
slot_name = (slot.get("slot") or "").strip().lower()
|
||
if slot_name in cleared_slot_names:
|
||
phrase = (slot.get("source_phrase") or "").strip()
|
||
if phrase:
|
||
displaced.append(phrase)
|
||
else:
|
||
kept.append(slot)
|
||
if displaced:
|
||
default["slots"] = kept
|
||
existing = list(b.get("_displaced_phrases") or [])
|
||
for p in displaced:
|
||
if p not in existing:
|
||
existing.append(p)
|
||
b["_displaced_phrases"] = existing
|
||
# Also strip the cleared phrases from the comma-separated
|
||
# `outfit_tags` string so the model doesn't re-emit them
|
||
# from that field.
|
||
tags_str = (default.get("tags") or "").strip()
|
||
if tags_str:
|
||
disp_canon = {_canonicalize_token(p) for p in displaced}
|
||
kept_tags = [
|
||
t.strip() for t in tags_str.split(",") if t.strip()
|
||
and _canonicalize_token(t.strip()) not in disp_canon
|
||
]
|
||
default["tags"] = ", ".join(kept_tags)
|
||
return bios
|
||
|
||
|
||
def _normalize_separators(s: str) -> str:
|
||
"""Lowercase and collapse hyphen/space/underscore separator runs to a
|
||
single `_`. Both bio names and intent text are normalized this way
|
||
before fuzzy matching, so `chun-li`, `chun_li`, and `chun li` all
|
||
match interchangeably regardless of which form decompose emitted on
|
||
a given stochastic turn."""
|
||
return re.sub(r"[\s\-_]+", "_", (s or "").lower().strip())
|
||
|
||
|
||
def _bio_name_forms(bio: dict) -> set[str]:
|
||
"""Separator-normalized name-form variants. All forms come back
|
||
underscore-collapsed; intent text is normalized via
|
||
`_normalize_separators` the same way before substring check.
|
||
Includes paren-suffix-stripped form (`mythra_(xenoblade)` →
|
||
`mythra`) and first-token form (`cammy` from `cammy_white`)."""
|
||
out: set[str] = set()
|
||
tag = (bio.get("tag") or "").lower().strip()
|
||
display = (bio.get("display") or "").lower().strip()
|
||
for s in (tag, display):
|
||
if not s:
|
||
continue
|
||
out.add(_normalize_separators(s))
|
||
# Strip parenthetical disambiguation suffix.
|
||
bare = re.sub(r"\s*\([^)]*\)", "", s).strip()
|
||
if bare and bare != s:
|
||
out.add(_normalize_separators(bare))
|
||
# First-token form (handles `cammy white` matching bio
|
||
# `cammy_white`, etc.).
|
||
words = re.split(r"[_\s\-]+", s)
|
||
if words and words[0]:
|
||
out.add(_normalize_separators(words[0]))
|
||
return {f for f in out if f}
|
||
|
||
|
||
def _resolve_bio_roles(bios: list[dict], sub_intents: list[dict],
|
||
request_id: str,
|
||
user_request: str = "") -> list[dict]:
|
||
"""Borrow attribution for outfit AND pose. Identify donor bios — a
|
||
character whose OUTFIT or POSE is being borrowed onto a different
|
||
subject — re-attach the borrowed data to the subject, and drop the
|
||
donor from the bios list so multi-character composition doesn't
|
||
false-positive `2girls`.
|
||
|
||
A bio is an OUTFIT-DONOR when no `character:` intent contains its
|
||
name AND an `outfit:` or `strip:` intent contains a possessive-form
|
||
reference to it (`name's`, `name s'`). Pose-donor mirrors this on
|
||
`pose:` intents.
|
||
|
||
A single donor bio can play both roles ("cammy in pyra's outfit
|
||
doing pyra's victory pose") — pyra contributes both her outfit and
|
||
her matched_pose to cammy.
|
||
|
||
A bio that's possessive-named in donor text but ALSO appears in a
|
||
`character:` intent stays a subject (safer default — over-emit
|
||
rather than wrongly drop a render target).
|
||
|
||
v1 scope: 1 subject + ≤1 outfit-donor + ≤1 pose-donor (covers the
|
||
cross_char harness scenarios `cammy in chunli's outfit with mythra's
|
||
pose`). When we detect more than one of either donor type or more
|
||
than one subject, bail and let multi-char composition handle it.
|
||
|
||
Mutates the chosen subject in place; returns subjects-only list."""
|
||
if not bios or not sub_intents:
|
||
return bios
|
||
|
||
char_intents = [i for i in sub_intents
|
||
if (i.get("section") or "").lower() == "character"]
|
||
outfit_intents = [i for i in sub_intents
|
||
if (i.get("section") or "").lower() in ("outfit", "strip")]
|
||
pose_intents = [i for i in sub_intents
|
||
if (i.get("section") or "").lower() == "pose"]
|
||
|
||
if not char_intents or (not outfit_intents and not pose_intents):
|
||
return bios
|
||
|
||
char_texts = [_normalize_separators(i.get("text") or "")
|
||
for i in char_intents]
|
||
outfit_texts = [_normalize_separators(i.get("text") or "")
|
||
for i in outfit_intents]
|
||
pose_texts = [_normalize_separators(i.get("text") or "")
|
||
for i in pose_intents]
|
||
# Decompose occasionally drops the possessive form when extracting
|
||
# an outfit/pose intent (`cammy in pyra's red outfit` -> `[outfit]
|
||
# red outfit` — pyra elided). Without this fallback the bio for
|
||
# pyra falls through to "ambiguous -> subject" and the borrow
|
||
# never fires. Use the original user_request as a secondary signal
|
||
# for possessive-form classification when the intent text alone is
|
||
# insufficient.
|
||
user_text_norm = _normalize_separators(user_request or "")
|
||
|
||
def _possessive_in(forms: set[str], intent_texts: list[str]) -> bool:
|
||
# All possessive shapes after separator normalization:
|
||
# `name's`, `name_'s`, `names'`. The middle form catches
|
||
# `name 's` (space before apostrophe) once normalized.
|
||
for t in intent_texts:
|
||
for form in forms:
|
||
possessives = (f"{form}'s", f"{form}_'s", f"{form}s'")
|
||
if any(p in t for p in possessives):
|
||
return True
|
||
return False
|
||
|
||
forms_by_id: dict[int, set[str]] = {}
|
||
for b in bios:
|
||
if b and b.get("tag"):
|
||
forms_by_id[id(b)] = _bio_name_forms(b)
|
||
|
||
subjects: list[dict] = []
|
||
outfit_donor: dict | None = None
|
||
pose_donor: dict | None = None
|
||
extra_donor_count = 0
|
||
|
||
for b in bios:
|
||
if not b or not b.get("tag"):
|
||
subjects.append(b)
|
||
continue
|
||
forms = forms_by_id.get(id(b), set())
|
||
|
||
is_subject = any(
|
||
form in t for t in char_texts for form in forms
|
||
)
|
||
if is_subject:
|
||
subjects.append(b)
|
||
continue
|
||
|
||
is_outfit_donor = _possessive_in(forms, outfit_texts)
|
||
is_pose_donor = _possessive_in(forms, pose_texts)
|
||
|
||
# Fallback: decompose lost the possessive — check raw
|
||
# user_request. Only fires when the bio's possessive form
|
||
# is in the user's text directly (not just in the elided
|
||
# intent). Classify by which intent types exist: outfit-only
|
||
# -> outfit_donor; pose-only -> pose_donor; both -> outfit
|
||
# (heuristic — outfit is the more common borrow shape and
|
||
# the user can disambiguate by re-saying if wrong).
|
||
if (not is_outfit_donor and not is_pose_donor
|
||
and _possessive_in(forms, [user_text_norm])):
|
||
if outfit_intents and not pose_intents:
|
||
is_outfit_donor = True
|
||
elif pose_intents and not outfit_intents:
|
||
is_pose_donor = True
|
||
elif outfit_intents and pose_intents:
|
||
is_outfit_donor = True
|
||
|
||
if not is_outfit_donor and not is_pose_donor:
|
||
subjects.append(b) # ambiguous → safer default
|
||
continue
|
||
|
||
if is_outfit_donor:
|
||
if outfit_donor is None:
|
||
outfit_donor = b
|
||
else:
|
||
extra_donor_count += 1
|
||
if is_pose_donor:
|
||
if pose_donor is None:
|
||
pose_donor = b
|
||
else:
|
||
extra_donor_count += 1
|
||
|
||
if outfit_donor is None and pose_donor is None:
|
||
return bios
|
||
|
||
if len(subjects) != 1 or extra_donor_count > 0:
|
||
dbg.info(
|
||
"ai-patch[%s] bio-role: %d subject(s), outfit_donor=%s "
|
||
"pose_donor=%s extra=%d — multi-subject borrow not yet "
|
||
"supported, treating all as subjects",
|
||
request_id, len(subjects),
|
||
(outfit_donor or {}).get("tag"),
|
||
(pose_donor or {}).get("tag"),
|
||
extra_donor_count,
|
||
)
|
||
return bios
|
||
|
||
subject = subjects[0]
|
||
transferred: list[str] = []
|
||
|
||
if outfit_donor is not None:
|
||
donor_outfit = (outfit_donor.get("user_requested_outfit")
|
||
or outfit_donor.get("default_outfit"))
|
||
if donor_outfit and not subject.get("user_requested_outfit"):
|
||
subject["user_requested_outfit"] = dict(donor_outfit)
|
||
transferred.append(
|
||
f"outfit={donor_outfit.get('name') or '(unnamed)'} "
|
||
f"from {outfit_donor.get('tag')}"
|
||
)
|
||
|
||
if pose_donor is not None:
|
||
donor_pose = pose_donor.get("matched_pose")
|
||
if donor_pose and not subject.get("matched_pose"):
|
||
subject["matched_pose"] = dict(donor_pose)
|
||
transferred.append(
|
||
f"pose={donor_pose.get('name') or '(unnamed)'} "
|
||
f"from {pose_donor.get('tag')}"
|
||
)
|
||
|
||
if transferred:
|
||
dbg.info(
|
||
"ai-patch[%s] bio-role: subject=%s borrows %s",
|
||
request_id, subject.get("tag"), "; ".join(transferred),
|
||
)
|
||
return subjects
|
||
|
||
|
||
def _drop_displaced_modifiers(sections: list[dict], displaced: set[str],
|
||
request_id: str) -> list[dict]:
|
||
"""Scrub modifier canonical tags from positive sections when the
|
||
user has explicitly filled a slot they cover. Catches the case
|
||
where patch mode preserves a modifier from node_prompt (e.g.
|
||
`barefoot`) but the user's request fills the same slot with a
|
||
real item (e.g. `wearing white socks`). Without this scrub the
|
||
output contains both, which is contradictory."""
|
||
if not displaced:
|
||
return sections
|
||
dropped: list[str] = []
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
kept: list[str] = []
|
||
for t in s.get("tokens") or []:
|
||
tag = _bare_form(t).replace(" ", "_").lower()
|
||
if tag in displaced:
|
||
dropped.append(t)
|
||
continue
|
||
kept.append(t)
|
||
s["tokens"] = kept
|
||
if dropped:
|
||
dbg.info(
|
||
"ai-patch[%s] slot-displacement scrubbed: %s",
|
||
request_id, ", ".join(dropped),
|
||
)
|
||
return sections
|
||
|
||
|
||
_PRIOR_CHAR_HEADER_RE = re.compile(
|
||
r"^\s*//\s*Character\s*:\s*([^\n]+?)\s*$",
|
||
re.MULTILINE | re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _enforce_character_swap(
|
||
sections: list[dict], bios: list[dict],
|
||
sub_intents: list[dict] | None, request_id: str,
|
||
) -> list[dict]:
|
||
"""Bios is the truth signal for who's in the rendered scene. Any
|
||
`// Character: X` section whose canonical isn't in bios is stale.
|
||
|
||
Two failure shapes the 8B produces:
|
||
1. Echoes prior char's section AND appends new one (two // Character
|
||
blocks, second one matches bios). We drop the stale one.
|
||
2. Echoes ONLY the prior char's section verbatim because patch-mode
|
||
preservation said to preserve existing sections (single stale
|
||
// Character block, no replacement). When bios has an "orphan"
|
||
canonical not yet represented anywhere, we RENAME the stale
|
||
section's header to the orphan canonical and rebuild the body
|
||
from the bio so the swap actually completes. Without this the
|
||
section just gets dropped and the editor loses its character.
|
||
|
||
Doesn't gate on sub_intents — decompose output for `change
|
||
character to X` / `switch character to X` / `swap with X` / etc.
|
||
varies stochastically, so trusting bios is robust across phrasings.
|
||
|
||
For legitimate multi-char additions (`cammy and chun-li sparring`),
|
||
the agent dispatches BOTH in character_queries so bios has both —
|
||
neither // Character section is stale."""
|
||
if not sections or not bios:
|
||
return sections
|
||
|
||
new_canons: list[str] = []
|
||
bio_by_canon: dict[str, dict] = {}
|
||
for b in bios:
|
||
tag = (b.get("tag") or "").strip()
|
||
if not tag:
|
||
continue
|
||
canon = _normalize_separators(tag)
|
||
if canon and canon not in bio_by_canon:
|
||
new_canons.append(canon)
|
||
bio_by_canon[canon] = b
|
||
if not new_canons:
|
||
return sections
|
||
|
||
represented: set[str] = set()
|
||
for s in sections:
|
||
header = s.get("header") or ""
|
||
m = re.match(
|
||
r"^\s*//\s*Character\s*:\s*(.+?)\s*$", header, re.IGNORECASE,
|
||
)
|
||
if m:
|
||
section_canon = _normalize_separators(m.group(1))
|
||
if section_canon in bio_by_canon:
|
||
represented.add(section_canon)
|
||
orphan_canons = [c for c in new_canons if c not in represented]
|
||
|
||
out: list[dict] = []
|
||
dropped_canons: list[str] = []
|
||
renamed: list[tuple[str, str]] = []
|
||
for s in sections:
|
||
header = s.get("header") or ""
|
||
m = re.match(
|
||
r"^\s*//\s*Character\s*:\s*(.+?)\s*$", header, re.IGNORECASE,
|
||
)
|
||
if m:
|
||
section_canon = _normalize_separators(m.group(1))
|
||
if section_canon and section_canon not in bio_by_canon:
|
||
if orphan_canons:
|
||
target_canon = orphan_canons.pop(0)
|
||
target_bio = bio_by_canon[target_canon]
|
||
weighted = _bio_weighted_tag_for(target_canon, [target_bio])
|
||
new_tokens = [weighted] if weighted else []
|
||
out.append({
|
||
**s,
|
||
"header": f"// Character: {target_canon}",
|
||
"tokens": new_tokens,
|
||
})
|
||
renamed.append((section_canon, target_canon))
|
||
continue
|
||
dropped_canons.append(section_canon)
|
||
continue
|
||
out.append(s)
|
||
|
||
if dropped_canons or renamed:
|
||
dbg.info(
|
||
"ai-patch[%s] character-swap: renamed=%s dropped=%s bios=%s",
|
||
request_id,
|
||
", ".join(f"{a}->{b}" for a, b in renamed) or "none",
|
||
", ".join(dropped_canons) or "none",
|
||
", ".join(sorted(bio_by_canon.keys())),
|
||
)
|
||
return out
|
||
|
||
|
||
def _scrub_prior_character_default_negs(
|
||
sections: list[dict], bios: list[dict], node_prompt: str,
|
||
request_id: str,
|
||
) -> list[dict]:
|
||
"""On a character-swap patch, remove preserved negative tokens that
|
||
were auto-added by a prior turn's `_enforce_default_outfit_negation`
|
||
against the OLD character's default-outfit slot phrases. Without
|
||
this scrub, a swap from cammy_white -> mythra_(xenoblade) leaves
|
||
cammy's `green leotard, beret, red gloves, ...` in the negative
|
||
prompt forever.
|
||
|
||
Detection: prior // Character header in node_prompt names a
|
||
canonical that's absent from the new bios list. If so, query the DB
|
||
for the prior character's default-outfit slot source_phrases and
|
||
drop any matching tokens from the negative prompt. The new
|
||
character's default-outfit negs (added by
|
||
`_enforce_default_outfit_negation` upstream) survive untouched."""
|
||
if not bios or not node_prompt:
|
||
return sections
|
||
|
||
new_canons = {
|
||
(b.get("tag") or "").lower().strip()
|
||
for b in bios if b and b.get("tag")
|
||
}
|
||
if not new_canons:
|
||
return sections
|
||
|
||
prior_match = _PRIOR_CHAR_HEADER_RE.search(node_prompt)
|
||
if not prior_match:
|
||
return sections
|
||
prior_canon = prior_match.group(1).strip().lower()
|
||
prior_canon = re.sub(r"\s+", " ", prior_canon).replace(" ", "_")
|
||
if not prior_canon or prior_canon in new_canons:
|
||
return sections
|
||
|
||
try:
|
||
from .tag_builder import get_db
|
||
db = get_db()
|
||
rows = db.execute(
|
||
"SELECT ots.source_phrase FROM outfit_tag_slots ots "
|
||
"JOIN outfits o ON ots.outfit_id = o.id "
|
||
"WHERE LOWER(o.character_tag) = ? AND o.is_default = 1",
|
||
(prior_canon,),
|
||
).fetchall()
|
||
except Exception:
|
||
logger.warning(
|
||
"ai-patch[%s] prior-char neg scrub: DB lookup failed for %s",
|
||
request_id, prior_canon, exc_info=True,
|
||
)
|
||
return sections
|
||
|
||
prior_phrases: set[str] = set()
|
||
for r in rows:
|
||
phrase = (r["source_phrase"] or "").strip()
|
||
if phrase:
|
||
prior_phrases.add(_canonicalize_token(phrase).lower())
|
||
if not prior_phrases:
|
||
return sections
|
||
|
||
neg = next((s for s in sections if s.get("is_negative")), None)
|
||
if neg is None or not neg.get("tokens"):
|
||
return sections
|
||
|
||
kept: list[str] = []
|
||
scrubbed: list[str] = []
|
||
for t in neg["tokens"]:
|
||
if _canonicalize_token(t).lower() in prior_phrases:
|
||
scrubbed.append(t)
|
||
else:
|
||
kept.append(t)
|
||
if scrubbed:
|
||
neg["tokens"] = kept
|
||
dbg.info(
|
||
"ai-patch[%s] prior-char neg scrub: char swap %s -> %s "
|
||
"removed %d stale negs: %s",
|
||
request_id, prior_canon,
|
||
", ".join(sorted(new_canons)),
|
||
len(scrubbed), ", ".join(scrubbed),
|
||
)
|
||
return sections
|
||
|
||
|
||
def _enforce_default_outfit_negation(sections: list[dict], bios: list[dict]) -> list[dict]:
|
||
"""For each bio, diff the trained-canonical default outfit's slot
|
||
source_phrases against the AI's // Outfit section. Any phrase NOT in
|
||
the active outfit goes into Negative Prompt automatically — the model
|
||
has the character heavily entangled with their trained default in
|
||
latent space, so picking a different outfit needs explicit pushback
|
||
or default items leak into the image (e.g. user picks Killer Bee but
|
||
`green_leotard`/`beret` show up because Delta Red is Cammy's trained
|
||
default).
|
||
|
||
Reads `canonical_default_slot_phrases` (always shipped by
|
||
`_api_match_characters` regardless of which outfit was chosen).
|
||
Diffs against the LIVE outfit section the LLM produced — when the
|
||
active outfit IS the default, every default phrase is present and
|
||
the diff is empty (correct: nothing to negate)."""
|
||
if not bios:
|
||
return sections
|
||
|
||
# Collect every canonical-default source_phrase across all bios. Also
|
||
# include phrases displaced by the server-side color-swap pre-pass
|
||
# (_resolve_color_swaps) so a swap like green_leotard -> orange_leotard
|
||
# negates the original even though the model emitted the new one.
|
||
expected: list[str] = []
|
||
for b in bios:
|
||
canonical_phrases = b.get("canonical_default_slot_phrases") or []
|
||
if not canonical_phrases:
|
||
# Fallback for bios that came from a path that didn't populate
|
||
# canonical_default (older clients, harness bios that haven't
|
||
# been updated). Old behavior: only fire for default_outfit.
|
||
default = b.get("default_outfit")
|
||
if default and not b.get("user_requested_outfit"):
|
||
for s in default.get("slots") or []:
|
||
phrase = (s.get("source_phrase") or "").strip()
|
||
if phrase:
|
||
expected.append(phrase)
|
||
else:
|
||
for phrase in canonical_phrases:
|
||
phrase = (phrase or "").strip()
|
||
if phrase and phrase not in expected:
|
||
expected.append(phrase)
|
||
for phrase in (b.get("_displaced_phrases") or []):
|
||
phrase = (phrase or "").strip()
|
||
if phrase and phrase not in expected:
|
||
expected.append(phrase)
|
||
if not expected:
|
||
return sections
|
||
|
||
# Find what's in the AI's outfit section(s).
|
||
outfit_canon: set[str] = set()
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
header = (s.get("header") or "").lstrip("/").strip().lower()
|
||
if header.startswith("outfit"):
|
||
for t in s.get("tokens", []):
|
||
outfit_canon.add(_canonicalize_token(t))
|
||
|
||
# Anything expected but not present in outfit → it was dropped → negate.
|
||
dropped = [p for p in expected if _canonicalize_token(p) not in outfit_canon]
|
||
# Anything expected AND present in outfit → user has the canonical
|
||
# piece active (either always was, or just brought back). If a prior
|
||
# turn added it to Negative as a displacement backstop, scrub it now —
|
||
# otherwise `_dedupe_negatives_from_positives` would kill the positive
|
||
# the user just asked for. The LLM preserves Negative verbatim across
|
||
# turns per the patch system prompt, so without this scrub the
|
||
# displaced entry persists across reverts.
|
||
restored = {_canonicalize_token(p) for p in expected
|
||
if _canonicalize_token(p) in outfit_canon}
|
||
neg = next((s for s in sections if s.get("is_negative")), None)
|
||
if neg is not None and restored:
|
||
kept = []
|
||
scrubbed = []
|
||
for t in neg.get("tokens", []):
|
||
if _canonicalize_token(t) in restored:
|
||
scrubbed.append(t)
|
||
else:
|
||
kept.append(t)
|
||
if scrubbed:
|
||
neg["tokens"] = kept
|
||
|
||
if not dropped:
|
||
return sections
|
||
|
||
# Find or create the Negative Prompt section.
|
||
if neg is None:
|
||
neg = {"header": "Negative Prompt:", "tokens": [], "is_negative": True}
|
||
sections.append(neg)
|
||
existing_canon = {_canonicalize_token(t) for t in neg.get("tokens", [])}
|
||
for phrase in dropped:
|
||
if _canonicalize_token(phrase) not in existing_canon:
|
||
neg["tokens"].append(phrase)
|
||
existing_canon.add(_canonicalize_token(phrase))
|
||
|
||
# Weight outfit replacements that share an item-suffix with a negated
|
||
# canonical default. For "make boots red" against Cammy's SF2 Classic:
|
||
# `white_boots` lands in Negative as the trained-default backstop, but
|
||
# the LLM-emitted `red_boots` in // Outfit is unweighted — SDXL's
|
||
# latent pull toward the trained default plus the negative push of
|
||
# `white_boots` outweighs an unweighted positive, so the new color
|
||
# silently drops out. Weighting it to 1.1 lets the swap actually win.
|
||
# Match by last word (boots ↔ boots, dress ↔ dress) — same slot in
|
||
# spirit; safer than slot-decomposition lookups that not every bio
|
||
# carries.
|
||
def _item_suffix(phrase: str) -> str:
|
||
parts = (phrase or "").lower().replace("_", " ").split()
|
||
return parts[-1] if parts else ""
|
||
dropped_items = {_item_suffix(p) for p in dropped if _item_suffix(p)}
|
||
if dropped_items:
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
header = (s.get("header") or "").lstrip("/").strip().lower()
|
||
if not header.startswith("outfit"):
|
||
continue
|
||
new_tokens = []
|
||
for t in s.get("tokens", []):
|
||
if _WEIGHTED_TOKEN_RE.match(t):
|
||
new_tokens.append(t)
|
||
continue
|
||
if _item_suffix(t) in dropped_items:
|
||
new_tokens.append(f"({t}:1.1)")
|
||
else:
|
||
new_tokens.append(t)
|
||
s["tokens"] = new_tokens
|
||
return sections
|
||
|
||
|
||
def _dedupe_negatives_from_positives(sections: list[dict]) -> list[dict]:
|
||
"""If a phrase appears in the Negative Prompt section, remove it from every
|
||
other section. Negative is authoritative for 'this must not be in positives'."""
|
||
neg_canon: set[str] = set()
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
for t in s.get("tokens", []):
|
||
neg_canon.add(_canonicalize_token(t))
|
||
if not neg_canon:
|
||
return sections
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
s["tokens"] = [t for t in s.get("tokens", [])
|
||
if _canonicalize_token(t) not in neg_canon]
|
||
# Drop sections that became empty.
|
||
return [s for s in sections if s.get("is_negative") or s.get("tokens")]
|
||
|
||
|
||
# Editorial qualifier in Danbooru wiki glosses ("…in a deliberately
|
||
# suggestive or lewd manner.") biases the AI against applying the
|
||
# modifier in non-NSFW contexts even when the visual concept matches
|
||
# (e.g. presenting_foot for "lifting up their legs and pointing feet"
|
||
# — model refused because the request "isn't suggestive"). The visual
|
||
# concept is what matters; we strip the editorial framing when surfacing
|
||
# the gloss to the AI without modifying the DB (faithful to wiki source).
|
||
_LEWD_QUALIFIER_RE = re.compile(
|
||
r"\s+in a (?:[a-z]+\s+)*(?:suggestive|lewd|sexual|erotic|provocative|pornographic)\s+manner\.?",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _neutralize_gloss(text: str) -> str:
|
||
if not text:
|
||
return text or ""
|
||
cleaned = _LEWD_QUALIFIER_RE.sub(".", text)
|
||
return re.sub(r"\.\.+$", ".", cleaned).strip()
|
||
|
||
|
||
def _is_tag_form(token: str) -> bool:
|
||
"""True if the token looks like a canonical Danbooru tag — has an
|
||
underscore outside of paren/escape syntax. `presenting_foot` and
|
||
`(legs_up:1.1)` are tag-form; `looking sad` and `bare feet` are
|
||
phrase-form. Used by trace-check to apply stricter rules to tokens
|
||
that claim canonical-tag identity."""
|
||
if not token:
|
||
return False
|
||
s = token.strip()
|
||
m = _WEIGHTED_TOKEN_RE.match(s)
|
||
if m:
|
||
s = m.group(1)
|
||
s = s.replace("\\(", "").replace("\\)", "")
|
||
return "_" in s
|
||
|
||
|
||
# Tag-group → preferred prompt section mapping. Source data is Danbooru's
|
||
# tag_group:* wikis (CC0); each enumerates the canonical tags belonging
|
||
# to a semantic domain. We map domain → which prompt section the tag
|
||
# belongs in, so the model emitting a posture tag (`sitting`, `legs_up`)
|
||
# under // Character gets the misplaced token dropped at output stage.
|
||
#
|
||
# A single tag in multiple groups uses first-match-wins by iteration
|
||
# order (more specific section listed first). Tags in NO group are
|
||
# left ambiguous — no constraint applied.
|
||
_TAG_GROUP_TO_SECTION = {
|
||
"gestures": "pose", # pointing, waving, salute, ...
|
||
"posture": "pose", # sitting, legs_up, kneeling, ...
|
||
"attire": "outfit", # clothing, accessories, footwear, ...
|
||
"eyes_tags": "character", # eye colors, eye shapes — appearance
|
||
"locations": "setting", # bedroom, forest, classroom, ...
|
||
}
|
||
|
||
# Per-tag overrides applied AFTER tag-group inference. The eyes_tags
|
||
# group contains both appearance tokens (`blue_eyes`, `red_eyes`) AND
|
||
# gaze-direction tokens (`looking_back`, `looking_at_viewer`, etc.) —
|
||
# only the appearance ones belong in // Character. Gaze and camera-
|
||
# framing tokens are POSE concepts (where the subject is looking,
|
||
# how the camera frames them). Without this override, the section-
|
||
# mismatch filter silently drops legitimate pose tokens emitted in
|
||
# // Pose, Action & Prop because their "canonical" section was
|
||
# misclassified as character.
|
||
_TAG_SECTION_OVERRIDES_TO_POSE: frozenset[str] = frozenset({
|
||
# Gaze / head direction
|
||
"looking_at_viewer", "looking_away", "looking_back", "looking_down",
|
||
"looking_up", "looking_to_the_side", "looking_ahead", "looking_afar",
|
||
"looking_around", "looking_outside", "looking_over_eyewear",
|
||
"looking_through_own_legs", "sideways_glance",
|
||
# Camera framing / subject orientation
|
||
"from_behind", "from_above", "from_below", "from_side",
|
||
# Body-part focus tags
|
||
"ass_focus", "feet_focus", "foot_focus", "hand_focus",
|
||
"breast_focus", "thigh_focus", "leg_focus", "back_focus",
|
||
# Common confident-stance / dynamic-pose tokens that get mis-grouped
|
||
"confident_stance",
|
||
})
|
||
|
||
# Tags whose Danbooru classification is too context-dependent to
|
||
# deterministically route. `glasses` is in the eyewear group (→ outfit)
|
||
# but also means drinking glasses (→ setting). When the model puts a
|
||
# context-ambiguous tag in any section, trust the model's contextual
|
||
# read rather than evicting against a fixed index.
|
||
_AMBIGUOUS_SECTION_TAGS: frozenset[str] = frozenset({
|
||
"glasses",
|
||
})
|
||
|
||
_TAG_SECTION_INDEX_CACHE: dict[str, str] | None = None
|
||
|
||
|
||
def _build_tag_section_index() -> dict[str, str]:
|
||
"""Returns {tag_underscored: preferred_section_name}. Cached after
|
||
first build; cleared by changing _TAG_GROUP_TO_SECTION (rare)."""
|
||
global _TAG_SECTION_INDEX_CACHE
|
||
if _TAG_SECTION_INDEX_CACHE is not None:
|
||
return _TAG_SECTION_INDEX_CACHE
|
||
out: dict[str, str] = {}
|
||
for group_name, section in _TAG_GROUP_TO_SECTION.items():
|
||
for tag in _load_tag_group(group_name):
|
||
# First section wins for ambiguous tags (gestures listed
|
||
# before posture so a gesture-and-posture overlap stays pose
|
||
# either way; outfit/character/setting are mutually exclusive
|
||
# in practice).
|
||
out.setdefault(tag, section)
|
||
# Modifier rules are authoritative for their canonical tag — override
|
||
# any tag-group inference. presenting_foot's substitute_section=pose
|
||
# is what tells _drop_misplaced_tokens to evict it from // Outfit
|
||
# when the model puts it there.
|
||
for m in _load_slot_modifiers():
|
||
canon = (m.get("canonical_tag") or "").strip().lower()
|
||
sec = (m.get("substitute_section") or "").strip().lower()
|
||
if canon and sec:
|
||
out[canon] = sec
|
||
# Hard overrides for gaze / camera-framing tokens that the
|
||
# eyes_tags group mis-classified as character. See
|
||
# _TAG_SECTION_OVERRIDES_TO_POSE for the curated list.
|
||
for tag in _TAG_SECTION_OVERRIDES_TO_POSE:
|
||
out[tag] = "pose"
|
||
# Strip context-ambiguous tags so section-mismatch never evicts them.
|
||
for tag in _AMBIGUOUS_SECTION_TAGS:
|
||
out.pop(tag, None)
|
||
_TAG_SECTION_INDEX_CACHE = out
|
||
return out
|
||
|
||
|
||
# Map prompt-section header text → canonical section key. Headers vary
|
||
# slightly ("// Pose, Action & Prop" vs "// Pose" vs "// Pose / Action").
|
||
_HEADER_PREFIXES = (
|
||
("// pose", "pose"),
|
||
("// outfit", "outfit"),
|
||
("// expression", "expression"),
|
||
("// setting", "setting"),
|
||
("// scene", "setting"),
|
||
("// character", "character"),
|
||
("// style", "style"),
|
||
("// quality", "quality"),
|
||
("// aesthetic", "quality"),
|
||
)
|
||
|
||
|
||
def _section_key_from_header(header: str) -> str | None:
|
||
h = (header or "").lower().strip()
|
||
for prefix, key in _HEADER_PREFIXES:
|
||
if h.startswith(prefix):
|
||
return key
|
||
return None
|
||
|
||
|
||
def _drop_misplaced_tokens(sections: list[dict], request_id: str) -> list[dict]:
|
||
"""For each emitted token in a positive section, look up its preferred
|
||
section from the tag_group → section map. If the model put it in a
|
||
section that doesn't match, drop it from this section (don't try to
|
||
move — the model also emitted it where it belongs in many cases, and
|
||
keeping the wrong copy double-weights the tag in SD).
|
||
|
||
Tokens with no entry in the index (most tags) pass through — we
|
||
only constrain tags whose semantic domain is known."""
|
||
index = _build_tag_section_index()
|
||
if not index:
|
||
return sections
|
||
dropped: list[str] = []
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
actual = _section_key_from_header(s.get("header") or "")
|
||
if not actual:
|
||
continue
|
||
# Style sections are curated template content — descriptive
|
||
# English (oil painting, 70s film grain, Fujifilm XT3) that
|
||
# doesn't fit the Danbooru tag-group taxonomy. Skip section-
|
||
# misplacement check for them.
|
||
if actual == "style":
|
||
continue
|
||
kept = []
|
||
for t in s.get("tokens", []):
|
||
underscore = _bare_form(t).replace(" ", "_")
|
||
preferred = index.get(underscore)
|
||
if preferred and preferred != actual:
|
||
dropped.append(f"{t} (in //{actual}, expected //{preferred})")
|
||
else:
|
||
kept.append(t)
|
||
s["tokens"] = kept
|
||
if dropped:
|
||
dbg.info(
|
||
"ai-patch[%s] section-mismatch dropped: %s",
|
||
request_id, "; ".join(dropped),
|
||
)
|
||
return [s for s in sections if s.get("tokens") or s.get("is_negative")]
|
||
|
||
|
||
# Compositional-token validator data. Built lazily on first call by
|
||
# `_load_compositional_components`. Used by trace-check to permit
|
||
# tag-form compounds like `pink_polka_dot_leotard` whose pieces are all
|
||
# valid concepts even though the whole compound isn't in danbooru_tags.
|
||
_COMPOSITIONAL_CACHE: set[str] | None = None
|
||
# Min length for an individual component to count as "valid". Stops
|
||
# trivial fragments like `a`, `at`, `of` from rubber-stamping random
|
||
# compounds.
|
||
_COMPONENT_MIN_LEN = 3
|
||
|
||
|
||
def _load_compositional_components() -> set[str]:
|
||
"""Returns the union of all "valid component" tag forms used by
|
||
`_decompose_token` to validate compositional tag-form tokens.
|
||
|
||
Sources (every entry is lowercase, underscored, length >= 3):
|
||
- clothing_colors, clothing_materials, clothing_patterns
|
||
- furniture_colors, furniture_materials, furniture_patterns
|
||
- clothing_items / appearance_items / pose_items / scene_items /
|
||
expression_items / action_items / nsfw_action_items (item_tag)
|
||
- danbooru_tags.tag (any category — we trust real tag forms)
|
||
|
||
Cached at module level. Cleared whenever _CLOTHING_PREFIX_CACHE is
|
||
cleared (they share sources)."""
|
||
global _COMPOSITIONAL_CACHE
|
||
if _COMPOSITIONAL_CACHE is not None:
|
||
return _COMPOSITIONAL_CACHE
|
||
out: set[str] = set()
|
||
try:
|
||
from .tag_builder import get_db
|
||
db = get_db()
|
||
# Color/material/pattern tables share a `tag` column convention.
|
||
for table in (
|
||
"clothing_colors", "clothing_materials", "clothing_patterns",
|
||
"furniture_colors", "furniture_materials", "furniture_patterns",
|
||
):
|
||
try:
|
||
for r in db.execute(f"SELECT tag FROM {table}").fetchall():
|
||
t = (r["tag"] or "").strip().lower()
|
||
if len(t) >= _COMPONENT_MIN_LEN:
|
||
out.add(t)
|
||
except Exception:
|
||
pass
|
||
for table in (
|
||
"clothing_items", "appearance_items", "pose_items",
|
||
"scene_items", "expression_items", "action_items",
|
||
"nsfw_action_items",
|
||
):
|
||
try:
|
||
for r in db.execute(f"SELECT item_tag FROM {table}").fetchall():
|
||
t = (r["item_tag"] or "").strip().lower()
|
||
if len(t) >= _COMPONENT_MIN_LEN:
|
||
out.add(t)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
# Tag-level: every real Danbooru tag with length >= 3 is a
|
||
# valid component. `feet`, `leotard`, `barefoot` all land here.
|
||
for r in db.execute(
|
||
"SELECT tag FROM danbooru_tags WHERE LENGTH(tag) >= ?",
|
||
(_COMPONENT_MIN_LEN,),
|
||
).fetchall():
|
||
t = (r["tag"] or "").strip().lower()
|
||
if t:
|
||
out.add(t)
|
||
# Compositional sub-words: split every compound general-category
|
||
# tag and admit each part as a component. `focus` isn't a
|
||
# standalone tag but appears in 37 *_focus compounds (foot_focus,
|
||
# breast_focus, hand_focus, ...) — it's real Danbooru
|
||
# compositional vocabulary that just never exists alone. Same
|
||
# for `hair`, `print`, `uniform`, color words, etc. Without
|
||
# this, legitimate compositional inventions like `feet_focus`
|
||
# fail trace-check even though every piece is valid by
|
||
# Danbooru's own usage. ranking >= 200 keeps us on the
|
||
# well-trodden compositional vocabulary; rare-tag noise stays
|
||
# out.
|
||
for r in db.execute(
|
||
"SELECT tag FROM danbooru_tags "
|
||
"WHERE category = 'general' AND ranking >= 200 "
|
||
"AND tag LIKE '%\\_%' ESCAPE '\\'"
|
||
).fetchall():
|
||
t = (r["tag"] or "").strip().lower()
|
||
if not t:
|
||
continue
|
||
for part in t.split("_"):
|
||
if len(part) >= _COMPONENT_MIN_LEN:
|
||
out.add(part)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
logger.warning("could not preload compositional components",
|
||
exc_info=True)
|
||
_COMPOSITIONAL_CACHE = out
|
||
return out
|
||
|
||
|
||
def _decompose_token(token_underscore: str,
|
||
components: set[str],
|
||
user_lc: str) -> bool:
|
||
"""Greedy left-to-right longest-prefix match: split on `_`, walk
|
||
the parts list and consume the longest prefix that's a valid
|
||
component at each step. Component is valid if it's in `components`
|
||
OR appears as a contiguous substring in `user_lc`. Returns True
|
||
only if every part is consumed.
|
||
|
||
Examples (assuming standard tag-builder DB):
|
||
pink_polka_dot_leotard → pink + polka_dot + leotard ✓
|
||
teal_leotard → teal + leotard ✓
|
||
feet_positioned_forward → feet + ??? → False
|
||
(positioned_forward isn't a component
|
||
and doesn't appear in user text)
|
||
red_pantyhose_pointing → red + pantyhose + pointing ✓
|
||
(technically passes but
|
||
_drop_misplaced_tokens catches the
|
||
wrong-domain composition)
|
||
"""
|
||
if not token_underscore:
|
||
return False
|
||
parts = [p for p in token_underscore.split("_") if p]
|
||
if not parts:
|
||
return False
|
||
i = 0
|
||
while i < len(parts):
|
||
matched = False
|
||
for j in range(len(parts), i, -1):
|
||
candidate = "_".join(parts[i:j])
|
||
if len(candidate) < _COMPONENT_MIN_LEN:
|
||
continue
|
||
if candidate in components:
|
||
i = j
|
||
matched = True
|
||
break
|
||
# Allow user-typed phrases (with or without underscores) too.
|
||
phrase = candidate.replace("_", " ")
|
||
if phrase and phrase in user_lc:
|
||
i = j
|
||
matched = True
|
||
break
|
||
if not matched:
|
||
return False
|
||
return True
|
||
|
||
|
||
# Body-part word pairs where Danbooru sometimes uses plural, sometimes
|
||
# singular, in compound tags. Model occasionally mashes the wrong variant
|
||
# (saw `feet_only` and `foot_focus` in the menu, wrote `feet_focus` —
|
||
# not a real tag). When the emitted compound isn't in `allowed` but a
|
||
# single body-part swap lands in `allowed`, prefer the canonical variant.
|
||
# Compounds without these words (`red_micro_bikini`, `pink_polka_dot_leotard`)
|
||
# are untouched — no swap considered, original compositional path runs.
|
||
_BODY_PART_VARIANTS: list[tuple[str, str]] = [
|
||
("feet", "foot"),
|
||
("hands", "hand"),
|
||
("eyes", "eye"),
|
||
("fingers", "finger"),
|
||
("toes", "toe"),
|
||
("knees", "knee"),
|
||
("legs", "leg"),
|
||
("ears", "ear"),
|
||
("breasts", "breast"),
|
||
("thighs", "thigh"),
|
||
]
|
||
|
||
|
||
def _canonical_body_part_variant(token: str, allowed: set[str]) -> str | None:
|
||
"""If the token's bare form isn't in `allowed` but exactly one
|
||
body-part-word substitution lands in `allowed`, return the corrected
|
||
token (preserving weight wrappers via word-boundary replace). Else
|
||
None. Single-substitution only — `feet_eyes_focus` won't get
|
||
multi-edited into something arbitrary."""
|
||
bare = _bare_form(token)
|
||
if not bare or bare in allowed:
|
||
return None
|
||
parts = bare.split(" ")
|
||
for i, p in enumerate(parts):
|
||
for plural, singular in _BODY_PART_VARIANTS:
|
||
if p == plural:
|
||
candidate = " ".join(parts[:i] + [singular] + parts[i + 1:])
|
||
if candidate in allowed:
|
||
return re.sub(
|
||
r"(?<![A-Za-z])" + re.escape(plural) + r"(?![A-Za-z])",
|
||
singular, token,
|
||
)
|
||
if p == singular:
|
||
candidate = " ".join(parts[:i] + [plural] + parts[i + 1:])
|
||
if candidate in allowed:
|
||
return re.sub(
|
||
r"(?<![A-Za-z])" + re.escape(singular) + r"(?![A-Za-z])",
|
||
plural, token,
|
||
)
|
||
return None
|
||
|
||
|
||
def _bare_form(token: str) -> str:
|
||
"""Canonical bare form for cross-source identity comparison: strip
|
||
weight wrapper, escape backslashes, swap underscores to spaces,
|
||
lowercase. `(cammy_white:1.1)` → `cammy white`. Used by trace-check
|
||
and output-scope filters that need to compare emitted tokens against
|
||
sets sourced from bios, modifier rows, danbooru_tags, etc."""
|
||
canon = _canonicalize_token(token).lower()
|
||
m = _WEIGHTED_TOKEN_RE.match(canon)
|
||
if m:
|
||
canon = m.group(1).strip()
|
||
return canon
|
||
|
||
|
||
def _drop_untraceable_tokens(sections: list[dict], user_request: str,
|
||
bios: list[dict], all_modifiers: list[dict],
|
||
request_id: str,
|
||
node_prompt: str = "") -> list[dict]:
|
||
"""Trace-check: every emitted positive-section token must trace to
|
||
one of these authoritative sources:
|
||
|
||
- bio.base_tags or outfit slot source_phrase
|
||
- modifier canonical_tag or implies_outfit_tag
|
||
- any positive token already present in node_prompt (patch mode —
|
||
the user's existing prompt is authoritative; preserved tokens
|
||
must survive even if their compositional form was validated
|
||
against an earlier turn's user text and lost that anchor here)
|
||
- canonical-scan output for this request
|
||
- a substring of the user's typed request (so freestyle phrasings
|
||
the user wrote stay even if they aren't canonical Danbooru tags)
|
||
- any row in the danbooru_tags table (any category, any ranking —
|
||
even low-rank tags are real, just niche)
|
||
|
||
Tokens matching none of those are model hallucinations and get
|
||
dropped with a log line. Negative Prompt sections are skipped —
|
||
they're explicit deletions, not generated content.
|
||
|
||
The allowed set is rebuilt per request because canonical scan output
|
||
and bios change every time. Single SQL query at the end batches the
|
||
danbooru_tags lookup so this is fast."""
|
||
allowed: set[str] = set()
|
||
|
||
# 1. Bio base_tags + outfit tags / slot phrases (authoritative, verbatim).
|
||
# The bio's outfit comes in two shapes:
|
||
# - slot-decomposed (slots list populated): each slot's source_phrase
|
||
# is the human-readable token shown to the model.
|
||
# - flat (slots empty, outfit['tags'] is the only source): the
|
||
# comma-separated string IS what the model sees.
|
||
# Trace-check needs both — without flat-form coverage, characters
|
||
# whose curated outfits aren't slot-decomposed (M. Bison's Shadaloo
|
||
# Uniform: 'red military uniform, cape, cap, Shadaloo dictator
|
||
# attire') get most of their tokens dropped because they're prose,
|
||
# not Danbooru tags.
|
||
for b in bios or []:
|
||
for raw in (b.get("base_tags") or "").split(","):
|
||
bare = _bare_form(raw)
|
||
if bare:
|
||
allowed.add(bare)
|
||
outfit = b.get("user_requested_outfit") or b.get("default_outfit") or {}
|
||
for slot in outfit.get("slots") or []:
|
||
phrase = _bare_form(slot.get("source_phrase") or "")
|
||
if phrase:
|
||
allowed.add(phrase)
|
||
for raw in (outfit.get("tags") or "").split(","):
|
||
bare = _bare_form(raw)
|
||
if bare:
|
||
allowed.add(bare)
|
||
|
||
# 2. Modifier canonical_tags + implies (alias output is always valid).
|
||
for m in all_modifiers or []:
|
||
canon = (m.get("canonical_tag") or "").replace("_", " ").lower()
|
||
if canon:
|
||
allowed.add(canon)
|
||
implies = (m.get("implies_outfit_tag") or "").replace("_", " ").lower()
|
||
if implies:
|
||
allowed.add(implies)
|
||
|
||
# 3. Existing node_prompt positives (patch mode). After format
|
||
# conversion, a previously-validated compound like `red_micro_bikini`
|
||
# becomes `red micro bikini` (phrase form) — the decomposition path
|
||
# is gated behind tag form so it can't re-validate it, and the
|
||
# original user-text anchor is gone the next turn. Treat the user's
|
||
# existing prompt as authoritative the same way bio source_phrases are.
|
||
src_pos, _ = _node_prompt_token_sets(node_prompt)
|
||
for tok_lc in src_pos:
|
||
if tok_lc:
|
||
allowed.add(tok_lc)
|
||
|
||
# 4. Tag-wiki retrieval for this request — looser threshold than the
|
||
# prompt-block retrieval (0.50 vs 0.55) so we err on permissive when
|
||
# building the allowed-set. If tag_search isn't loaded yet (cold
|
||
# boot before first ingest), this returns an empty list and the
|
||
# allowed-set is just bio + modifier — trace-check stays
|
||
# well-defined, just slightly stricter.
|
||
try:
|
||
from . import tag_search
|
||
for c in tag_search.search(user_request, top_k=30, threshold=0.50):
|
||
bare = (c.get("tag") or "").replace("_", " ").lower()
|
||
if bare:
|
||
allowed.add(bare)
|
||
except Exception:
|
||
logger.warning("trace-check: tag_search lookup failed", exc_info=True)
|
||
|
||
user_lc = (user_request or "").lower()
|
||
|
||
# 5. Collect emitted tokens that need cross-checking against the full
|
||
# danbooru_tags table. Skip ones already in `allowed` (no point
|
||
# querying for them).
|
||
candidates: set[str] = set()
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
for t in s.get("tokens", []):
|
||
bare = _bare_form(t)
|
||
if bare and bare not in allowed and bare not in user_lc:
|
||
# underscore form is what danbooru_tags stores
|
||
candidates.add(bare.replace(" ", "_"))
|
||
|
||
if candidates:
|
||
try:
|
||
from .tag_builder import get_db
|
||
db = get_db()
|
||
placeholders = ",".join("?" for _ in candidates)
|
||
for r in db.execute(
|
||
f"SELECT tag FROM danbooru_tags WHERE tag IN ({placeholders})",
|
||
list(candidates),
|
||
).fetchall():
|
||
allowed.add((r["tag"] or "").replace("_", " ").lower())
|
||
except Exception:
|
||
logger.warning("trace-check: danbooru_tags lookup failed",
|
||
exc_info=True)
|
||
|
||
# Three-tier traceability (Phase 4: component-aware):
|
||
#
|
||
# - Authoritative match: token's bare form is in `allowed` (came
|
||
# from bio / modifier / retrieval / danbooru_tags). Pass.
|
||
# - Phrase form (no underscores in original token): pass if its
|
||
# bare form is a substring of the user's typed request. The
|
||
# model's tag-source rule 3 says it can fall back to natural
|
||
# language phrases when no canonical exists, so we honor that.
|
||
# - Tag form (underscores present): try compositional decomposition.
|
||
# If every underscore-separated piece is itself a valid component
|
||
# (clothing color/material/pattern, item tag, real Danbooru tag,
|
||
# or appears in user text), pass. This admits legitimate
|
||
# compounds like `pink_polka_dot_leotard` while still rejecting
|
||
# hallucinated ones like `feet_positioned_forward` whose pieces
|
||
# don't all check out.
|
||
#
|
||
# `_drop_misplaced_tokens` runs after this and catches wrong-domain
|
||
# compositions that pass the structural check but belong in a
|
||
# different section (defense in depth).
|
||
components = _load_compositional_components()
|
||
dropped: list[str] = []
|
||
normalized: list[tuple[str, str]] = []
|
||
for s in sections:
|
||
if s.get("is_negative"):
|
||
continue
|
||
# Style sections are server-injected from curated templates —
|
||
# `Fujifilm XT3`, `oil painting`, `subsurface scattering` etc.
|
||
# aren't Danbooru tags and won't pass trace-check. Tokens come
|
||
# from a curator's hand-authored template, not model output, so
|
||
# there's nothing to validate against hallucination here.
|
||
if _section_key_from_header(s.get("header") or "") == "style":
|
||
continue
|
||
kept = []
|
||
for t in s.get("tokens", []):
|
||
bare = _bare_form(t)
|
||
if not bare:
|
||
continue
|
||
if bare in allowed:
|
||
kept.append(t)
|
||
continue
|
||
# User-text fallback BEFORE variant normalization. Tag-form
|
||
# tokens like `legs_up` get skipped from the danbooru_tags
|
||
# batch when their bare form is already in user_lc (an
|
||
# optimization). That leaves them missing from `allowed`,
|
||
# which would then wrongly trigger body-part variant
|
||
# normalization (`legs_up` -> `leg_up`). If the user typed
|
||
# the words explicitly this turn, trust them — no variant
|
||
# rewrite, just keep as-is.
|
||
if bare in user_lc:
|
||
kept.append(t)
|
||
continue
|
||
# Body-part variant normalization. If the token isn't in the
|
||
# authoritative set but a single feet↔foot / hands↔hand /
|
||
# eyes↔eye swap lands in `allowed`, prefer that — it's the
|
||
# canonical form the menu offered. Only swaps when the
|
||
# variant is in `allowed`, so legitimate compositional
|
||
# tags without body-part words (`red_micro_bikini`) and
|
||
# legitimate compositions where neither variant is in the
|
||
# menu (`pink_polka_dot_leotard`) flow to decomposition
|
||
# untouched.
|
||
canon = _canonical_body_part_variant(t, allowed)
|
||
if canon is not None:
|
||
kept.append(canon)
|
||
normalized.append((t, canon))
|
||
continue
|
||
# User-text fallback: phrase-form tokens whose words all
|
||
# appear in the user request pass even when written in a
|
||
# different order. "make boots red" → token `red boots` —
|
||
# substring fails on word reversal, but the word-set check
|
||
# catches it. Stays gated on phrase form so canonical
|
||
# Danbooru compounds (e.g. `pink_polka_dot_leotard`) still
|
||
# flow to the structural decompose check below.
|
||
tag_form = _is_tag_form(t)
|
||
if not tag_form and all(w in user_lc for w in bare.split()):
|
||
kept.append(t)
|
||
continue
|
||
# Compositional decompose: also runs on phrase form, not
|
||
# only tag form. The LLM emits phrase form when the model's
|
||
# tag_format is `spaces`, but a "<color> <item>" compound
|
||
# like "red boots" is the same structurally as `red_boots` —
|
||
# SDXL/LAION-trained models accept both. Underscoring before
|
||
# decomposition lets the components-table lookup fire.
|
||
underscore = bare.replace(" ", "_")
|
||
if _decompose_token(underscore, components, user_lc):
|
||
kept.append(t)
|
||
continue
|
||
dropped.append(t)
|
||
s["tokens"] = kept
|
||
|
||
if dropped:
|
||
dbg.info(
|
||
"ai-patch[%s] trace-check dropped untraceable tokens: %s",
|
||
request_id, ", ".join(dropped),
|
||
)
|
||
if normalized:
|
||
dbg.info(
|
||
"ai-patch[%s] trace-check normalized body-part variants: %s",
|
||
request_id, ", ".join(f"{a}->{b}" for a, b in normalized),
|
||
)
|
||
return [s for s in sections if s.get("tokens") or s.get("is_negative")]
|
||
|
||
|
||
|
||
|
||
def _parse_sectioned_output(text: str) -> list[dict]:
|
||
"""Parse the model's plain-text output into [{header, tokens, body_text}].
|
||
Section headers start with `//`. Tag lines are comma-separated.
|
||
A literal `Negative Prompt:` line opens a special section whose
|
||
header is preserved verbatim (no `//`) so the assembled output
|
||
matches PromptChain's A1111-style negative-prompt marker that the
|
||
compiler splits on. Lines that aren't a header or a tag line are
|
||
folded into the most recent section's tokens.
|
||
|
||
`body_text` preserves the raw body lines verbatim for natlang prose
|
||
bodies that would round-trip lossily through comma-split/join (e.g.
|
||
multi-line paragraphs, internal sentence punctuation). Tag mode
|
||
ignores body_text and assembles from `tokens` as before."""
|
||
sections: list[dict] = []
|
||
current: dict | None = None
|
||
body_lines_for_current: list[str] | None = None
|
||
def _flush_body():
|
||
if current is not None and body_lines_for_current is not None:
|
||
current["body_text"] = "\n".join(body_lines_for_current)
|
||
for line in (text or "").splitlines():
|
||
s = line.strip()
|
||
if not s:
|
||
continue
|
||
m_neg = _NEGATIVE_PROMPT_RE.match(s)
|
||
if m_neg:
|
||
_flush_body()
|
||
current = {
|
||
"header": "Negative Prompt:",
|
||
"tokens": [],
|
||
"is_negative": True,
|
||
"body_text": "",
|
||
}
|
||
sections.append(current)
|
||
body_lines_for_current = []
|
||
inline = m_neg.group(1).strip()
|
||
if inline:
|
||
current["tokens"].extend(t.strip() for t in _split_prompt_tokens(inline) if t.strip())
|
||
body_lines_for_current.append(inline)
|
||
current["body_text"] = inline
|
||
continue
|
||
m = _SECTION_HEADER_RE.match(s)
|
||
if m:
|
||
_flush_body()
|
||
current = {
|
||
"header": f"// {m.group(1)}",
|
||
"tokens": [],
|
||
"body_text": "",
|
||
}
|
||
sections.append(current)
|
||
body_lines_for_current = []
|
||
continue
|
||
# Tag content line — comma-split for tag-mode tokens, AND
|
||
# appended verbatim to body_text for natlang assembly.
|
||
tokens = [t.strip() for t in _split_prompt_tokens(s) if t.strip()]
|
||
if current is None:
|
||
current = {"header": "// Prompt", "tokens": [], "body_text": ""}
|
||
sections.append(current)
|
||
body_lines_for_current = []
|
||
if tokens:
|
||
current["tokens"].extend(tokens)
|
||
if body_lines_for_current is not None:
|
||
body_lines_for_current.append(s)
|
||
_flush_body()
|
||
# Keep sections that have either tokens (tag mode) or body_text (natlang prose).
|
||
sections = [s for s in sections if s["tokens"] or (s.get("body_text") or "").strip()]
|
||
# Dedup sections with the same header — small models occasionally
|
||
# emit duplicate `// Style: X` / `// Setting / Scene` blocks (one
|
||
# near the section's natural position and a copy at the very end of
|
||
# the output). Merge tokens uniquely, preserve first-seen body_text.
|
||
return _dedup_section_headers(sections)
|
||
|
||
|
||
def _dedup_section_headers(sections: list[dict]) -> list[dict]:
|
||
"""Merge sections that share the same header (case-insensitive). The
|
||
first-seen wins for body_text and section flags; later duplicates
|
||
contribute their unique tokens to the merged `tokens` list, then are
|
||
dropped. Defends against the 8B Qwen failure mode where a slightly-
|
||
unusual edit causes the model to emit the same section header twice
|
||
in one response — without dedup the renderer ships both copies."""
|
||
seen: dict[str, dict] = {}
|
||
out: list[dict] = []
|
||
for s in sections:
|
||
key = (s.get("header") or "").strip().lower()
|
||
if not key:
|
||
out.append(s)
|
||
continue
|
||
existing = seen.get(key)
|
||
if existing is None:
|
||
seen[key] = s
|
||
out.append(s)
|
||
continue
|
||
# Append tokens that aren't already in the kept section. Order-
|
||
# preserving — first-seen ordering wins.
|
||
existing_set = {t for t in existing.get("tokens") or []}
|
||
for t in s.get("tokens") or []:
|
||
if t not in existing_set:
|
||
existing.setdefault("tokens", []).append(t)
|
||
existing_set.add(t)
|
||
# Don't merge body_text — keep first-seen; later duplicates are
|
||
# almost always trailing copies that would corrupt natlang prose
|
||
# if concatenated.
|
||
return out
|
||
|
||
|
||
def _resolve_style_from_text(text: str, arch_prompts: list) -> dict | None:
|
||
"""Best-template match for a style sub-intent text. Multi-stage:
|
||
1. Whitespace-normalized substring match — handles `hyper realistic`
|
||
finding `Hyperrealistic`.
|
||
2. Anime-aware filter — when user mentions `anime`, prefer
|
||
templates with `anime` in their id/name (so `hyper realistic
|
||
anime` doesn't pick the 3D Realistic Unreal Engine template).
|
||
3. Fallback to the existing token-overlap scorer.
|
||
|
||
Returns the matched template dict or None."""
|
||
if not text or not arch_prompts:
|
||
return None
|
||
text_lc = text.lower().strip()
|
||
text_normalized = re.sub(r"[\s\-_]+", "", text_lc)
|
||
user_words = set(re.findall(r"\w{4,}", text_lc))
|
||
|
||
# Anime-aware filter
|
||
user_says_anime = "anime" in text_lc
|
||
candidate_pool = arch_prompts
|
||
if user_says_anime:
|
||
anime_only = [
|
||
p for p in arch_prompts
|
||
if "anime" in (p.get("id") or "").lower()
|
||
or "anime" in (p.get("name") or "").lower()
|
||
]
|
||
if anime_only:
|
||
candidate_pool = anime_only
|
||
|
||
# Stage 1: normalized-substring match.
|
||
best_norm = None
|
||
best_norm_len = 0
|
||
for p in candidate_pool:
|
||
name_lc = (p.get("name") or "").lower()
|
||
if not name_lc:
|
||
continue
|
||
name_normalized = re.sub(r"[\s\-_]+", "", name_lc)
|
||
if name_normalized and name_normalized in text_normalized:
|
||
if len(name_normalized) > best_norm_len:
|
||
best_norm = p
|
||
best_norm_len = len(name_normalized)
|
||
if best_norm and best_norm_len >= 6:
|
||
return best_norm
|
||
|
||
# Stage 2: token-overlap scorer (same as the existing one, but
|
||
# restricted to candidate_pool).
|
||
best = None
|
||
best_score = 0.0
|
||
for p in candidate_pool:
|
||
name = (p.get("name") or "").lower()
|
||
name_words = set(re.findall(r"\w{4,}", name))
|
||
if not name_words:
|
||
continue
|
||
overlap = name_words & user_words
|
||
if not overlap:
|
||
continue
|
||
overlap_chars = sum(len(w) for w in overlap)
|
||
name_chars = sum(len(w) for w in name_words)
|
||
if name_chars == 0:
|
||
continue
|
||
adj = (overlap_chars ** 2) / name_chars
|
||
if adj > best_score:
|
||
best = p
|
||
best_score = adj
|
||
if best and best_score >= 4.0:
|
||
return best
|
||
return None
|
||
|
||
|
||
async def _run_natlang_v2(request_id, body, user_request, bios, node_prompt,
|
||
style_template, provider, config,
|
||
_t_request_start, _timing,
|
||
_mark, _status, _dump,
|
||
arch_prompts: list | None = None):
|
||
"""vibrant-rendering-loom Phase E — delta-based natlang pipeline.
|
||
|
||
Replaces the structural-intent + ad-hoc-helper flow with:
|
||
1. parse_user_request_to_deltas → ordered list[Delta]
|
||
2. apply_deltas → cumulative state mutation (modifier propagation
|
||
folded in, no separate apply_reverse_displacement pass)
|
||
3. render_all_sections → per-section prose (server for
|
||
Character/Outfit, model for Pose, server for Expression/Setting)
|
||
4. Section post-pass (style + neg merge) — reuses existing helpers."""
|
||
from . import natlang_facts as _nlf
|
||
from . import natlang_render_v2 as _nrv2
|
||
from .prompt_state import PromptState as _PromptState, StyleState as _StyleState
|
||
|
||
_mark("build_prompt")
|
||
|
||
# 1. Load incoming state and refresh bio fields against current bios.
|
||
# Per the plan: "refresh bio fields from current bios" on every load
|
||
# so stale outfit anchors / slot rows / pose anchors don't survive
|
||
# turn-over-turn.
|
||
incoming_state = _PromptState.from_dict(body.get("prompt_state"))
|
||
|
||
# FRESH BUILD GATE: when there's no prose in the editor, prior state
|
||
# has nothing to anchor to — preserving it leaks unrequested data
|
||
# into the new prompt (sultry expressions, prior pose-anchor overrides
|
||
# that wipe the new bio's anchor, user_extras that bypass the outfit
|
||
# bio short-circuit). State without prose is meaningless; reset it.
|
||
if not (node_prompt and node_prompt.strip()):
|
||
incoming_state = _PromptState()
|
||
|
||
# 1a. Ingestion path: when the user has hand-written / pasted prose
|
||
# into the editor and pcrPromptState is empty, the LLM extracts
|
||
# structured facts from the prose so subsequent edits work
|
||
# incrementally. Without this, the user's custom prose gets nuked
|
||
# the first time they hit Apply because state has nothing to edit
|
||
# against — the renderer would compose fresh from bios + this turn's
|
||
# deltas and lose every custom phrasing.
|
||
# Ingest when:
|
||
# (a) pcrPromptState is empty (first run), OR
|
||
# (b) node_prompt has no `// Section:` headers (flat user prose —
|
||
# they pasted custom text, state is stale from a prior session
|
||
# and doesn't reflect what's now in the editor).
|
||
# Without (b), a user pasting custom prose into a node that has
|
||
# leftover pcrPromptState gets bio-driven recomposition, completely
|
||
# losing their prose.
|
||
has_section_headers = bool(re.search(
|
||
r"(?m)^\s*//\s*(character|outfit|pose|expression|scene|setting|style)\b",
|
||
node_prompt or "",
|
||
re.IGNORECASE,
|
||
))
|
||
should_ingest = (
|
||
node_prompt and node_prompt.strip()
|
||
and (incoming_state.is_empty() or not has_section_headers)
|
||
# Ingest is character-centric — extracting `character:`,
|
||
# `outfit:`, `pose:` facts. For non-character prompts (no bios
|
||
# matched), ingest mis-classifies scene content as outfit_body.
|
||
# Skip it; the no-bio fallback below treats the prose as a
|
||
# // Scene body and applies edits via FRUIT-style LLM rewrite.
|
||
and bool(bios)
|
||
)
|
||
|
||
just_ingested = False
|
||
if should_ingest:
|
||
# Reset incoming_state so seeded facts replace stale data.
|
||
if not incoming_state.is_empty() and not has_section_headers:
|
||
incoming_state = _PromptState()
|
||
ingested = await _ingest_node_prompt_to_facts(
|
||
request_id, provider, config, node_prompt,
|
||
)
|
||
if ingested:
|
||
dbg.info(
|
||
"ai-patch[%s] ingest: %d facts from %d-char node_prompt",
|
||
request_id, len(ingested), len(node_prompt),
|
||
)
|
||
_nlf.seed_state_from_ingested_facts(incoming_state, bios, ingested)
|
||
# Replace flat node_prompt with a sectioned synthesis built
|
||
# from ingested per-section bodies — the rest of the pipeline
|
||
# uses // Section: headers to identify what to preserve, and
|
||
# flat user prose has none.
|
||
char_for_header = incoming_state.primary_character()
|
||
synth = _nlf.synthesize_node_prompt_from_ingest(
|
||
ingested,
|
||
char_display=(char_for_header.display if char_for_header else ""),
|
||
char_series=(char_for_header.series if char_for_header else ""),
|
||
outfit_name=(char_for_header.outfit.name if char_for_header else ""),
|
||
)
|
||
if synth:
|
||
node_prompt = synth
|
||
dbg.info(
|
||
"ai-patch[%s] ingest: synthesized %d-char sectioned node_prompt",
|
||
request_id, len(synth),
|
||
)
|
||
just_ingested = True
|
||
|
||
# Refresh bio fields against current bios — only when state wasn't
|
||
# just ingested. Ingested state IS the truth; merging bios would
|
||
# re-add items the user explicitly omitted from their prose
|
||
# (e.g. fingerless_gloves from Killer Bee bio when the user only
|
||
# wrote `blue leotard, garrison cap, red gloves, yellow necktie`).
|
||
if just_ingested:
|
||
state = incoming_state
|
||
else:
|
||
state = _nlf.refresh_state_with_bios(incoming_state, bios)
|
||
|
||
# 2. Decompose first, then parse each sub-intent in its own section
|
||
# context. Per the plan: split into atomic per-section sub-intents
|
||
# before parsing — each sub-intent only emits deltas for its own
|
||
# section, so cross-section bleed (outfit text reaching pose, etc.)
|
||
# is structurally impossible. Falls back to monolithic parsing if
|
||
# decompose returns the trivial single-fallback (network down).
|
||
# Capture sub_intents for the v3 refinement pass. The decompose call
|
||
# happens inside parse_request_via_decompose; closure captures the
|
||
# result so we can route candidates without decomposing twice.
|
||
captured_sub_intents: list[dict] = []
|
||
async def _decompose(user_text: str):
|
||
try:
|
||
result = await _decompose_user_request(
|
||
request_id, provider, config, user_text,
|
||
)
|
||
except Exception:
|
||
logger.exception("ai-patch[%s] natlang_v2 decompose failed", request_id)
|
||
return None
|
||
if result:
|
||
captured_sub_intents.extend(result)
|
||
return result
|
||
|
||
# Style resolver — when a `style` sub-intent comes through decompose,
|
||
# resolve it against the same arch_prompts the upstream alias scan
|
||
# uses. The resolver is anime-aware and handles compound words like
|
||
# `hyper realistic` matching `Hyperrealistic`.
|
||
arch_prompts_for_style = arch_prompts or []
|
||
def _style_resolver(text: str):
|
||
return _resolve_style_from_text(text, arch_prompts_for_style)
|
||
|
||
# Pose-chip LLM picker — bge-small narrows the chip pool to top-K
|
||
# candidates; the LLM reads each candidate's authored natlang and
|
||
# picks the chip whose meaning best captures the user's pose
|
||
# intent. Mirrors tag mode's "retrieve candidates, LLM picks"
|
||
# pattern. No chip prose is authored here — the LLM only chooses,
|
||
# then the chosen chip's natlang renders verbatim.
|
||
async def _pose_chip_picker(user_text: str, candidates: list[dict]):
|
||
return await _llm_pick_pose_chip(
|
||
request_id, provider, config, user_text, candidates,
|
||
)
|
||
|
||
# No-bio fallback: when match-characters returned 0 bios, the user's
|
||
# prompt has no character anchor. Decompose is character-centric and
|
||
# mis-classifies generic objects ("bowl" / "fruit") as clothing.
|
||
# Skip decompose; the entire request becomes the // Scene body.
|
||
#
|
||
# Iteration vs build: when node_prompt has a prior // Scene body,
|
||
# treat user_request as an EDIT instruction and apply it via FRUIT-
|
||
# style LLM rewrite (preserving unaffected clauses). Otherwise treat
|
||
# user_request as the fresh scene description.
|
||
no_bio_changed_sections: set | None = None
|
||
if not bios:
|
||
prior_sections_for_no_bio = _nrv2._parse_prior_sections(node_prompt or "")
|
||
prior_scene = (
|
||
(prior_sections_for_no_bio.get("scene") or {}).get("body_text", "")
|
||
or (prior_sections_for_no_bio.get("setting") or {}).get("body_text", "")
|
||
).strip()
|
||
|
||
async def _no_bio_compose(system_prompt: str, user_prompt: str) -> str:
|
||
return await _run_generation(
|
||
f"{request_id}-no-bio-scene", provider, config,
|
||
system_prompt, user_prompt, [],
|
||
)
|
||
|
||
new_scene = await _nrv2.edit_scene_no_bio(
|
||
prior_scene, user_request, model_compose=_no_bio_compose,
|
||
)
|
||
state.setting = new_scene
|
||
# Force "setting" into changed_sections so the orchestrator
|
||
# re-renders with our state.setting instead of preserving the
|
||
# prior // Scene from node_prompt.
|
||
no_bio_changed_sections = {"setting"}
|
||
dbg.info(
|
||
"ai-patch[%s] natlang_v2 no-bio fallback: prior_scene=%d chars, "
|
||
"new_scene=%d chars (edit=%s)",
|
||
request_id, len(prior_scene), len(new_scene),
|
||
"yes" if prior_scene else "no",
|
||
)
|
||
deltas: list = []
|
||
else:
|
||
try:
|
||
deltas = await _nlf.parse_request_via_decompose(
|
||
user_request, bios, state, _decompose,
|
||
style_resolver=_style_resolver,
|
||
pose_chip_picker_fn=_pose_chip_picker,
|
||
)
|
||
except _nlf.DecomposeUnavailable as e:
|
||
logger.warning(
|
||
"ai-patch[%s] natlang_v2 decompose unavailable: %s",
|
||
request_id, e,
|
||
)
|
||
return error_response(
|
||
"AI service unavailable: cannot decompose your request. "
|
||
"Make sure ollama is running and the configured model is loaded.",
|
||
503,
|
||
)
|
||
dbg.info(
|
||
"ai-patch[%s] natlang_v2 deltas: %s",
|
||
request_id,
|
||
[type(d).__name__ for d in deltas],
|
||
)
|
||
|
||
# If v2 produced a SwapStyleDelta (style sub-intent fired), it has
|
||
# already mutated state.style via apply_deltas below. Override the
|
||
# upstream-resolved style_template so the post-pass uses the v2
|
||
# resolution instead.
|
||
for d in deltas:
|
||
if isinstance(d, _nlf.SwapStyleDelta) and d.template_id:
|
||
v2_style = _find_prompt_by_id(arch_prompts_for_style, d.template_id)
|
||
if v2_style is not None:
|
||
style_template = v2_style
|
||
break
|
||
|
||
# 3. Apply deltas to state (in place).
|
||
# Snapshot active modifiers BEFORE applying deltas so we can detect
|
||
# which modifiers got auto-dropped by `update_active_modifiers_
|
||
# from_slots` (runs inside apply_deltas after FillSlotDelta). When
|
||
# the user adds e.g. blue socks against state with active barefoot,
|
||
# state correctly drops barefoot -- but v4 only sees the prose
|
||
# (which still says "Barefoot."), so the prose carries the stale
|
||
# modifier word. Capture the diff so v4 can be told to scrub it.
|
||
prior_active_modifiers: set[str] = set()
|
||
for _c in state.characters:
|
||
prior_active_modifiers.update(_c.outfit.active_modifiers)
|
||
|
||
_nlf.apply_deltas(state, deltas, bios)
|
||
|
||
post_active_modifiers: set[str] = set()
|
||
for _c in state.characters:
|
||
post_active_modifiers.update(_c.outfit.active_modifiers)
|
||
dropped_modifiers: set[str] = prior_active_modifiers - post_active_modifiers
|
||
|
||
# Text-based modifier conflict detection: state-tracking is
|
||
# insufficient when prior-turn prose has a stale modifier word
|
||
# (e.g. "Barefoot" left over from a misrouted earlier turn) that
|
||
# state never recorded as active. Scan node_prompt for modifier
|
||
# aliases that conflict with this turn's filled slots, and add
|
||
# any matches to dropped_modifiers so v4 emits cleanup directives
|
||
# for them.
|
||
fill_slots: set[str] = set()
|
||
for _d in deltas or []:
|
||
if isinstance(_d, _nlf.FillSlotDelta):
|
||
slot = (getattr(_d, "slot", "") or "").lower().strip()
|
||
if slot:
|
||
fill_slots.add(slot)
|
||
if fill_slots and node_prompt:
|
||
for _m in _load_slot_modifiers():
|
||
clears = {s.lower().strip() for s in (_m.get("clears_slots") or [])}
|
||
if not (clears & fill_slots):
|
||
continue
|
||
canonical = (_m.get("canonical_tag") or "").strip()
|
||
if not canonical:
|
||
continue
|
||
# Match canonical + aliases as whole words in the prose.
|
||
tokens = [canonical] + list(_m.get("aliases") or [])
|
||
for tok in tokens:
|
||
if not tok:
|
||
continue
|
||
pat = re.compile(rf"\b{re.escape(tok)}\b", re.IGNORECASE)
|
||
if pat.search(node_prompt):
|
||
dropped_modifiers.add(canonical)
|
||
break
|
||
|
||
# Backstop-touched sections tracker. Each backstop that mutates
|
||
# state needs to surface its change to `changed_sections` so the
|
||
# affected section gets re-rendered (otherwise it'd be preserved
|
||
# verbatim from node_prompt and the backstop's effect would be
|
||
# invisible in the output). Dev-log bug 2026-05-16 21:41: "make
|
||
# her bare foot" fired barefoot modifier via backstop, cleared
|
||
# legwear, but `outfit` wasn't in changed_sections → outfit
|
||
# preserved with old "blue socks" still in body.
|
||
backstop_changed_sections: set[str] = set()
|
||
|
||
# 3a. Text-based modifier detection backstop. Decompose at 8B often
|
||
# routes body-modifier phrases ("bare feet", "topless") to the
|
||
# `pose` section instead of emitting them as explicit modifier
|
||
# sub-intents. Without this, the modifier never fires and the
|
||
# corresponding slot stays filled — e.g. "showing bare feet" leaves
|
||
# footwear=brown_boots in state, and the FRUIT pass preserves
|
||
# "knee-high brown leather boots" in the // Outfit prose.
|
||
# Scan the raw user_request for modifier aliases (same scan the
|
||
# tag-mode slot-conflicts block uses) and inject any missing
|
||
# canonical as an ApplyModifierDelta.
|
||
detected_modifiers = _detect_modifiers_in_text(user_request)
|
||
if detected_modifiers and state.characters:
|
||
primary = state.primary_character()
|
||
if primary is not None:
|
||
already = set(primary.outfit.active_modifiers)
|
||
# When a pose chip already applied this turn (ApplyPoseChipDelta
|
||
# via bucket_search), the chip's authored natlang IS the pose
|
||
# body — re-injecting a pose-section modifier like
|
||
# `presenting_foot` would re-populate pose.pose_modifiers,
|
||
# defeat the render short-circuit, and route through the FRUIT
|
||
# pose compose path that rewrites the chip natlang. Skip
|
||
# pose-section modifiers when chip is anchoring the pose.
|
||
chip_applied = (
|
||
primary.pose.natlang_anchor.strip()
|
||
and primary.pose.bio_pose_id is None
|
||
and primary.pose.name.strip()
|
||
)
|
||
for mod in detected_modifiers:
|
||
canon = (mod.get("canonical_tag") or "").strip().lower()
|
||
if not canon or canon in already:
|
||
continue
|
||
substitute_section = (mod.get("substitute_section") or "").lower()
|
||
if chip_applied and substitute_section == "pose":
|
||
continue
|
||
_nlf.apply_deltas(state, [_nlf.ApplyModifierDelta(
|
||
canonical=canon,
|
||
clears_slots=list(mod.get("clears_slots") or []),
|
||
substitute_section=substitute_section,
|
||
)], bios)
|
||
already.add(canon)
|
||
# Mark affected sections changed. Outfit-section
|
||
# modifiers touch outfit slots; pose-section modifiers
|
||
# touch pose.pose_modifiers (but chip-applied path
|
||
# skips those above).
|
||
if substitute_section == "pose":
|
||
backstop_changed_sections.add("pose")
|
||
else:
|
||
backstop_changed_sections.add("outfit")
|
||
|
||
# 3a-bis-outfit. Outfit-swap backstop. "in X outfit" / "wearing X
|
||
# outfit" routed by decompose to a non-outfit section is lost
|
||
# otherwise. Run only if no SwapOutfitDelta already fired this
|
||
# turn — decompose-side path takes priority when it works.
|
||
if state.characters:
|
||
primary = state.primary_character()
|
||
if primary is not None:
|
||
already_swapped = any(
|
||
isinstance(d, _nlf.SwapOutfitDelta) for d in deltas
|
||
)
|
||
if not already_swapped:
|
||
swap_delta = _nlf.extract_outfit_swap_from_text(user_request, bios)
|
||
if swap_delta is not None:
|
||
_nlf.apply_deltas(state, [swap_delta], bios)
|
||
backstop_changed_sections.add("outfit")
|
||
|
||
# 3a-bis-pre. Strip-intent backstop. "Wearing only X" / "just X" /
|
||
# "in nothing but X" routed by decompose to anywhere but `strip:`
|
||
# would lose the strip semantics. Same brittleness pattern as slot
|
||
# fills. Strip extractor scans user_request, emits StripDelta +
|
||
# FillSlotDelta for each detected strip phrase. Must run BEFORE the
|
||
# slot-fill backstop so the strip's keep-set is established first.
|
||
if state.characters:
|
||
primary = state.primary_character()
|
||
if primary is not None:
|
||
# Only fire if decompose didn't already emit a strip-shaped
|
||
# delta this turn — otherwise we'd double-strip.
|
||
already_stripped = any(
|
||
isinstance(d, _nlf.StripDelta) for d in deltas
|
||
)
|
||
if not already_stripped:
|
||
strip_deltas = _nlf.extract_strip_intents_from_text(user_request)
|
||
if strip_deltas:
|
||
_nlf.apply_deltas(state, strip_deltas, bios)
|
||
backstop_changed_sections.add("outfit")
|
||
|
||
# 3a-bis. Full-request slot-fill backstop. Symmetric to the modifier
|
||
# backstop above: scans the raw user_request for clothing items and
|
||
# fills the corresponding slots regardless of how decompose chose
|
||
# to route the tokens. 8B decompose is non-deterministic on routing
|
||
# — same input gives `pose: legs up showing blue socks at viewer`
|
||
# one run and `outfit: blue socks` + `pose: ...` the next. Pipeline
|
||
# cannot depend on decompose section choices. Mirror of tag mode's
|
||
# parallel-extractor pattern (alias scan + literal anchor + semantic
|
||
# search all run on the full user_request).
|
||
if state.characters:
|
||
primary = state.primary_character()
|
||
if primary is not None:
|
||
backstop_fills = _nlf.extract_slot_fills_from_text(user_request)
|
||
# Don't re-fill slots already user-filled this turn or in
|
||
# prior state — those took the explicit path; the backstop
|
||
# only covers slots decompose dropped.
|
||
from .prompt_state import SLOT_STATE_FILLED, ORIGIN_USER
|
||
for fill in backstop_fills:
|
||
slot_name = fill.slot
|
||
cur = primary.outfit.slot_states.get(slot_name)
|
||
if (cur and cur.state == SLOT_STATE_FILLED
|
||
and (cur.origin or "") == ORIGIN_USER):
|
||
continue
|
||
_nlf.apply_deltas(state, [fill], bios)
|
||
backstop_changed_sections.add("outfit")
|
||
|
||
# 3a-bis-clear. Slot-clear backstop. "no socks" / "remove the boots"
|
||
# / "without gloves" / "drop the leotard" — per-slot clearing via
|
||
# negation patterns. Independent of slot_modifier aliases so the
|
||
# user doesn't need to add per-clothing-item modifiers to fire
|
||
# specific clears. Cleared items keep prior item/color so the
|
||
# render-layer prose strippers can locate and remove them.
|
||
if state.characters:
|
||
primary = state.primary_character()
|
||
if primary is not None:
|
||
clear_deltas = _nlf.extract_slot_clears_from_text(user_request)
|
||
if clear_deltas:
|
||
_nlf.apply_deltas(state, clear_deltas, bios)
|
||
backstop_changed_sections.add("outfit")
|
||
|
||
# 3a-ter. Chip-implied slot clears. When a pose chip applies, scan
|
||
# its authored tags + natlang against `slot_modifiers` aliases. Any
|
||
# matched modifier's `clears_slots` applies — the chip is describing
|
||
# a pose state that implies certain slots are empty/clear.
|
||
#
|
||
# Example: chip `presenting_feet` has tags `(legs up:1.1), sitting,
|
||
# presenting feet, soles, foot focus`. The phrase "presenting feet"
|
||
# is an alias of slot_modifier `presenting_foot` with
|
||
# clears_slots=[footwear, legwear]. So when the chip applies, the
|
||
# bio's brown boots (footwear) get cleared — the pose explicitly
|
||
# shows feet, boots would hide them.
|
||
#
|
||
# User fills take priority: when a slot to-be-cleared was filled by
|
||
# the user this turn, the fill wins and the implied clear is dropped
|
||
# for that slot only (same logic as
|
||
# `update_active_modifiers_from_slots`). For "blue socks at viewer":
|
||
# legwear=blue_socks (user fill) survives, footwear=brown_boots
|
||
# (bio default) clears.
|
||
chip_deltas = [d for d in deltas if isinstance(d, _nlf.ApplyPoseChipDelta)]
|
||
if chip_deltas and state.characters:
|
||
primary = state.primary_character()
|
||
if primary is not None:
|
||
from .prompt_state import SLOT_STATE_FILLED, ORIGIN_USER, SlotState, SLOT_STATE_CLEARED
|
||
all_modifiers = _load_slot_modifiers()
|
||
for chip in chip_deltas:
|
||
bag_lc = " ".join([
|
||
chip.display_name or "",
|
||
chip.base_tags or "",
|
||
chip.base_natlang or "",
|
||
chip.chip_tag or "",
|
||
]).lower()
|
||
for mod in all_modifiers:
|
||
aliases = list(mod.get("aliases") or [])
|
||
canon = (mod.get("canonical_tag") or "").strip().lower()
|
||
if canon:
|
||
aliases = [canon.replace("_", " "), canon] + aliases
|
||
matched = False
|
||
for alias in aliases:
|
||
alias_lc = (alias or "").strip().lower()
|
||
if not alias_lc:
|
||
continue
|
||
if re.search(rf"(?<!\w){re.escape(alias_lc)}(?!\w)", bag_lc):
|
||
matched = True
|
||
break
|
||
if not matched:
|
||
continue
|
||
clears = [s.strip().lower() for s in (mod.get("clears_slots") or []) if s and s.strip()]
|
||
if not clears:
|
||
continue
|
||
for slot_name in clears:
|
||
cur = primary.outfit.slot_states.get(slot_name)
|
||
# Respect user fills this turn — don't undo
|
||
# what the user just put there.
|
||
if (cur and cur.state == SLOT_STATE_FILLED
|
||
and (cur.origin or "") == ORIGIN_USER):
|
||
continue
|
||
# Preserve prior item/color for downstream
|
||
# body-prose stripping (same shape as
|
||
# `_apply_modifier`'s preservation).
|
||
prior_item = (cur.item if cur else "") or ""
|
||
prior_color = (cur.color if cur else "") or ""
|
||
primary.outfit.slot_states[slot_name] = SlotState(
|
||
state=SLOT_STATE_CLEARED,
|
||
by_modifier=canon,
|
||
item=prior_item,
|
||
color=prior_color,
|
||
)
|
||
backstop_changed_sections.add("outfit")
|
||
|
||
# 3b. Style template overlay (handled outside the delta system so we
|
||
# don't lose it on bio-driven rebuilds).
|
||
if style_template:
|
||
state.style = _StyleState(
|
||
template_id=(style_template.get("id") or "").strip(),
|
||
name=(style_template.get("name") or "").strip(),
|
||
)
|
||
|
||
# 4. Compose section bodies.
|
||
#
|
||
# No-bio path: state.setting carries the FRUIT-authored scene from
|
||
# edit_scene_no_bio earlier; render_all_sections composes it into a
|
||
# `// Scene` section deterministically. No LLM authoring needed.
|
||
#
|
||
# Bio path: LLM authors the full prompt body via the natlang patch
|
||
# system prompt, with bios (V2 chip data + enrichers) as reference.
|
||
# Mirrors tag-mode's authoring path — chips are helpers, not
|
||
# templates. State (PromptState) remains the structured backing for
|
||
# TagBuilder2 round-trip, derived from typed deltas above; the LLM
|
||
# output is the prose. Both reflect the user's request because
|
||
# decompose + delta application + LLM authoring all consume the
|
||
# same user_request and bios.
|
||
_mark("inference")
|
||
# Deterministic compositor handles all paths. V2 chip composers (in
|
||
# tag_builder.py) feed enriched natlang into bios; render_all_sections
|
||
# surfaces it via the bio-anchor short-circuit (build mode, no user
|
||
# fills) or FRUIT-edits with chip prose as seed (patch / user fills).
|
||
#
|
||
# LLM body authoring was prototyped here for build mode but 8B couldn't
|
||
# preserve bio chip detail reliably (hallucinated pose bodies, invented
|
||
# scenes the user never asked for, dropped header attributions). The
|
||
# deterministic compositor + V2 chips delivers richer faithful output
|
||
# than the LLM authoring path at the production model size.
|
||
async def _model_compose(system_prompt: str, user_prompt: str) -> str:
|
||
return await _run_generation(
|
||
request_id, provider, config,
|
||
system_prompt, user_prompt, [],
|
||
)
|
||
if not node_prompt.strip():
|
||
changed_sections = None
|
||
elif no_bio_changed_sections is not None:
|
||
changed_sections = no_bio_changed_sections
|
||
else:
|
||
changed_sections = _nlf.changed_sections_from_deltas(deltas)
|
||
# Merge backstop-applied changes — modifier / outfit-swap /
|
||
# strip / slot-fill / chip-implied-clears all mutate state
|
||
# outside the initial deltas list, so changed_sections_from_deltas
|
||
# can't see them. Without this merge, the affected section
|
||
# would be preserved verbatim from node_prompt and the
|
||
# backstop's state mutation would be invisible in the output.
|
||
if backstop_changed_sections:
|
||
changed_sections = set(changed_sections) | backstop_changed_sections
|
||
# Force pose re-render when outfit slots changed AND the
|
||
# character has a chip-anchored pose body mentioning a body
|
||
# part. The slot-context injection in render_pose_section
|
||
# depends on a re-render; without it, preserved-verbatim pose
|
||
# bodies stay decoupled from the new slot state (image model
|
||
# sees "presenting feet with focus on soles" + outfit "pink
|
||
# socks" as two separate sentences, defaults feet to bare).
|
||
if "outfit" in changed_sections and "pose" not in changed_sections:
|
||
primary = state.primary_character() if state.characters else None
|
||
if (primary is not None
|
||
and primary.pose.natlang_anchor.strip()
|
||
and primary.pose.bio_pose_id is None
|
||
and primary.pose.name.strip()):
|
||
anchor_lc = primary.pose.natlang_anchor.lower()
|
||
# Match the body-part scope of the injection: foot-region only.
|
||
if any(part in anchor_lc for part in (
|
||
"feet", "foot", "soles", "sole", "toes", "toe",
|
||
)):
|
||
changed_sections = set(changed_sections) | {"pose"}
|
||
# ── v4 identify-rewrite branch ─────────────────────────────────
|
||
# When deltas have no swap-shape entries, v2's state-driven render
|
||
# is destructive: it recomposes section bodies from PromptState,
|
||
# which doesn't track rich modifier prose (insignia descriptions,
|
||
# leg openings, chest harness details). The FRUIT recompose drops
|
||
# that prose. Route to v4 identify-rewrite instead -- surgical
|
||
# clause-level edits against the original node_prompt, with
|
||
# algorithmic byte-identity guarantees on sections the user didn't
|
||
# touch. Gated to iteration mode (non-empty node_prompt) with
|
||
# bios; fresh-build and no-bio paths still use v2.
|
||
from . import natlang_v4 as _nv4
|
||
v4_active = (
|
||
bool(node_prompt and node_prompt.strip())
|
||
and bool(bios)
|
||
and not _nv4.has_swap_shape_delta(deltas)
|
||
and no_bio_changed_sections is None
|
||
and not just_ingested
|
||
)
|
||
v4_authored_text = ""
|
||
if v4_active:
|
||
async def _v4_compose(system_prompt: str, user_prompt: str) -> str:
|
||
return await _run_generation(
|
||
f"{request_id}-v4", provider, config,
|
||
system_prompt, user_prompt, [],
|
||
)
|
||
# Augment user_request with cleanup directives for modifiers
|
||
# that the state machine dropped this turn (e.g. user added
|
||
# blue socks -> legwear is user-filled now -> barefoot was
|
||
# auto-dropped from state, but the prose still says "Barefoot"
|
||
# from the prior turn). v4's split_intents picks these up as
|
||
# additional atomic removal intents.
|
||
v4_user_request = user_request
|
||
if dropped_modifiers:
|
||
cleanup_phrases = [
|
||
f"remove the {m.replace('_', ' ')} mention"
|
||
for m in sorted(dropped_modifiers)
|
||
]
|
||
# Cleanup directives MUST come FIRST so removal of stale
|
||
# modifier prose happens before the user's additive intent.
|
||
# Otherwise: "give her blue socks" runs first against state
|
||
# with active Barefoot -> rewrites "[OUTFIT] Barefoot" to
|
||
# "[OUTFIT] Barefoot, blue socks". Then "remove barefoot"
|
||
# finds the new combined clause, deletes the whole thing,
|
||
# losing the socks. Cleanup first -> Barefoot gone ->
|
||
# blue socks lands on a non-Barefoot anchor.
|
||
v4_user_request = (
|
||
". ".join(cleanup_phrases) + ". " + user_request.lstrip(". ")
|
||
)
|
||
dbg.info(
|
||
"ai-patch[%s] v4: state-dropped modifiers %s; augmenting "
|
||
"user_request with cleanup directives",
|
||
request_id, sorted(dropped_modifiers),
|
||
)
|
||
v4_result = await _nv4.edit_prompt(
|
||
node_prompt, v4_user_request, _v4_compose,
|
||
)
|
||
v4_output = (v4_result or {}).get("output") or ""
|
||
if v4_output.strip() and "// " in v4_output:
|
||
# v4's output is the FINAL text. Its reassemble step
|
||
# already preserves every untouched clause byte-identical
|
||
# (including the negative prompt block) -- piping the
|
||
# text back through v2's post-passes (style preservation,
|
||
# _preserve_existing_negatives) would double the negative
|
||
# prompt because the post-pass appends a new is_negative
|
||
# section when it can't find one in the list.
|
||
# Skip post-passes entirely; set output_text directly.
|
||
v4_authored_text = v4_output
|
||
# Still build a rendered_sections list for the response
|
||
# payload shape (TagBuilder UI parses this). Mark the
|
||
# negative block as is_negative so downstream consumers
|
||
# recognize it.
|
||
parsed = _nrv2._parse_prior_sections(v4_output)
|
||
section_list: list[dict] = []
|
||
if isinstance(parsed, dict):
|
||
for kind, sec in parsed.items():
|
||
section_list.append({
|
||
"header": sec.get("header") or f"// {kind.title()}:",
|
||
"body_text": sec.get("body_text") or "",
|
||
"tokens": [sec.get("body_text") or ""],
|
||
})
|
||
rendered_sections = section_list
|
||
dbg.info(
|
||
"ai-patch[%s] natlang_v4 applied: intents=%s statuses=%s "
|
||
"output_chars=%d",
|
||
request_id, v4_result.get("intents"),
|
||
v4_result.get("statuses"), len(v4_output),
|
||
)
|
||
else:
|
||
dbg.info(
|
||
"ai-patch[%s] natlang_v4 produced empty body -- "
|
||
"falling back to v2 render", request_id,
|
||
)
|
||
v4_active = False
|
||
|
||
if not v4_active:
|
||
rendered_sections = await _nrv2.render_all_sections(
|
||
state,
|
||
node_prompt=node_prompt,
|
||
changed_sections=changed_sections,
|
||
model_compose=_model_compose,
|
||
)
|
||
|
||
# 5. Section-level post-pass — reuse existing style + negative helpers.
|
||
# SKIPPED when v4 authored: v4's reassemble already preserves the
|
||
# style sentence and negative prompt block byte-identical from the
|
||
# input. Running _preserve_existing_negatives on top would APPEND a
|
||
# second negative-prompt section because it doesn't find one marked
|
||
# is_negative in our rendered_sections list (we built that list for
|
||
# response-payload shape only -- v4's text is authoritative).
|
||
if not v4_active:
|
||
if style_template:
|
||
new_style = _build_style_section(style_template)
|
||
if new_style:
|
||
rendered_sections = _replace_or_append_style_section(
|
||
rendered_sections, new_style,
|
||
)
|
||
rendered_sections = _merge_template_negatives(
|
||
rendered_sections, style_template,
|
||
)
|
||
rendered_sections = _preserve_existing_style_section(
|
||
rendered_sections, node_prompt,
|
||
)
|
||
rendered_sections = _preserve_existing_negatives(
|
||
rendered_sections, node_prompt,
|
||
)
|
||
|
||
# 6. Assemble output text.
|
||
from . import natlang_render as _nlr
|
||
if v4_active and v4_authored_text:
|
||
output_text = v4_authored_text
|
||
else:
|
||
output_text = _nlr.assemble_output_text(rendered_sections)
|
||
_dump(request_id, "patch_rendered_v2", output_text)
|
||
_mark("post")
|
||
|
||
# 7. v3 LLM authoring refinement. v2's state-driven render handles
|
||
# modifier slot clearing, named outfit/pose swaps, and chip
|
||
# substitution faithfully -- but it can't represent free-form
|
||
# anatomy/clothing modifications like "bigger feet", "stretched
|
||
# ears", "wearing red socks" since they don't map to delta types.
|
||
# The refinement pass hands v2's body to the LLM with the original
|
||
# user_request + curated candidates; the LLM applies any unaddressed
|
||
# intent in-place and preserves sections v2 already got right.
|
||
# Bypassed when v2 produced no body (defensive -- v2's render is
|
||
# always expected to succeed) or no characters matched.
|
||
# Skip v3 when there's no edit to apply -- ingestion turns have no
|
||
# user_request, fresh-build turns have empty deltas, and either way
|
||
# v3 would just re-author v2's body for no benefit (risk of dropping
|
||
# preserved sections the LLM didn't recognize as worth keeping).
|
||
v3_should_run = (
|
||
output_text.strip()
|
||
and bios
|
||
and (user_request or "").strip()
|
||
and (deltas or captured_sub_intents)
|
||
# Fresh-build mode (no prior node_prompt) doesn't need v3 -- v2
|
||
# authored from scratch with the right deltas, and v3 LLM tends
|
||
# to invent placeholder sections like "// Scene: (unchanged)"
|
||
# when there's no prior prose to anchor against.
|
||
and node_prompt
|
||
and node_prompt.strip()
|
||
# v4 already authored body via identify-rewrite; re-running v3
|
||
# would just add latency and risk over-authoring.
|
||
and not v4_active
|
||
)
|
||
if v3_should_run:
|
||
try:
|
||
from . import natlang_v3 as _nv3
|
||
v3_candidates = _nv3.candidates_from_v2_sections(
|
||
rendered_sections, deltas,
|
||
)
|
||
v3_patch_user = _nv3.build_patch_user(
|
||
bios, output_text, user_request, v3_candidates,
|
||
)
|
||
v3_body = await _run_generation(
|
||
f"{request_id}-v3-author", provider, config,
|
||
_nv3.V3_SYSTEM, v3_patch_user, [],
|
||
)
|
||
v3_body = (v3_body or "").strip()
|
||
if v3_body and "// " in v3_body:
|
||
v3_body = _nv3.strip_placeholder_sections(v3_body)
|
||
v3_body = _nv3.strip_invented_sections(v3_body, output_text)
|
||
v3_body = _nv3.enforce_candidate_substitutions(
|
||
v3_body, v3_candidates,
|
||
)
|
||
_dump(request_id, "patch_rendered_v3", v3_body)
|
||
output_text = v3_body
|
||
dbg.info(
|
||
"ai-patch[%s] natlang_v3 refinement applied: "
|
||
"candidates=%d chars=%d",
|
||
request_id, len(v3_candidates), len(v3_body),
|
||
)
|
||
else:
|
||
dbg.info(
|
||
"ai-patch[%s] natlang_v3 refinement empty -- keeping v2 body",
|
||
request_id,
|
||
)
|
||
except Exception:
|
||
logger.exception(
|
||
"ai-patch[%s] natlang_v3 refinement failed -- keeping v2 body",
|
||
request_id,
|
||
)
|
||
|
||
total = time.perf_counter() - _t_request_start
|
||
dbg.info(
|
||
"ai-patch[%s] natlang_v2 done: deltas=%s sections=%d chars=%d total=%.2fs",
|
||
request_id,
|
||
[type(d).__name__ for d in deltas],
|
||
len(rendered_sections),
|
||
len(output_text),
|
||
total,
|
||
)
|
||
|
||
return web.json_response({
|
||
"request_id": request_id,
|
||
"output_text": output_text,
|
||
"sections": rendered_sections,
|
||
"raw": "",
|
||
"prompt_state": state.to_dict(),
|
||
})
|
||
|
||
|
||
@routes.post("/promptchain/ai/patch")
|
||
async def _api_patch(request):
|
||
body, err = await parse_json(request)
|
||
if err:
|
||
return err
|
||
|
||
node_prompt = (body.get("node_prompt") or "").strip()
|
||
user_request = (body.get("user_request") or "").strip()
|
||
# Original user message (chat agent's view of the user's actual
|
||
# words). Used to recover intents the agent's distilled `request`
|
||
# may have paraphrased away. Falls back to user_request when not
|
||
# provided (legacy single-shot panel path).
|
||
latest_user_text = (body.get("latest_user_text") or "").strip() or user_request
|
||
# Current-turn message ONLY (no multi-turn concat). Used for
|
||
# outfit-name scan and other current-turn intent classification
|
||
# where prior-turn phrases would pollute. Falls back to
|
||
# latest_user_text -> user_request for the legacy single-shot path.
|
||
current_user_text = (
|
||
(body.get("current_user_text") or "").strip()
|
||
or latest_user_text
|
||
or user_request
|
||
)
|
||
bios = body.get("bios") or []
|
||
if not isinstance(bios, list):
|
||
bios = []
|
||
|
||
if not user_request:
|
||
return error_response("user_request is empty", 400)
|
||
|
||
# Same-character-multi-outfit pre-pass. Production scenario:
|
||
# `cammy in killer_bee outfit fighting cammy in shadaloo outfit`.
|
||
# Character matcher dedups by tag → bios has 1 cammy. Detect
|
||
# `<char_name> in <outfit_phrase> outfit` patterns in user_request
|
||
# and, for each character with >1 distinct outfit phrase, duplicate
|
||
# the bio with each outfit picked from the DB. Downstream multi-
|
||
# char machinery + _ensure_same_char_multi_outfit_sections handle
|
||
# the rest.
|
||
bios = _expand_bios_for_same_char_multi_outfit(bios, user_request)
|
||
|
||
# Resolved up-front so any logging in the style/grounding block
|
||
# below can use it. Other code that follows still references it.
|
||
request_id = (body.get("request_id") or "").strip() or uuid.uuid4().hex
|
||
|
||
# ── Railed-thinking pipeline (NATLANG mode only) ─────────────────
|
||
# Routes to decompose -> resolve -> locate-infill -> apply in
|
||
# _harness/natlang_rails_probe.py. Hybrid/rails operates entirely on
|
||
# `// Section:` headers and prose section bodies — it is a natlang-
|
||
# only pipeline. Tag mode keeps the legacy multi-stage path below
|
||
# which composes comma-separated tag sections.
|
||
#
|
||
# Override paths:
|
||
# - PROMPTCHAIN_USE_LEGACY=1 : never use rails (emergency rollback)
|
||
# - body.use_legacy: true : per-request legacy
|
||
# - body.use_rails: true : force rails even in build mode
|
||
_prompt_style_for_routing = (
|
||
(body.get("prompt_style") or "").strip().lower()
|
||
or "tags"
|
||
)
|
||
_force_legacy = (
|
||
os.environ.get("PROMPTCHAIN_USE_LEGACY", "").strip() in ("1", "true", "yes")
|
||
or bool(body.get("use_legacy"))
|
||
)
|
||
_force_rails = bool(body.get("use_rails"))
|
||
# Hybrid edit-path router is the DEFAULT for natlang. Rails handles
|
||
# whole-section ops + KB injection, fragment-rewrite handles
|
||
# anatomy_mod/modify/remove (which rails' locate-infill mishandles
|
||
# — e.g. EXAMPLE 5 produced 'barefoot, bigger feet' peer-comma
|
||
# output for footwear anatomy mods, which is the slot-exclusivity
|
||
# bug). To force pure rails-v2 (no fragment path), set
|
||
# PROMPTCHAIN_USE_RAILS_ONLY=1 or body.use_rails_only=true.
|
||
_force_rails_only = (
|
||
os.environ.get("PROMPTCHAIN_USE_RAILS_ONLY", "").strip() in ("1", "true", "yes")
|
||
or bool(body.get("use_rails_only"))
|
||
)
|
||
_use_hybrid = not _force_rails_only
|
||
_use_rails = (not _force_legacy) and (_prompt_style_for_routing == "natural")
|
||
|
||
# Tag-rails is the DEFAULT for tag mode. Replaces the legacy
|
||
# monolithic patch-generation LLM call with a deterministic compose
|
||
# pipeline (scripts/tag_rails_v1.py). Opt-out paths:
|
||
# - PROMPTCHAIN_USE_LEGACY=1 / body.use_legacy: true → legacy
|
||
# - PROMPTCHAIN_USE_TAG_RAILS=0 → force-disable rails specifically
|
||
# - body.use_tag_rails: false → per-request legacy
|
||
# See dev-promptchain/docs/plans/tag-rails-migration-plan.md for
|
||
# architecture and migration sequence.
|
||
_tag_rails_explicit_off = (
|
||
os.environ.get("PROMPTCHAIN_USE_TAG_RAILS", "").strip()
|
||
in ("0", "false", "no")
|
||
or body.get("use_tag_rails") is False
|
||
)
|
||
_use_tag_rails = (
|
||
(not _force_legacy)
|
||
and _prompt_style_for_routing == "tags"
|
||
and not _tag_rails_explicit_off
|
||
)
|
||
if _use_tag_rails:
|
||
_pc_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
if _pc_root not in sys.path:
|
||
sys.path.insert(0, _pc_root)
|
||
try:
|
||
from scripts.tag_rails_v1 import run_tag_rails as _ai_run_tag_rails
|
||
except Exception as e:
|
||
logger.error("ai-patch: tag-rails import failed: %s", e, exc_info=True)
|
||
return error_response(f"tag-rails unavailable: {e}", 500)
|
||
try:
|
||
trace = await _ai_run_tag_rails(
|
||
node_prompt, user_request, bios,
|
||
model_hash=(body.get("model_hash") or "").strip(),
|
||
request_id=request_id,
|
||
tag_format=(body.get("tag_format") or "spaces"),
|
||
is_standalone_main=bool(body.get("is_standalone_main")),
|
||
)
|
||
except Exception as e:
|
||
logger.error("ai-patch (tag-rails-v1): pipeline error: %s",
|
||
e, exc_info=True)
|
||
return error_response(f"tag-rails pipeline error: {e}", 500)
|
||
return web.json_response({
|
||
"request_id": request_id,
|
||
"output_text": trace.get("final_prompt") or node_prompt,
|
||
"sections": trace.get("sections") or [],
|
||
"raw": {
|
||
"pipeline": "tag-rails-v1",
|
||
"intents": trace.get("intents", []),
|
||
"trace": trace.get("trace", []),
|
||
},
|
||
"prompt_state": body.get("prompt_state") or None,
|
||
"pipeline": "tag-rails-v1",
|
||
})
|
||
|
||
if _use_rails:
|
||
# Multi-character EDIT compose path. When the chat agent
|
||
# passes 2+ characters in character_queries but the existing
|
||
# node_prompt has 0-1 // Character: sections (i.e., the user
|
||
# is ADDING a character via natural language), bypass the
|
||
# hybrid path entirely and build inline-bundle prose via
|
||
# compose_from_plan + scene composer. The chat agent's
|
||
# request text is unreliable in this transition (treats new
|
||
# chars as scene content rather than as a character add).
|
||
# Single-character paths AND already-multi-char edits both
|
||
# fall through to hybrid.
|
||
_mc_character_queries = body.get("character_queries") or []
|
||
if isinstance(_mc_character_queries, str):
|
||
_mc_character_queries = [_mc_character_queries]
|
||
if not isinstance(_mc_character_queries, list):
|
||
_mc_character_queries = []
|
||
try:
|
||
_mc_composed = await _maybe_compose_multichar_edit(
|
||
request,
|
||
node_prompt=node_prompt,
|
||
user_request=user_request,
|
||
character_queries=_mc_character_queries,
|
||
model_hash=(body.get("model_hash") or "").strip(),
|
||
bios=bios or [],
|
||
)
|
||
except Exception:
|
||
logger.exception("ai-patch: multichar-edit compose raised")
|
||
_mc_composed = None
|
||
if _mc_composed:
|
||
return web.json_response({
|
||
"request_id": request_id,
|
||
"output_text": _mc_composed,
|
||
"sections": _parse_sectioned_output(_mc_composed),
|
||
"raw": {
|
||
"pipeline": "multichar-edit-compose",
|
||
"character_queries": _mc_character_queries,
|
||
},
|
||
"prompt_state": body.get("prompt_state") or None,
|
||
"pipeline": "multichar-edit-compose",
|
||
})
|
||
|
||
# ComfyUI's custom-node loader doesn't put the extension root on
|
||
# sys.path, so a plain `import scripts.X` fails. Inject the
|
||
# PromptChain root explicitly.
|
||
_pc_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
if _pc_root not in sys.path:
|
||
sys.path.insert(0, _pc_root)
|
||
try:
|
||
if _use_hybrid:
|
||
from scripts.natlang_hybrid_v1 import (
|
||
run_hybrid_turn as _ai_run_turn,
|
||
)
|
||
_pipeline_name = "hybrid-v1"
|
||
else:
|
||
from scripts.natlang_rails_v2 import (
|
||
run_turn_v2 as _ai_run_turn,
|
||
)
|
||
_pipeline_name = "rails-v2"
|
||
except Exception as e:
|
||
logger.error("ai-patch: failed to import %s pipeline: %s",
|
||
"hybrid" if _use_hybrid else "rails",
|
||
e, exc_info=True)
|
||
return error_response(
|
||
f"pipeline unavailable: {e}", 500
|
||
)
|
||
try:
|
||
trace = await _ai_run_turn(
|
||
node_prompt, user_request,
|
||
model_hash=(body.get("model_hash") or "").strip() or None,
|
||
bios=bios,
|
||
)
|
||
except Exception as e:
|
||
logger.error("ai-patch (%s): pipeline error: %s",
|
||
_pipeline_name, e, exc_info=True)
|
||
return error_response(f"{_pipeline_name} pipeline error: {e}", 500)
|
||
|
||
# Style-template promotion post-pass. The rails dispatcher
|
||
# works at sentence-level and replaces only the style sentence
|
||
# it matched — leaving the section header stuck on the prior
|
||
# template name, scene-shaped sentences orphaned in the style
|
||
# section, and the negatives still pointing at the auto-seeded
|
||
# default. This post-pass detects a style intent that resolved
|
||
# to a known template and does the full swap: header, body,
|
||
# negatives. Behavior matches what a tag-builder dropdown apply
|
||
# would produce.
|
||
final_prompt = trace.get("final_prompt") or node_prompt
|
||
style_intent = _find_style_intent(trace.get("intents") or [])
|
||
if style_intent:
|
||
model_hash_for_style = (body.get("model_hash") or "").strip()
|
||
try:
|
||
_grounding = _build_grounding(model_hash_for_style)
|
||
from . import prompts as _prompts_for_style
|
||
_arch_prompts = _prompts_for_style.list_prompts(
|
||
architecture=(_grounding.get("architecture") or "").strip()
|
||
or None,
|
||
family=_grounding.get("family"),
|
||
model_name=(_grounding.get("model_name")
|
||
or _grounding.get("display_name")),
|
||
model_hash=model_hash_for_style or None,
|
||
)
|
||
_target_name = (
|
||
style_intent.get("resolved_match_name") or ""
|
||
).strip().lower()
|
||
_template = next(
|
||
(p for p in _arch_prompts
|
||
if (p.get("name") or "").strip().lower() == _target_name),
|
||
None,
|
||
)
|
||
if _template:
|
||
swapped = _swap_style_template_in_prompt(final_prompt, _template)
|
||
if swapped and swapped != final_prompt:
|
||
logger.info(
|
||
"ai-patch (%s): style-template post-pass swapped "
|
||
"to template=%r (id=%s)",
|
||
_pipeline_name,
|
||
_template.get("name"),
|
||
_template.get("id"),
|
||
)
|
||
final_prompt = swapped
|
||
else:
|
||
logger.info(
|
||
"ai-patch (%s): style post-pass found intent with "
|
||
"name=%r but no matching template in arch_prompts",
|
||
_pipeline_name, _target_name,
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"ai-patch (%s): style-template post-pass failed",
|
||
_pipeline_name, exc_info=True,
|
||
)
|
||
|
||
# Multi-character polish post-pass. When the final prompt has
|
||
# 2+ // Character: sections (typical of edit-mode multi-char
|
||
# adds — "make her fighting chun-li"), modern T2I encoders
|
||
# (T5-XXL, Qwen3-4B, Qwen2.5-VL) can't bind a shared //
|
||
# Outfit/Pose section to the right subject because they don't
|
||
# honor // section boundaries. Fold into inline-bundle prose
|
||
# with spatial anchors via the scene composer. Single-char
|
||
# outputs return verbatim (composer call is gated internally).
|
||
# Build-mode multi-char is already polished inside rails-v2 by
|
||
# the time it reaches here, so this is a no-op for those.
|
||
final_prompt = await _polish_multichar_to_prose(
|
||
final_prompt, _pipeline_name,
|
||
)
|
||
|
||
# Build a panel-compatible response. `output_text` is the new
|
||
# full node_prompt. `raw` carries the pipeline trace.
|
||
intents_summary = [
|
||
{
|
||
"concept": it.get("concept"),
|
||
"op": it.get("op"),
|
||
"text": it.get("text"),
|
||
"resolved_source": it.get("resolved_source"),
|
||
"dispatch_kind": it.get("dispatch_kind"),
|
||
"parent": it.get("parent"),
|
||
"search": it.get("search"),
|
||
"replace": it.get("replace"),
|
||
"insert_after": it.get("insert_after"),
|
||
"method": it.get("method"),
|
||
"route": it.get("route"),
|
||
}
|
||
for it in trace.get("intents", [])
|
||
]
|
||
return web.json_response({
|
||
"request_id": request_id,
|
||
"output_text": final_prompt,
|
||
# Build mode has no edit-diff, but the proposal card and the chat
|
||
# agent's success check both read `sections`. Derive them from the
|
||
# built prompt so the card shows // Character/Outfit/Pose blocks
|
||
# and the agent doesn't misread an empty list as a no-op.
|
||
"sections": _parse_sectioned_output(final_prompt),
|
||
"raw": {
|
||
"pipeline": _pipeline_name,
|
||
"decompose": trace.get("decompose"),
|
||
"scan": trace.get("scan"),
|
||
"intents": intents_summary,
|
||
"routing": trace.get("routing"),
|
||
},
|
||
"prompt_state": body.get("prompt_state") or None,
|
||
"pipeline": _pipeline_name,
|
||
})
|
||
# ── /experimental rails ────────────────────────────────────────
|
||
|
||
# Per-step timing instrumentation. Captures wall-clock deltas around
|
||
# each major stage so we can see where time goes in a slow patch.
|
||
# Logged as a single one-line summary at the end of the request.
|
||
_timing: dict[str, float] = {}
|
||
_t_request_start = time.perf_counter()
|
||
_t_step_start = _t_request_start
|
||
|
||
def _mark(step: str) -> None:
|
||
nonlocal _t_step_start
|
||
now = time.perf_counter()
|
||
_timing[step] = now - _t_step_start
|
||
_t_step_start = now
|
||
|
||
# Stage A: model_hash + prompt_style plumbing. model_hash drives
|
||
# arch-aware style template lookup (Stage B) and per-model
|
||
# prompt_style fallback (Stage C). prompt_style drives the dual
|
||
# tag-mode / natlang-mode output split (Stage C). Both are read here
|
||
# so behavior keys can hang off them in subsequent phases without
|
||
# threading the wires again.
|
||
model_hash = (body.get("model_hash") or "").strip()
|
||
grounding = _build_grounding(model_hash)
|
||
arch = (grounding.get("architecture") or "").strip()
|
||
prompt_style = (
|
||
(body.get("prompt_style") or "").strip().lower()
|
||
or (grounding.get("prompt_style") or "").strip().lower()
|
||
or "tags"
|
||
)
|
||
|
||
# Stage B: deterministic style alias scan. When the user's request
|
||
# phrasing matches a curated alias ("make it anime", "switch to
|
||
# photography"), resolve to a canonical prompt-template id valid
|
||
# for the current model. The scan returns at most one hit;
|
||
# NEUTRAL_TEMPLATE_ID is a synthetic "no style" intent used to
|
||
# suppress build-mode default injection (Phase B4).
|
||
#
|
||
# The full scope lookup (architecture + family + model_name +
|
||
# model_hash) matters: many templates are model-scoped (Illustrious
|
||
# XL, JANKU NoobAI) or family-scoped (Pony) and won't resolve from
|
||
# architecture alone. Pass everything from grounding so the same
|
||
# template list available to the patch flow is what alias scan checks against.
|
||
#
|
||
# No model-call involved — this fires before inference, in cold
|
||
# boot, regardless of LLM availability. The model never sees the
|
||
# style decision; the server injects the `// Style:` section after
|
||
# post-pass filters run.
|
||
style_template = None
|
||
style_alias_hit = None
|
||
arch_prompts: list[dict] = []
|
||
# Style infrastructure runs in BOTH modes — templates are sectioned
|
||
# text with prose-leaning bodies and tag-shaped negs (e.g. z-image's
|
||
# `// 3D Styles - Cartoony\n<prose>\n\nNegative Prompt:\n<negs>`).
|
||
# `_build_style_section` populates both `tokens` (tag-mode comma
|
||
# output) and `body_text` (natlang prose output) so the same style
|
||
# injection path works for either output shape.
|
||
if arch:
|
||
try:
|
||
from . import prompts as _prompts
|
||
arch_prompts = _prompts.list_prompts(
|
||
architecture=arch,
|
||
family=grounding.get("family"),
|
||
model_name=(grounding.get("model_name")
|
||
or grounding.get("display_name")),
|
||
model_hash=model_hash,
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"ai-patch: prompts.list_prompts failed for arch=%r",
|
||
arch, exc_info=True,
|
||
)
|
||
valid_template_ids = {
|
||
(p.get("id") or "").strip()
|
||
for p in arch_prompts
|
||
if p.get("id")
|
||
}
|
||
try:
|
||
from . import style_search
|
||
style_alias_hit = style_search.style_alias_scan(
|
||
user_request, valid_template_ids,
|
||
)
|
||
except Exception:
|
||
logger.warning("ai-patch: style alias scan failed", exc_info=True)
|
||
if style_alias_hit and not style_alias_hit.get("is_neutral"):
|
||
template_id = style_alias_hit["template_id"]
|
||
if style_alias_hit.get("is_default"):
|
||
# Synthetic _default sentinel — resolve to whatever this
|
||
# model declares as its default_prompt_id. Lets users say
|
||
# "default style" / "use default" on any arch without
|
||
# per-arch alias curation.
|
||
resolved = (grounding.get("default_prompt_id") or "").strip()
|
||
if resolved:
|
||
style_template = _find_prompt_by_id(arch_prompts, resolved)
|
||
if style_template is None:
|
||
logger.warning(
|
||
"ai-patch: default_prompt_id=%r not in resolved "
|
||
"prompt list for arch=%r (scope mismatch?)",
|
||
resolved, arch,
|
||
)
|
||
else:
|
||
logger.info(
|
||
"ai-patch: _default style hit but model has no "
|
||
"default_prompt_id configured — skipping injection",
|
||
)
|
||
else:
|
||
style_template = _find_prompt_by_id(arch_prompts, template_id)
|
||
if style_template is None:
|
||
logger.warning(
|
||
"ai-patch: style template id=%r in alias seed but "
|
||
"not in resolved prompt list (scope mismatch?)",
|
||
template_id,
|
||
)
|
||
|
||
is_neutral_suppress = bool(
|
||
style_alias_hit and style_alias_hit.get("is_neutral")
|
||
)
|
||
|
||
# Fallback: name word-overlap match. When the alias scan didn't hit
|
||
# but the user's request shares significant words with a template's
|
||
# `name`, pick the best-scoring template across arch_prompts.
|
||
#
|
||
# Char-weighted overlap, not just word count: "hyperrealistic anime"
|
||
# should pick `Hyperrealistic` (14-char overlap) over `90s Anime`
|
||
# (5-char overlap) — both share exactly one word with the user's
|
||
# request, but the longer match is more specific and more likely
|
||
# the intended template. Word-count scoring tied 1:1 and picked
|
||
# whichever appeared first in arch_prompts.
|
||
#
|
||
# Filter words to length >= 4 to avoid noise (`a`, `the`, `to`, etc.
|
||
# don't carry intent; `cel` is short but rare enough to skip too).
|
||
#
|
||
# Minimum score threshold of 4.0 prevents weakly-matching templates
|
||
# from getting picked when the user's request doesn't actually
|
||
# describe any available template — e.g. "unreal engine 3d style"
|
||
# with no template named anything close. Falls through to grounding
|
||
# default-style or no style.
|
||
if (style_template is None
|
||
and not is_neutral_suppress
|
||
and arch_prompts):
|
||
user_words = set(re.findall(r"\w{4,}", user_request.lower()))
|
||
if user_words:
|
||
best = None
|
||
best_score = 0.0
|
||
for p in arch_prompts:
|
||
name = (p.get("name") or "").lower()
|
||
name_words = set(re.findall(r"\w{4,}", name))
|
||
if not name_words:
|
||
continue
|
||
overlap = name_words & user_words
|
||
if not overlap:
|
||
continue
|
||
overlap_chars = sum(len(w) for w in overlap)
|
||
name_chars = sum(len(w) for w in name_words)
|
||
if name_chars == 0:
|
||
continue
|
||
adj = (overlap_chars ** 2) / name_chars
|
||
if adj > best_score:
|
||
best = p
|
||
best_score = adj
|
||
if best and best_score >= 4.0:
|
||
style_template = best
|
||
dbg.info(
|
||
"ai-patch[%s] style matched by name overlap: "
|
||
"id=%s name=%r score=%.2f",
|
||
request_id,
|
||
best.get("id"),
|
||
best.get("name"),
|
||
best_score,
|
||
)
|
||
elif best:
|
||
dbg.info(
|
||
"ai-patch[%s] style name overlap below threshold: "
|
||
"best=%r score=%.2f (cutoff=4.0); falling through",
|
||
request_id, best.get("name"), best_score,
|
||
)
|
||
|
||
# Stage B4: build-mode default style injection. When the user is on
|
||
# a standalone main PromptChain node (no children, output not
|
||
# feeding another PromptChain) AND has an empty editor AND didn't
|
||
# name a specific style AND didn't say "no style", auto-seed the
|
||
# model's default_prompt_id as the style — fresh nodes get the
|
||
# canonical default for their checkpoint.
|
||
#
|
||
# Skip explicitly when:
|
||
# - Frontend says is_standalone_main=False (child prompt or chain
|
||
# parent — styles belong on the final compiled prompt only).
|
||
# - node_prompt is non-empty (this isn't a fresh node).
|
||
# - User already triggered an alias hit (handled above).
|
||
# - Name fuzzy-match resolved a template (handled above).
|
||
# - User triggered _neutral ("no style please") — suppression.
|
||
# - No default_prompt_id configured for this checkpoint.
|
||
is_standalone_main = bool(body.get("is_standalone_main"))
|
||
if (style_template is None
|
||
and is_standalone_main
|
||
and not node_prompt
|
||
and not is_neutral_suppress
|
||
and arch_prompts):
|
||
default_id = (grounding.get("default_prompt_id") or "").strip()
|
||
if default_id:
|
||
style_template = _find_prompt_by_id(arch_prompts, default_id)
|
||
if style_template is not None:
|
||
dbg.info(
|
||
"ai-patch[%s] build-mode default style auto-seeded: "
|
||
"id=%s (standalone main with empty prompt)",
|
||
request_id, default_id,
|
||
)
|
||
|
||
# Server-side outfit pre-resolution. Two deterministic passes
|
||
# mutate the bio's default_outfit before the LLM sees it:
|
||
# 1. Color swaps — "orange leotard" replaces the bio's leotard
|
||
# slot color; original phrase recorded as displaced.
|
||
# 2. Modifier clears — [APPLIES] modifiers like `barefoot` with
|
||
# clears=[footwear, legwear] drop matching slots; phrases
|
||
# recorded as displaced.
|
||
# Smaller backing models (qwen3-vl:8b-instruct) often skip these
|
||
# rules; doing them in code makes the contracts self-enforcing
|
||
# regardless of model capability.
|
||
bios = _resolve_color_swaps(bios, user_request)
|
||
bios = _resolve_modifier_clears(bios, user_request)
|
||
_mark("setup")
|
||
|
||
config = _load_config()
|
||
provider = body.get("provider") or config.get("provider")
|
||
if not provider:
|
||
return error_response("no AI provider configured", 400)
|
||
|
||
def _status(msg: str) -> None:
|
||
_emit(request_id, "status", content=msg)
|
||
|
||
# Pre-flight: cold-load detection. If Ollama is the local provider
|
||
# and the target model isn't currently in VRAM, surface a 'Loading
|
||
# model' status and force the load synchronously before the first
|
||
# inference call. Without this, the user sees 'Thinking about
|
||
# request' for the entire 10-30s VRAM populate, which misattributes
|
||
# the wait to the model itself.
|
||
if provider == "local":
|
||
local = config.get("local") or {}
|
||
local_base_url = (local.get("base_url") or "").strip().rstrip("/")
|
||
local_model = (local.get("model") or "").strip()
|
||
if local_base_url and local_model:
|
||
ollama_root = _ollama_root(local_base_url)
|
||
if (await _is_ollama(ollama_root)
|
||
and not await _is_ollama_model_loaded(ollama_root, local_model)):
|
||
_status("Loading model")
|
||
await _warmup_ollama_model(ollama_root, local_model)
|
||
_mark("warmup")
|
||
|
||
# Step A: decompose the user request into atomic sub-intents (LLM
|
||
# call). Failure falls back to a single-intent list. This is a
|
||
# separate Qwen call from the main pick step — adds ~5-15s latency
|
||
# but makes per-intent retrieval focused, dramatically improving
|
||
# tag-wiki RAG coverage on multi-intent prompts.
|
||
#
|
||
# Skipped in natlang mode — `_patch_user_message_natlang` doesn't
|
||
# surface sub_intents to the model (they're tag-shaped). The full
|
||
# decompose round-trip is wasted work in prose mode.
|
||
if prompt_style == "natural":
|
||
sub_intents = [{"text": user_request, "section": ""}]
|
||
else:
|
||
_status("Thinking about request")
|
||
# Decompose ONLY the current turn's message, not the multi-turn
|
||
# extract. `latest_user_text` (the multi-turn _extract_recent_text)
|
||
# was used here briefly to recover intents the agent's distilled
|
||
# paraphrase dropped — but it concatenates the last 6 messages,
|
||
# so prior-turn intents (cyberpunk, thunder, cowboy outfit, etc.)
|
||
# leak into the current turn's decompose and the patch flow
|
||
# treats them as fresh user intent. `current_user_text` is the
|
||
# most recent user message verbatim — exactly the right scope.
|
||
# Falls back to user_request when current_user_text is missing
|
||
# or trivially short (bare 'yes' / 'ok' confirmations where the
|
||
# agent's distilled request carries the actual intent).
|
||
decompose_input = current_user_text
|
||
if not decompose_input or len(decompose_input.strip()) < 10:
|
||
decompose_input = user_request
|
||
sub_intents = await _decompose_user_request(
|
||
request_id, provider, config, decompose_input
|
||
)
|
||
sub_intents = _filter_franchise_setting_style(
|
||
sub_intents, decompose_input, request_id,
|
||
)
|
||
if sub_intents and len(sub_intents) > 1:
|
||
dbg.info(
|
||
"ai-patch[%s] decomposed into %d sub-intents:\n%s",
|
||
request_id, len(sub_intents),
|
||
"\n".join(f" [{si['section']}] {si['text']}" for si in sub_intents),
|
||
)
|
||
_mark("decompose")
|
||
|
||
# Outfit-borrow attribution (Tier 2 of outfit resolution). When the
|
||
# agent dispatched a donor character via character_queries (e.g.
|
||
# "cammy in chun-li's outfit" → queries=[cammy_white, chun-li]),
|
||
# both bios load. Without this pass, multi_char fires and the model
|
||
# emits 2girls. _resolve_bio_roles detects the donor (no character:
|
||
# intent + possessive mention in an outfit:/strip: intent), copies
|
||
# its outfit to the subject's user_requested_outfit, and drops the
|
||
# donor from the bios list.
|
||
bios = _resolve_bio_roles(
|
||
bios, sub_intents or [], request_id, user_request=user_request,
|
||
)
|
||
|
||
# MODIFY mode: user is asking to alter the existing outfit, not
|
||
# replace it with a stock one ('bikini version of the maid', 'make
|
||
# it sexier', etc.). Skip tier-3 DB lookup (no auto-attach) and
|
||
# node_prompt rewrite (no pre-resolved body). Always set the strip
|
||
# flag so the bio.default_outfit doesn't pollute patch_user. A
|
||
# modify_outfit_hint string is built and surfaced to the patch
|
||
# model as an explicit reasoning task.
|
||
modify_intent = _detect_modify_outfit_intent(current_user_text)
|
||
modify_outfit_hint = ""
|
||
if modify_intent:
|
||
existing_outfit_match = _NODE_OUTFIT_HEADER_RE.search(node_prompt or "")
|
||
existing_name = existing_outfit_match.group(1).strip() if existing_outfit_match else ""
|
||
# Pull the existing outfit body lines straight out of node_prompt
|
||
# so the model has the precise base to modify.
|
||
existing_body = ""
|
||
if existing_outfit_match:
|
||
tail = (node_prompt or "")[existing_outfit_match.end():]
|
||
body_lines = []
|
||
for line in tail.lstrip("\n").splitlines():
|
||
stripped = line.strip()
|
||
if stripped.startswith("//"):
|
||
break
|
||
if re.match(r"^\s*Negative\s+Prompt\s*:", line, re.IGNORECASE):
|
||
break
|
||
if stripped:
|
||
body_lines.append(stripped)
|
||
existing_body = ", ".join(body_lines)
|
||
# Slot-aware classification: split the existing outfit's tokens
|
||
# into (body_garments, preserve_others). Body garments are
|
||
# what the modifier replaces; the rest must survive verbatim.
|
||
# When the existing outfit matches a generic_outfits DB entry,
|
||
# use the DB's slot decomposition — it lets us exclude tokens
|
||
# from the `modifiers` slot. Modifiers are descriptive (e.g.
|
||
# frilly_apron describes how the apron looks), not separate
|
||
# physical items; surfacing them as preserve tokens leads the
|
||
# model to emit both the base item AND its modifier-form, which
|
||
# render as conflicting layers (frilly_apron renders as a
|
||
# dress-shape over a bikini). Falls back to suffix-matching on
|
||
# raw tokens for custom outfits the DB doesn't know about.
|
||
body_tokens = [t.strip() for t in existing_body.split(",") if t.strip()]
|
||
from . import tag_builder as _tb_modify
|
||
db_outfit = _tb_modify.find_generic_outfit(existing_name) if existing_name else None
|
||
if db_outfit and db_outfit.get("slots"):
|
||
body_garments: list[str] = []
|
||
preserved_tokens: list[str] = []
|
||
db_phrases: set[str] = set()
|
||
for slot in db_outfit["slots"]:
|
||
slot_kind = (slot.get("slot") or "").strip().lower()
|
||
item = (slot.get("item") or "").strip().lower()
|
||
phrase = (slot.get("source_phrase") or "").strip()
|
||
if not phrase:
|
||
continue
|
||
db_phrases.add(phrase.lower())
|
||
if slot_kind == "modifiers":
|
||
continue
|
||
if item in set(_BODY_GARMENT_SUFFIXES):
|
||
body_garments.append(phrase)
|
||
else:
|
||
preserved_tokens.append(phrase)
|
||
# Catch user-added tokens not in the DB seed — classify via
|
||
# suffix fallback so they don't silently disappear.
|
||
for t in body_tokens:
|
||
bare = re.sub(r"\s*:\s*[\d.]+\s*\)?\s*$", "", t.strip("()").strip()).strip().lower()
|
||
if bare in db_phrases:
|
||
continue
|
||
if _is_body_garment_token(t):
|
||
body_garments.append(t)
|
||
else:
|
||
preserved_tokens.append(t)
|
||
else:
|
||
body_garments, preserved_tokens = _classify_outfit_tokens_for_modify(body_tokens)
|
||
preserve_list = ", ".join(preserved_tokens) if preserved_tokens else "(none)"
|
||
replace_list = ", ".join(body_garments) if body_garments else "(none — outfit has no body garment to swap)"
|
||
modifier_phrase = modify_intent["phrase"]
|
||
# Extract the principal modifier noun from the phrase (best-effort).
|
||
# 'make it a bikini version' -> 'bikini'. 'goth variant of' -> 'goth'.
|
||
# Used to make the replacement instruction concrete.
|
||
principal_match = re.search(
|
||
r"\b(bikini|skimpy|topless|nude|naked|casual|formal|fancy|sexy|"
|
||
r"kinky|slutty|cute|edgy|gothic|goth|punk|tomboy|girly|lewd|"
|
||
r"alternate|alt|leather|latex|wet|sheer|transparent|short|"
|
||
r"shorter|longer|tighter|looser|sexier|cuter|edgier|skimpier|"
|
||
r"\w{3,}er)\b",
|
||
modifier_phrase, re.IGNORECASE,
|
||
)
|
||
principal_modifier = (
|
||
principal_match.group(1).lower() if principal_match else "modified"
|
||
)
|
||
# Build a concrete replacement-tag suggestion list keyed off the
|
||
# principal modifier. Concrete suggestions reduce the model's
|
||
# ambiguity tax — it stops asking 'what should I emit' and just
|
||
# picks from the list.
|
||
replacement_examples = {
|
||
"bikini": "bikini_top, bikini_bottom, OR a single bikini token "
|
||
"themed to the base aesthetic (e.g., frilly_bikini, "
|
||
"black_bikini with maid frills, white_lace_bikini)",
|
||
"topless": "(drop the body garment with no replacement — "
|
||
"topless is the rare case where REMOVE means just "
|
||
"remove)",
|
||
"nude": "(drop the body garment with no replacement)",
|
||
"naked": "(drop the body garment with no replacement)",
|
||
"leather": "leather_dress, leather_pants, leather_jacket, OR "
|
||
"the leather equivalent of the base body garment",
|
||
"latex": "latex_dress, latex_bodysuit, OR latex equivalent",
|
||
"goth": "black_dress, gothic_dress, dark_lace, OR dark "
|
||
"version of the base body garment",
|
||
"casual": "t-shirt, jeans, OR casual equivalents",
|
||
"formal": "evening_dress, ballgown, suit, OR formal equivalents",
|
||
}
|
||
replacement_hint = replacement_examples.get(
|
||
principal_modifier,
|
||
f"a {principal_modifier}-themed version of the base body garment "
|
||
f"(use Danbooru tag forms, underscored, lowercase)",
|
||
)
|
||
is_topless_class = principal_modifier in {"topless", "nude", "naked"}
|
||
modify_outfit_hint = (
|
||
f"OUTFIT MODIFY MODE — apply a delta to the existing outfit\n\n"
|
||
f"The user is modifying the existing // Outfit. Your job:\n"
|
||
f" (1) KEEP the base outfit's accessories/headwear/footwear "
|
||
f"verbatim.\n"
|
||
f" (2) {'REMOVE' if is_topless_class else 'REPLACE'} the body "
|
||
f"garment{'(s)' if not is_topless_class else ''} listed below "
|
||
f"{'(no replacement — modifier means strip)' if is_topless_class else 'with the modifier-themed version'}.\n"
|
||
f" (3) Keep the base aesthetic — color palette, "
|
||
f"frills/lace/ribbons if present.\n\n"
|
||
f" Base outfit name: {existing_name or '(none)'}\n"
|
||
f" User said: \"{modifier_phrase}\"\n"
|
||
f" Principal modifier: {principal_modifier}\n\n"
|
||
f"KEEP (verbatim — must appear in your output // Outfit body):\n"
|
||
f" {preserve_list}\n\n"
|
||
f"{'REMOVE' if is_topless_class else 'REPLACE'} (these are the "
|
||
f"body garments {'to drop' if is_topless_class else 'to swap'}):\n"
|
||
f" {replace_list}\n\n"
|
||
+ (
|
||
""
|
||
if is_topless_class
|
||
else f"ADD (the new body garment to take their place):\n"
|
||
f" {replacement_hint}\n\n"
|
||
) +
|
||
f"OUTPUT FORMAT for // Outfit body — emit a comma-separated tag "
|
||
f"line containing:\n"
|
||
f" - All KEEP tokens listed above (verbatim)\n"
|
||
+ (
|
||
f" - (no body garment — '{principal_modifier}' means the body is bare)\n"
|
||
if is_topless_class else
|
||
f" - At least one ADD token replacing the body garment\n"
|
||
) +
|
||
f"\n"
|
||
f"CRITICAL: do NOT omit any KEEP token. "
|
||
+ (
|
||
f"Do NOT add a body garment — '{principal_modifier}' "
|
||
f"explicitly means there isn't one."
|
||
if is_topless_class else
|
||
f"You MUST add at least one body-garment-shaped token "
|
||
f"to replace what was removed — the user wants the outfit "
|
||
f"transformed, not stripped."
|
||
)
|
||
)
|
||
dbg.info(
|
||
"ai-patch[%s] MODIFY mode: phrase=%r base=%r preserve=%d replace=%d",
|
||
request_id, modify_intent["phrase"], existing_name or "(none)",
|
||
len(preserved_tokens), len(body_garments),
|
||
)
|
||
# Inject [strip] so user_strips_outfit fires; suppresses bio's
|
||
# default-outfit slot list in patch_user.
|
||
sub_intents = _ensure_strip_for_named_outfit_phrase(
|
||
sub_intents, current_user_text, request_id,
|
||
)
|
||
if not any((si.get("section") or "").lower() == "strip"
|
||
for si in (sub_intents or [])):
|
||
sub_intents = list(sub_intents or [])
|
||
sub_intents.append({"section": "strip", "text": "modify-mode"})
|
||
else:
|
||
# Outfit resolution Tier 3: generic-outfit KB lookup. When the user
|
||
# named a curated archetype (cowboy/maid/pirate/...) and tier 1+2
|
||
# haven't already attached a user_requested_outfit, pull the slot
|
||
# decomposition from tag_builder.generic_outfits and attach it to
|
||
# the bio. Avoids canonical_resolver pulling scene-adjacent tags
|
||
# (horse, gun) from "cowboy outfit" via bge embedding neighbors.
|
||
sub_intents, bios = _apply_generic_outfit_to_bios(
|
||
sub_intents, bios, current_user_text, request_id,
|
||
)
|
||
|
||
# Tier 4 strip-flag fallback: when the user named an outfit aesthetic
|
||
# the generic-outfits DB doesn't recognize ('in a steampunk-cyberpunk
|
||
# hybrid outfit'), tier-3 scan returns no match — but the user clearly
|
||
# wants a fresh outfit, not bio defaults stacked with the LLM-vibed
|
||
# additions. Inject a [strip] intent so user_strips_outfit fires and
|
||
# bio.default_outfit slots get suppressed in patch_user. The model
|
||
# then vibes the aesthetic from world knowledge alone.
|
||
sub_intents = _ensure_strip_for_named_outfit_phrase(
|
||
sub_intents, current_user_text, request_id,
|
||
)
|
||
|
||
# Step B: build the bio_known + modifier_canon sets so retrieval
|
||
# doesn't surface tags already covered. Also detect which modifiers
|
||
# are firing so retrieval can apply modifier-conflict filtering
|
||
# (e.g. drop gestures-group tags when presenting_foot is firing).
|
||
all_modifiers = _load_slot_modifiers()
|
||
bio_known: set[str] = set()
|
||
for b in bios or []:
|
||
for t in (b.get("base_tags") or "").split(","):
|
||
t = t.strip().lstrip("(").split(":", 1)[0]
|
||
if t:
|
||
bio_known.add(t.lower())
|
||
modifier_canon = {m["canonical_tag"].lower() for m in all_modifiers}
|
||
detected_modifiers = _detect_modifiers_in_text(user_request)
|
||
applies_by_tag = {d["canonical_tag"]: d for d in detected_modifiers}
|
||
|
||
# Step C: per-sub-intent retrieval. Two layers, additive:
|
||
#
|
||
# C1. Canonical resolver — LLM proposes Danbooru tags per sub-intent,
|
||
# validates against danbooru_tags + body-part variant swap.
|
||
# Solves the bge bare-form ranking bias where compound tags
|
||
# (`sitting_on_arm`) outrank bare canonicals (`sitting`) and
|
||
# the model picks user-verbatim phrasal forms over canonical.
|
||
# ~86% resolution rate on the test corpus vs ~18% for bge alone.
|
||
# Skipped sections: character (bio system handles), clear
|
||
# (no tag), style (template path).
|
||
#
|
||
# C2. bge / alias / literal-anchor retrieval (legacy) — fallback
|
||
# when the resolver returns empty or section is out-of-scope.
|
||
# Lands in "Related candidates" block below resolver hits.
|
||
#
|
||
# Skipped in natlang mode — tag candidates are tag-shaped and the
|
||
# natlang user message doesn't include them.
|
||
if prompt_style == "natural":
|
||
tag_candidates = []
|
||
else:
|
||
# Filter out intents that were pre-resolved upstream (tier-3
|
||
# generic-outfit lookup attaches the curated outfit directly to
|
||
# the bio; running canonical_resolver / bge on the same intent
|
||
# text would pull scene-adjacent tags like horse and gun back
|
||
# into the candidate pool).
|
||
retrieval_intents = [
|
||
si for si in (sub_intents or []) if not si.get("pre_resolved")
|
||
]
|
||
from . import canonical_resolver
|
||
resolved_candidates = await canonical_resolver.resolve_intents_parallel(
|
||
retrieval_intents, request_id, on_status=_status,
|
||
)
|
||
# Tags already covered by bio / modifier sets — surfacing them
|
||
# in the candidate menu is noise. Resolver has no view of those
|
||
# sets, so filter here.
|
||
resolved_candidates = [
|
||
c for c in resolved_candidates
|
||
if (c.get("tag") or "").lower() not in bio_known
|
||
and (c.get("tag") or "").lower() not in modifier_canon
|
||
]
|
||
|
||
_status("Analyzing tag database")
|
||
bge_candidates = _retrieve_tag_candidates(
|
||
retrieval_intents, bio_known, modifier_canon,
|
||
applies_by_tag=applies_by_tag,
|
||
on_status=_status,
|
||
)
|
||
|
||
# Merge: resolved first (anchor block), then bge (filtered to
|
||
# skip tags the resolver already surfaced).
|
||
resolved_tags = {(c.get("tag") or "").lower() for c in resolved_candidates}
|
||
merged_bge = [
|
||
c for c in bge_candidates
|
||
if (c.get("tag") or "").lower() not in resolved_tags
|
||
]
|
||
tag_candidates = list(resolved_candidates) + merged_bge
|
||
|
||
if tag_candidates:
|
||
dbg.info(
|
||
"ai-patch[%s] %d tag candidates "
|
||
"(resolver=%d, bge=%d): %s",
|
||
request_id, len(tag_candidates),
|
||
len(resolved_candidates), len(merged_bge),
|
||
", ".join(
|
||
f"{c['tag']}({c['matched_via']}<-{c['matched_intent']!r})"
|
||
for c in tag_candidates[:10]
|
||
),
|
||
)
|
||
|
||
# Rewrite sub_intents text → resolved canonicals. The patch user
|
||
# message's "Decomposed visual intents" block defaults to showing
|
||
# the user's literal phrasing ([pose] focus_on_feet). Combined
|
||
# with the user_request line and the literal phrasing pre-
|
||
# underscored by the chat agent, that put `focus_on_feet` in
|
||
# front of the patch model in three places vs `foot_focus` in
|
||
# one, and the model echoed the literal — trace-check then
|
||
# dropped it as untraceable, so the canonical never landed.
|
||
# Replace the sub-intent text with its resolved canonical(s) so
|
||
# the decomposed-intents block reinforces the canonical instead
|
||
# of the literal. Sub-intents whose phrase didn't resolve keep
|
||
# their original text.
|
||
phrase_to_canonicals: dict[str, list[str]] = {}
|
||
for c in resolved_candidates:
|
||
intent = c.get("matched_intent") or ""
|
||
tag = c.get("tag") or ""
|
||
if intent and tag:
|
||
phrase_to_canonicals.setdefault(intent, []).append(tag)
|
||
if phrase_to_canonicals and sub_intents:
|
||
rewritten: list[dict] = []
|
||
for si in sub_intents:
|
||
text = (si.get("text") or "").strip()
|
||
canonicals = phrase_to_canonicals.get(text)
|
||
if canonicals:
|
||
rewritten.append({**si, "text": ", ".join(canonicals)})
|
||
else:
|
||
rewritten.append(si)
|
||
sub_intents = rewritten
|
||
_mark("retrieve")
|
||
|
||
# Server-side outfit-section delta. When a bio's user_requested_outfit
|
||
# differs from node_prompt's existing // Outfit section, rewrite the
|
||
# section so patch-mode preservation honors the swap. Without this,
|
||
# the model sees both 'preserve // Outfit: <old>' and 'use bio's
|
||
# user_requested_outfit (<new>)' and chooses the preserved-old path,
|
||
# causing the swap to silently no-op on multi-turn flows. Skipped
|
||
# in MODIFY mode (no user_requested_outfit was attached; the model
|
||
# is reasoning about the delta, not consuming a pre-resolved one).
|
||
if not modify_intent:
|
||
node_prompt = _rewrite_node_prompt_outfit_for_user_requested(
|
||
node_prompt, bios, request_id,
|
||
)
|
||
|
||
# Server-side handling for `[clear] X` intents. Decompose emits these
|
||
# for 'reset scene' / 'remove pose' / etc., but the patch model
|
||
# would preserve the targeted section per patch-mode rules. Strip
|
||
# the section from node_prompt server-side so 'clear' actually
|
||
# clears.
|
||
node_prompt = _apply_clear_intents_to_node_prompt(
|
||
node_prompt, sub_intents, request_id,
|
||
)
|
||
|
||
system = _patch_system_prompt(
|
||
bios=bios, has_node_prompt=bool(node_prompt),
|
||
prompt_style=prompt_style,
|
||
)
|
||
user = _patch_user_message(
|
||
node_prompt, user_request, bios=bios,
|
||
sub_intents=sub_intents, tag_candidates=tag_candidates,
|
||
prompt_style=prompt_style,
|
||
modify_outfit_hint=modify_outfit_hint,
|
||
)
|
||
|
||
dbg.info(
|
||
"ai-patch[%s] request: provider=%s node_chars=%d req_chars=%d bios=%d "
|
||
"arch=%s prompt_style=%s style_alias=%s",
|
||
request_id, provider, len(node_prompt), len(user_request), len(bios),
|
||
arch or "(none)", prompt_style,
|
||
(style_alias_hit and style_alias_hit.get("template_id")) or "(none)",
|
||
)
|
||
_dump(request_id, "patch_system", system)
|
||
_dump(request_id, "patch_user", user)
|
||
_mark("build_prompt")
|
||
|
||
# ── Natlang render-mode short-circuit ───────────────────────────
|
||
# Replace the patch-mode prose-surgery model call with deterministic
|
||
# server-side composition. Server owns the structure (PromptState
|
||
# + slot data); model is bypassed entirely for natlang mode. The
|
||
# existing post-pass (style injection, neg merge, preserve_existing
|
||
# _negatives) runs unchanged on the rendered sections — those are
|
||
# section-level operations and don't care if the body came from a
|
||
# composer or a model.
|
||
if prompt_style == "natural":
|
||
return await _run_natlang_v2(
|
||
request_id, body, user_request, bios, node_prompt,
|
||
style_template, provider, config, _t_request_start, _timing,
|
||
_mark, _status, _dump, arch_prompts=arch_prompts,
|
||
)
|
||
|
||
_status("Building prompt")
|
||
# Up to 3 attempts. The retry exists for the "thought hard then
|
||
# emitted nothing" failure mode where reasoning_chars > 0 but body
|
||
# is empty — common to abliterated/thinking-mode variants. Each
|
||
# retry appends an output-forcing tail to the user message to nudge
|
||
# past the reasoning step.
|
||
max_attempts = 3
|
||
raw = ""
|
||
last_attempt = 0
|
||
for attempt in range(1, max_attempts + 1):
|
||
last_attempt = attempt
|
||
attempt_user = user
|
||
if attempt > 1:
|
||
_status(f"Retrying ({attempt}/{max_attempts})")
|
||
attempt_user = (
|
||
user
|
||
+ "\n\nOutput the prompt body now. "
|
||
"Do not reason. Begin with the first `// Section:` line."
|
||
)
|
||
# Reset reasoning counter per attempt so the failure-mode check
|
||
# only reflects this attempt, not stale state from an earlier one.
|
||
_request_reasoning_chars.pop(request_id, None)
|
||
try:
|
||
raw = await _run_generation(
|
||
request_id, provider, config, system, attempt_user, [],
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.exception("ai-patch[%s] provider failed", request_id)
|
||
return error_response(str(e) or "provider call failed", 500)
|
||
|
||
raw = (raw or "").strip()
|
||
reasoning_chars = _request_reasoning_chars.get(request_id, 0)
|
||
if raw:
|
||
break
|
||
# Empty body. If reasoning was non-empty, it's the thinking-only
|
||
# failure mode and a retry is justified. If reasoning was also
|
||
# empty, the model genuinely returned nothing — retrying with the
|
||
# same setup likely won't help, so bail.
|
||
if reasoning_chars <= 0:
|
||
logger.warning(
|
||
"ai-patch[%s] empty body and empty reasoning on attempt %d — not retrying",
|
||
request_id, attempt,
|
||
)
|
||
break
|
||
logger.info(
|
||
"ai-patch[%s] attempt %d: empty body, reasoning_chars=%d — will retry",
|
||
request_id, attempt, reasoning_chars,
|
||
)
|
||
|
||
if not raw:
|
||
reasoning_chars = _request_reasoning_chars.get(request_id, 0)
|
||
if reasoning_chars > 0:
|
||
return error_response(
|
||
"Model emitted only reasoning, no output. "
|
||
"Try a non-thinking model variant or `ollama stop` and reload.",
|
||
502,
|
||
)
|
||
return error_response("empty response from model", 502)
|
||
if last_attempt > 1:
|
||
logger.info("ai-patch[%s] succeeded on attempt %d/%d",
|
||
request_id, last_attempt, max_attempts)
|
||
|
||
_dump(request_id, "patch_raw", raw)
|
||
_mark("inference")
|
||
|
||
sections = _parse_sectioned_output(raw)
|
||
|
||
# Provenance set: tokens the user already had in node_prompt.
|
||
node_pos_set, _ = _node_prompt_token_sets(node_prompt)
|
||
|
||
# Backstop: drop any // section whose name isn't one of the schema-
|
||
# allowed ones AND contains no node_prompt tokens. Section-level —
|
||
# works in both modes because canonical headers (Character/Outfit/
|
||
# Pose/Style) match the prefix whitelist regardless of body shape.
|
||
sections = _filter_allowed_sections(sections, node_pos=node_pos_set)
|
||
|
||
# Character-swap enforcement: when sub_intents say the user wants
|
||
# to swap out the existing character, drop any // Character: <X>
|
||
# section whose canonical isn't in the current bios. The 8B patch
|
||
# model occasionally appends the new character's section instead
|
||
# of replacing the prior one — leaves two // Character blocks with
|
||
# the wrong identity tags applied to the swap target.
|
||
sections = _enforce_character_swap(
|
||
sections, bios, sub_intents, request_id,
|
||
)
|
||
|
||
if prompt_style != "natural":
|
||
# Tag-mode-only token-level filters. Each operates on the
|
||
# comma-split `tokens` list inside each section — meaningless on
|
||
# prose section bodies, where the body is one paragraph and
|
||
# comma-fragments aren't independently droppable concepts.
|
||
|
||
# Drop sections the user didn't ask for AND that weren't in the
|
||
# input node_prompt. Catches qwen3-vl:8b's habit of speculating
|
||
# // Pose/// Setting/// Quality on build-mode bare requests.
|
||
sections = _filter_unrequested_sections(
|
||
sections, sub_intents, node_prompt, bios, request_id,
|
||
)
|
||
|
||
# Outfit borrow: if a bio is marked _outfit_source_only, the
|
||
# patch model is supposed to rewrite // Outfit using that bio's
|
||
# slots. PATCH MODE preservation + OUTFIT HEADER rule fight the
|
||
# rename — model emits old header `// Outfit: Delta Red` with
|
||
# new body. Server-side rewrite makes the borrow deterministic.
|
||
sections = _apply_outfit_borrow_overwrite(sections, bios, request_id)
|
||
|
||
# Same-character-multi-outfit expansion: if bios has duplicate
|
||
# tag entries (same canonical character with different
|
||
# user_requested_outfit picks), duplicate the // Character
|
||
# section per instance with distinct outfit names. Runs BEFORE
|
||
# the outfit-auto-inject so the duplicates are in place when
|
||
# the outfit-auto-inject scans for missing outfits.
|
||
sections = _ensure_same_char_multi_outfit_sections(
|
||
sections, bios, request_id,
|
||
)
|
||
|
||
# Auto-inject missing outfits. Multi-char-build failure mode:
|
||
# qwen3-vl:8b emits both // Character sections but skips both
|
||
# // Outfit sections, leaving SDXL to freelance clothing. Run
|
||
# AFTER the outfit-borrow rewrite so the borrow case is already
|
||
# handled and we only inject for true subject characters.
|
||
sections = _ensure_bio_outfits_emitted(sections, bios, request_id)
|
||
|
||
sections = _enforce_applies_modifiers(sections, applies_by_tag, request_id)
|
||
|
||
# Modifier slot-clear post-pass. Runs AFTER
|
||
# _enforce_applies_modifiers (which adds the modifier canonical
|
||
# to the right section) so the drop-pass sees the modifier
|
||
# canonical-token already in place and won't drop it as a stray
|
||
# legwear/footwear fill. `wearing only X` strip semantics are
|
||
# handled by the patch system prompt directly — no server-side
|
||
# narrowing pass.
|
||
sections = _apply_modifier_clear_post_pass(
|
||
sections, user_request, bios, request_id,
|
||
)
|
||
sections = _apply_pose_anchor_override_post_pass(
|
||
sections, user_request, node_prompt, request_id,
|
||
)
|
||
|
||
displaced_modifiers = _resolve_slot_displacements(
|
||
user_request, node_prompt=node_prompt,
|
||
)
|
||
sections = _drop_displaced_modifiers(sections, displaced_modifiers, request_id)
|
||
|
||
sections = _resolve_posture_conflicts(
|
||
sections, user_request, node_prompt, request_id,
|
||
)
|
||
|
||
sections = _drop_untraceable_tokens(
|
||
sections, user_request, bios, _load_slot_modifiers(), request_id,
|
||
node_prompt=node_prompt,
|
||
)
|
||
|
||
sections = _drop_misplaced_tokens(sections, request_id)
|
||
|
||
sections = _restore_weighted_parens(sections)
|
||
|
||
sections = _enforce_default_outfit_negation(sections, bios)
|
||
|
||
# Multi-char structural reorder: pair each `// Outfit: <canon>`
|
||
# with its `// Character: <canon>` so the BREAK insertion
|
||
# below lands at correct chunk boundaries. The 8B patch model
|
||
# sometimes emits all character sections first then all outfit
|
||
# sections — without this, sagat's chunk pulls in ryu's outfit.
|
||
sections = _reorder_multi_char_sections(sections, bios, request_id)
|
||
|
||
# Multi-char deterministic composition: replace per-character
|
||
# subject counts with the canonical aggregate computed from
|
||
# bios (1boy + 1girl -> `1boy, 1girl`, not `2girls`), drop
|
||
# `solo` tokens that contradict multi-character composition,
|
||
# and insert `BREAK` chunk separators between character
|
||
# blocks. No-op for single-char output (bios < 2).
|
||
sections = _enforce_multi_char_composition(sections, bios, request_id)
|
||
|
||
# Dedup AFTER the composer: composer re-injects `(canon:1.1)`
|
||
# into character sections. If a swap left that token in negs,
|
||
# dedup must run after the re-injection to catch it.
|
||
sections = _dedupe_negatives_from_positives(sections)
|
||
|
||
# Belt-and-suspenders franchise strip — patch model echoes
|
||
# franchise names into // Setting / Scene and // Style from
|
||
# its world knowledge even after we franchise-strip the
|
||
# user_request line. Drop deterministically.
|
||
sections = _strip_franchise_tokens_from_scene_style(sections, request_id)
|
||
|
||
# Section-level: preserve user's existing negative tokens (negs are
|
||
# tag-shaped in both modes, so this works either way).
|
||
sections = _preserve_existing_negatives(sections, node_prompt)
|
||
|
||
# Character-swap cleanup: drop preserved negatives that match the
|
||
# PRIOR character's default-outfit slot phrases. Without this, a
|
||
# cammy -> mythra swap leaves cammy's `green leotard, red gloves,
|
||
# ...` negs persisting forever (auto-added by an earlier turn's
|
||
# default-outfit negation, no longer relevant). The new character's
|
||
# default negs were already added by `_enforce_default_outfit_negation`
|
||
# above so they survive this scrub.
|
||
sections = _scrub_prior_character_default_negs(
|
||
sections, bios, node_prompt, request_id,
|
||
)
|
||
|
||
# Stage B3/B5: server-side `// Style:` section injection + template
|
||
# negative merge. Section-level — works in both modes because the
|
||
# injected style section carries both `tokens` (for tag mode joined
|
||
# comma-output) and `body_text` (for natlang prose-shaped output).
|
||
if style_template:
|
||
new_style = _build_style_section(style_template)
|
||
if new_style:
|
||
sections = _replace_or_append_style_section(sections, new_style)
|
||
before_neg_count = sum(
|
||
len(s.get("tokens") or [])
|
||
for s in sections if s.get("is_negative")
|
||
)
|
||
sections = _merge_template_negatives(sections, style_template)
|
||
after_neg_count = sum(
|
||
len(s.get("tokens") or [])
|
||
for s in sections if s.get("is_negative")
|
||
)
|
||
dbg.info(
|
||
"ai-patch[%s] style injected: id=%s name=%s tokens=%d "
|
||
"matched_alias=%r negs_added=%d",
|
||
request_id,
|
||
style_template.get("id"),
|
||
style_template.get("name"),
|
||
len(new_style["tokens"]),
|
||
(style_alias_hit or {}).get("matched_alias"),
|
||
max(0, after_neg_count - before_neg_count),
|
||
)
|
||
|
||
# Note on // Style / // Quality preservation: the patch system
|
||
# prompt now allows the LLM to emit these sections directly and
|
||
# tells it to preserve them verbatim across patch turns. We no
|
||
# longer band-aid with `_preserve_existing_style_section` here —
|
||
# block-then-restore was a fragile pattern (when restore failed,
|
||
# content vanished silently). The LLM handles preservation as
|
||
# part of its patch-mode rule; server-side `_replace_or_append_
|
||
# style_section` above still OVERRIDES Style on alias / template
|
||
# hit, which is the correct deterministic concern.
|
||
|
||
tag_format = (body.get("tag_format") or "spaces").strip().lower()
|
||
if tag_format == "spaces":
|
||
for s in sections:
|
||
s["tokens"] = [_format_output(t, tag_format) for t in s["tokens"]]
|
||
for s in sections:
|
||
s["tokens"] = [_strip_articles_from_phrasal(t) for t in s["tokens"]]
|
||
|
||
output_text = "\n\n".join(
|
||
f"{s['header']}\n{', '.join(s['tokens'])}" for s in sections
|
||
)
|
||
|
||
full_sections = sections
|
||
sections = _drop_unchanged_positives(sections, node_prompt)
|
||
sections = _add_positive_removal_chips(sections, full_sections, node_prompt)
|
||
# `body_text` was populated by `_parse_sectioned_output` from the
|
||
# raw LLM output and never updated when post-passes mutated
|
||
# `tokens` (modifier-clear dropped `brown_boots`, strip narrowed,
|
||
# etc.). The frontend chip view prefers `body_text` for prose
|
||
# rendering when set — which leaks the LLM's pre-post-pass body
|
||
# into the diff display, showing dropped tokens that aren't
|
||
# actually in the final output. Tag-mode chips should always
|
||
# render from `tokens`; clearing `body_text` forces the
|
||
# frontend's fallback branch to fire.
|
||
for s in sections:
|
||
if not s.get("is_negative"):
|
||
s["body_text"] = ""
|
||
|
||
_mark("post")
|
||
|
||
dbg.info(
|
||
"ai-patch[%s] response: sections=%d tokens=%d output_chars=%d",
|
||
request_id, len(sections),
|
||
sum(len(s["tokens"]) for s in sections),
|
||
len(output_text),
|
||
)
|
||
|
||
total = time.perf_counter() - _t_request_start
|
||
dbg.info(
|
||
"ai-patch[%s] timing: setup=%.2fs warmup=%.2fs decompose=%.2fs "
|
||
"retrieve=%.2fs build_prompt=%.2fs inference=%.2fs post=%.2fs "
|
||
"total=%.2fs",
|
||
request_id,
|
||
_timing.get("setup", 0.0),
|
||
_timing.get("warmup", 0.0),
|
||
_timing.get("decompose", 0.0),
|
||
_timing.get("retrieve", 0.0),
|
||
_timing.get("build_prompt", 0.0),
|
||
_timing.get("inference", 0.0),
|
||
_timing.get("post", 0.0),
|
||
total,
|
||
)
|
||
|
||
# Phase 1 of render-flow plan: accept and echo prompt_state. The
|
||
# parser/composer/render mutations land in subsequent phases. For now
|
||
# the frontend can persist what it sends; nothing structural changes.
|
||
incoming_prompt_state = body.get("prompt_state") or None
|
||
|
||
return web.json_response({
|
||
"request_id": request_id,
|
||
"output_text": output_text,
|
||
"sections": sections,
|
||
"raw": raw,
|
||
"prompt_state": incoming_prompt_state,
|
||
})
|