Files
EricRollei-Local_LLM_Prompt…/wildcard_support.py
T
Eric HissandCopilot 373ea8bda3 v2.2.0: Prompt Builder nodes, wildcard review tool, docs refresh
- Add Wildcard Prompt Composer and Wildcard Character Composer builder nodes (core/builder, web/prompt_builder.js, builders/, vp wildcard lists)

- Add browser-based wildcard review tool (tools/wildcard_review) with manifest-based undo

- Add wildcard catalog/curation, surreal compiler/lint/scene, profile token budget

- README: document all 18 nodes, bump to v2.2.0, fix stale node/platform lists; CHANGELOG 2.2.0

- Rewrite NODE_COMPARISON for 18 nodes; fix QUICKSTART (dead test_setup.py ref, menu path) and QUICK_REFERENCE node list

- gitignore review manifests and CLAUDE.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-28 20:36:44 -07:00

323 lines
13 KiB
Python

"""
Wildcard support shared by every enhancer node.
One place for: the two optional inputs every node appends, the seed convention,
the IS_CHANGED rule, the prompt-database provider, and the call into core/wildcards.
Where wildcard packs live (highest priority first; the first folder that has a key wins):
1. a node's extra_paths input
2. ComfyUI/models/wildcards the shared home for packs - drop them here
3. any `wildcards:` entry in extra_model_paths.yaml, e.g. A:/Models/wildcards
4. "wildcard_paths" in this package's config.json
5. this package's own wildcards/ folder the lists that ship with the nodes
Inside any of those folders (see core/wildcards/library.py):
_nsfw/<pack>/... NSFW lists (explicit), used only when a node's nsfw input is explicit
_generated/... lists written by the tools, always searched
_other/ any other top-level folder starting with "_" is ignored
(_duplicates, _quarantine, ...)
Seed convention (wildcard_seed input):
-1 fresh roll on every run (the node is never cached by ComfyUI)
>= 0 reproducible: same seed + same wildcard files = same picks
Author: Eric Hiss (GitHub: EricRollei)
License: Dual License (Non-Commercial and Commercial Use). See LICENSE.
"""
import os
import random
import time
from typing import Any, Dict, Iterable, List, Optional, Tuple
from .core.wildcards import NSFW_LEVELS, VIOLENCE_LEVELS, Resolution, WildcardEngine, get_engine, prompt_block_reason
DB_CANDIDATE_LIMIT = 200
WILDCARD_SEED_INPUT = ("INT", {
"default": -1,
"min": -1,
"max": 0x7FFFFFFF,
"tooltip": (
"Seed for wildcards: __name__, {a|b|c}, __objects[tag AND tag]__.\n"
"-1 = fresh roll every run. 0 or more = reproducible picks."
),
})
WILDCARD_LLM_BLOCK_INPUT = ("BOOLEAN", {
"default": True,
"tooltip": (
"Tell the LLM which elements were picked at random so it keeps them:\n"
"concepts keep their meaning, __=verbatim__ picks stay word for word.\n"
"Off = the LLM only sees the resolved text."
),
})
WILDCARD_INSTRUCTIONS_INPUT = ("STRING", {
"forceInput": True,
"tooltip": (
"Connect the llm_instructions output of a Wildcard Processor or Semantic Variation node here\n"
"when the wildcards were resolved before this node. The LLM is then told which elements were\n"
"picked at random, and verbatim picks are checked in its answer, exactly as if this node had\n"
"resolved them itself."
),
})
def comfy_wildcard_dirs() -> List[str]:
"""
ComfyUI's wildcard folders: models/wildcards first, then whatever extra_model_paths.yaml
registers under `wildcards:`. models/wildcards is registered with folder_paths under the
name "wildcards" so other tools that ask ComfyUI for that folder find the same place.
Only folders that exist are returned. Outside ComfyUI this returns [].
"""
try:
import folder_paths
except ImportError:
return []
default_dir = os.path.join(folder_paths.models_dir, "wildcards")
try:
folder_paths.add_model_folder_path("wildcards", default_dir)
except Exception:
pass
ordered: List[str] = [default_dir]
try:
ordered.extend(folder_paths.get_folder_paths("wildcards"))
except Exception:
pass
existing: List[str] = []
for path in ordered:
absolute = os.path.abspath(path)
if os.path.isdir(absolute) and absolute not in existing:
existing.append(absolute)
return existing
WILDCARD_NSFW_INPUT = (list(NSFW_LEVELS), {
"default": NSFW_LEVELS[0],
"tooltip": (
"How far wildcard picks may go (a word match on each entry and on list names, no model).\n"
"off = SFW: no suggestive, nude or explicit lists or entries\n"
"suggestive = also lingerie, bikinis, sexy, seductive ...\n"
"nudity = also nude, naked, topless ...\n"
"explicit = everything, including lists under _nsfw/\n"
"Fix a wrong rating in _catalog_user.yaml (nsfw: on a list, ratings: on entries). Entries pairing\n"
"minor-related and sexual terms are removed at every level (config.json \"content_filter\")."
),
})
WILDCARD_VIOLENCE_INPUT = (list(VIOLENCE_LEVELS), {
"default": VIOLENCE_LEVELS[0],
"tooltip": (
"Hold back violent wildcard picks (a word match on each entry and on list names, no model).\n"
"off = nothing held back\n"
"no gore = blood and gore, torture, mutilation, corpses\n"
"no gore or violent acts = also murder, execution, stabbing, strangling, suicide ...\n"
"no violence or weapons = also guns, swords, knives, bombs, battle, war\n"
"Only wildcard picks are filtered; text you type is left as is."
),
})
def wildcard_nsfw_input() -> Dict[str, Any]:
"""
The nsfw and violence_filter inputs (violence_filter after nsfw). Kept apart from wildcard_optional_inputs() on purpose: nodes append it at the
very END of their optional section, after anything added since, so saved widget order is unchanged.
"""
return {"nsfw": WILDCARD_NSFW_INPUT, "violence_filter": WILDCARD_VIOLENCE_INPUT}
CATALOG_CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache", "catalogs")
def get_wildcard_engine(extra_paths: Optional[Iterable[str]] = None) -> WildcardEngine:
"""
The engine every node uses: node extra paths, then ComfyUI's wildcard folders, config, shipped lists.
The catalog and the user's curation (_catalog_user.yaml) are attached on first use; the catalog of
new or changed lists is built in a background thread (config.json "wildcards": {"catalog": false} = off).
"""
engine = get_engine(extra_paths, host_paths=comfy_wildcard_dirs())
engine.enable_curation(CATALOG_CACHE_DIR)
return engine
def wildcard_optional_inputs() -> Dict[str, Any]:
"""The inputs a node appends at the END of its 'optional' section, in this order."""
return {
"wildcard_seed": WILDCARD_SEED_INPUT,
"wildcard_llm_block": WILDCARD_LLM_BLOCK_INPUT,
"wildcard_instructions": WILDCARD_INSTRUCTIONS_INPUT,
}
def normalise_seed(wildcard_seed: Optional[int], offset: int = 0) -> Optional[int]:
"""-1 / None -> None (fresh roll). Otherwise seed + offset, so variation N differs from variation 0."""
if wildcard_seed is None:
return None
try:
value = int(wildcard_seed)
except (TypeError, ValueError):
return None
if value < 0:
return None
return value + int(offset)
def is_changed_token(wildcard_seed: Optional[int]) -> Any:
"""
Value for a node's IS_CHANGED. A fresh-roll seed must defeat ComfyUI's cache, or the
wildcards would never re-roll while the other inputs stay the same.
"""
if normalise_seed(wildcard_seed) is None:
return float(time.time())
return int(wildcard_seed)
def database_provider(argument: str, text_so_far: str, rng: random.Random) -> Optional[str]:
"""
__db:query__ -> one prompt from the prompt database matching the FTS query.
Candidates come back in a stable order and are picked with the run's own RNG, so the
draw is reproducible for a given seed (SQLite's RANDOM() would not be).
"""
from .prompt_database import PromptDatabase
results, _total = PromptDatabase().search(query=argument, limit=DB_CANDIDATE_LIMIT, order_by="oldest")
texts = [row.get("prompt_text", "").strip() for row in results if row.get("prompt_text")]
if not texts:
return None
return rng.choice(texts)
def resolve_wildcards(text: str, seed: Optional[int] = None, legacy: bool = True,
upstream_instructions: str = "", nsfw: str = "off",
violence_filter: str = "off") -> Resolution:
"""
Resolve wildcard syntax in `text`. Never raises: on any failure the text comes back
unchanged with a warning, so a prompt problem cannot break a workflow.
upstream_instructions: the llm_instructions text of a Wildcard Processor or Semantic Variation
node that resolved wildcards earlier in the graph. It travels on the Resolution, so llm_block()
and enforce_verbatim() treat those picks like this node's own.
nsfw: the node's nsfw input (NSFW_LEVELS); lists and entries rated above it are left out.
violence_filter: the node's violence_filter input (VIOLENCE_LEVELS).
"""
try:
resolution = get_wildcard_engine().process(
text or "",
seed=seed,
legacy=legacy,
providers={"db": database_provider},
nsfw=nsfw,
violence=violence_filter,
)
except Exception as error: # pragma: no cover - defensive
resolution = Resolution(text=text or "", seed=seed)
resolution.warnings.append(f"wildcard engine failed, text passed through: {error}")
resolution.upstream_instructions = (upstream_instructions or "").strip()
return resolution
def screen_output(text: str, where: str = "") -> Tuple[str, str]:
"""
The content filter on a finished prompt that did not come from the wildcard engine (an LLM's
rewrite). Returns (text, note): ('', why) when it pairs a minor term with an explicit sexual
term, else (text, ''). Put the note at the top of the node's report (lead_with_filter_notes).
Off when config.json "wildcards": {"content_filter": false}.
"""
if not text or not get_wildcard_engine().library.content_filter:
return text, ""
reason = prompt_block_reason(text)
if not reason:
return text, ""
note = (f"content filter: {where or 'prompt'} blocked ({reason}); "
f"set \"content_filter\": false in config.json to turn the filter off")
print(f"[video_prompter] {note}")
return "", note
def filter_notes_of(resolution: Optional[Resolution], *extra: str) -> List[str]:
"""What the content and violence filters did in this run: the resolution's filter notes, then `extra`."""
notes = list(resolution.filter_notes()) if resolution is not None else []
return notes + [note for note in extra if note]
def lead_with_filter_notes(report: str, notes: Iterable[str]) -> str:
"""The report with one 'FILTER: ...' line per note at the very top (duplicates dropped)."""
unique = list(dict.fromkeys(note for note in notes if note))
if not unique:
return report
head = "\n".join(f"FILTER: {note}" for note in unique)
return f"{head}\n{report}" if report else head
def items_from_instructions(instructions: str, heading: str) -> List[str]:
"""The bullet items listed under one heading ('Concepts', 'Verbatim', 'Inspiration') of an llm_instructions block."""
found: List[str] = []
inside = False
for line in (instructions or "").splitlines():
stripped = line.strip()
if not stripped.startswith("- "):
inside = stripped.lower().startswith(heading.lower())
continue
if inside:
value = stripped[2:].strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
value = value[1:-1]
if value and value not in found:
found.append(value)
return found
def verbatim_from_instructions(instructions: str) -> List[str]:
"""The quoted strings listed under the 'Verbatim' heading of an llm_instructions block."""
return items_from_instructions(instructions, "Verbatim")
def concepts_from_instructions(instructions: str) -> List[str]:
"""The phrases listed under the 'Concepts' heading of an llm_instructions block."""
return items_from_instructions(instructions, "Concepts")
def llm_block(resolution: Optional[Resolution], enabled: bool = True) -> str:
"""The selected-elements instruction block, or '' when disabled or nothing was picked."""
if not enabled or resolution is None:
return ""
blocks = [resolution.format_for_llm(), getattr(resolution, "upstream_instructions", "")]
return "\n\n".join(block for block in blocks if block)
def enforce_verbatim(text: str, resolution: Optional[Resolution]) -> Tuple[str, List[str]]:
"""
Survival check for verbatim picks (__=name__, {=a|b}): LoRA triggers and names must be in
the LLM's output word for word. Any that went missing are appended, the same way the nodes
already treat missing positive keywords. Returns (text, list of strings that had to be added).
"""
if resolution is None or not text:
return text, []
lowered = text.lower()
wanted = [slot.value for slot in resolution.slots_by_binding("verbatim")]
for value in verbatim_from_instructions(getattr(resolution, "upstream_instructions", "")):
if value not in wanted:
wanted.append(value)
missing = [value for value in wanted if value.lower() not in lowered]
if missing:
text = f"{text.rstrip()} {', '.join(missing)}"
return text, missing
def report_lines(resolution: Optional[Resolution]) -> str:
"""Short text for a node's status / breakdown output. '' when no wildcard was used."""
if resolution is None:
return ""
upstream = "Wildcards resolved upstream: instructions received." if getattr(resolution, "upstream_instructions", "") else ""
if not (resolution.top_level_slots() or resolution.warnings):
return upstream
# Filter notes are left out here: nodes put them at the top of their report (lead_with_filter_notes).
own = "Wildcards: " + "\n".join(line for line in resolution.summary().splitlines() if not line.startswith("FILTER: "))
return f"{own}\n{upstream}" if upstream else own