Files
EricRollei-Local_LLM_Prompt…/core/wildcards/resolver.py
T
Eric HissandClaude Fable 5.1 4459af9ea6 V2.0.0: wildcard engine, platform profiles, MiniMax H3 default, API models
Every node id, input name and existing widget position is unchanged, so saved
workflows load; new inputs are appended only.

Wildcards
- core/wildcards: Dynamic Prompts / Impact Pack grammar, tag queries, variables,
  conditionals, concept / verbatim / loose bindings, reproducible seeds.
  Wired into every enhancer node; reads ComfyUI/models/wildcards.
- New nodes: Wildcard Processor, Semantic Variation (direction axes),
  Concept Survival Check. Starter lists in wildcards/.
- Random choices use a private generator; global random state is untouched.

Platform profiles
- core/profiles + profiles/*.yaml: one file per model (format, length,
  negative prompt or none, quality tokens, text and dialogue syntax, vendor
  rewriter prompt fetched once and cached). profiles_user/ overrides by id.
- New node: Platform Prompt Rewriter (prose, tags, edit instructions, JSON,
  MiniMax H3 fields, Hailuo brackets), with validators for each format.
- Text-to-Image and Image-to-Image read profiles through platforms.py;
  corrected facts for FLUX, Kontext, Chroma, Wan, AuraFlow, Kolors, NoobAI,
  Qwen-Image, HunyuanImage. Negative prompt is empty for models without one.
- Hosted / API models: Seedream 5, Nano Banana, GPT Image, Midjourney V8,
  FLUX.2 pro, Ideogram 3, Recraft V4, Seedance 2.0 / 2.5, Kling 3.0, Veo 3.1,
  Gemini Omni Flash, Runway, Wan 3.0, Luma, hosted H3, Grok, Vidu, PixVerse.
  Reports say how to switch the vendor's own prompt rewriting off.
- Illustrious: spaced tags, quality tags last, 25-80 words (OnomaAI paper).

Video nodes
- target_model and duration_seconds appended to the three video expanders.
  Default target is MiniMax H3; wan22_video is the original path, unchanged.
  A workflow saved earlier loads with the default until the input is set.

Also included: the Alice, Ideogram Magic Prompt and prompt-database nodes that
were already registered in __init__.py but had not been committed.

Fixes: v1 {animal} / {label:a|b} wildcards were inert in the video nodes;
Image-to-Video save_to_file raised NameError; T2I padded short-prompt models.

.gitattributes added (text=auto) so CRLF working files stop showing as
whole-file changes. prompt_database.db and the cached Ideogram prompt are
git-ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VpCi6nw5UECMdZ7ayYJvN
2026-09-21 00:28:58 -07:00

489 lines
22 KiB
Python

"""
Resolver: walks a parsed syntax tree and produces text plus a resolution record.
Determinism: every random decision goes through one private random.Random(seed).
The global `random` module is never seeded, reseeded or drawn from, so other
nodes in a ComfyUI graph are unaffected. Same seed + same files = same text.
Run modes
random weighted random picks
combinatorial `index` enumerates combinations: each choice point consumes one
mixed-radix digit of the index (weights ignored). The product of
the option counts met on the way is reported as combination_space,
so a sequencer can walk 0 .. combination_space-1.
Per-construct samplers override the mode: '~' always random, '@' cyclical
(options[cycle_index % n]).
(c) Eric Hiss. Dual license, see LICENSE.
"""
from __future__ import annotations
import random
import re
from typing import Callable, Dict, List, Optional, Tuple, Union
from .boolean_expr import BooleanExpressionError, compile_expression, evaluate
from .library import Entry, WildcardLibrary
from .parser import parse
from .record import Resolution, Slot
from .syntax_tree import (
COUNT_ALL,
BINDING_CONCEPT,
SAMPLER_CYCLICAL,
SAMPLER_RANDOM,
Conditional,
Node,
Provider,
Sequence,
Text,
VariableAssign,
VariableRef,
Variant,
Wildcard,
)
MODE_RANDOM = "random"
MODE_COMBINATORIAL = "combinatorial"
MODES = (MODE_RANDOM, MODE_COMBINATORIAL)
MAX_DEPTH = 30
_FALSY = {"", "0", "false", "no", "none", "off"}
# provider(argument, text_so_far, rng) -> replacement text, or None when it cannot fill the slot
ProviderCallable = Callable[[str, str, random.Random], Optional[str]]
class PickContext:
"""
What a pick hook sees at one choice point. A hook may return a replacement list of indices
into `candidates` (same length as `chosen`), or None to keep the engine's own pick.
Used by core/variability to move a pick to a semantic neighbour without touching the grammar.
"""
__slots__ = ("kind", "key", "source", "candidates", "chosen", "depth", "binding", "rng")
def __init__(self, kind: str, key: str, source: str, candidates: List[str], chosen: List[int],
depth: int, binding: str, rng: random.Random):
self.kind = kind # "wildcard" | "variant"
self.key = key # wildcard name / tag query, or "" for an inline variant
self.source = source # raw construct text, e.g. "__materials__"
self.candidates = candidates # raw text of every entry or option that could have been picked
self.chosen = chosen # indices the engine picked
self.depth = depth
self.binding = binding
self.rng = rng
PickHook = Callable[[PickContext], Optional[List[int]]]
# A variable holds resolved text plus its binding (immediate) or an unresolved tree (deferred).
_VariableValue = Union[Tuple[str, str], Sequence]
class _Chooser:
"""All selection logic in one place, so random / combinatorial / cyclical stay consistent."""
def __init__(self, rng: random.Random, mode: str, index: int, cycle_index: int):
self.rng = rng
self.mode = mode if mode in MODES else MODE_RANDOM
self.remaining_index = max(0, int(index))
self.cycle_index = max(0, int(cycle_index))
self.combination_space = 1
def _digit(self, radix: int) -> int:
self.combination_space *= radix
digit = self.remaining_index % radix
self.remaining_index //= radix
return digit
def choose_count(self, low: int, high: int, sampler: str) -> int:
if high <= low:
return low
if self.mode == MODE_COMBINATORIAL and sampler != SAMPLER_RANDOM:
return low + self._digit(high - low + 1)
return self.rng.randint(low, high)
def choose(self, weights: List[float], count: int, sampler: str) -> List[int]:
"""Return `count` DISTINCT indices into a list of len(weights)."""
total = len(weights)
if total == 0:
return []
count = max(0, min(count, total))
if sampler == SAMPLER_CYCLICAL:
start = self.cycle_index % total
return [(start + offset) % total for offset in range(count)]
available = list(range(total))
chosen: List[int] = []
if self.mode == MODE_COMBINATORIAL and sampler != SAMPLER_RANDOM:
for _ in range(count):
chosen.append(available.pop(self._digit(len(available))))
return chosen
for _ in range(count):
pool_weights = [max(0.0, weights[i]) for i in available]
if sum(pool_weights) <= 0:
pool_weights = [1.0] * len(available)
picked = self.rng.choices(range(len(available)), weights=pool_weights, k=1)[0]
chosen.append(available.pop(picked))
return chosen
class Resolver:
def __init__(
self,
library: WildcardLibrary,
seed: Optional[int] = None,
mode: str = MODE_RANDOM,
index: int = 0,
cycle_index: int = 0,
legacy: bool = False,
providers: Optional[Dict[str, ProviderCallable]] = None,
variables: Optional[Dict[str, str]] = None,
pick_hook: Optional[PickHook] = None,
):
self.library = library
self.pick_hook = pick_hook
self.rng = random.Random(seed) if seed is not None else random.Random()
self.chooser = _Chooser(self.rng, mode, index, cycle_index)
self.legacy = legacy
self.providers = providers or {}
self.resolution = Resolution(seed=seed, mode=self.chooser.mode)
self._initial_variables: Dict[str, _VariableValue] = {
name: (str(value), BINDING_CONCEPT) for name, value in (variables or {}).items()
}
self._entry_cache: Dict[str, Sequence] = {}
self._output_so_far: List[str] = []
# -- public -----------------------------------------------------------------
def resolve_text(self, text: str) -> Resolution:
tree = parse(text, legacy=self.legacy)
scope = dict(self._initial_variables)
self.resolution.text = self._resolve(tree, scope, depth=0, parent=None, in_output=True, track=True)
self.resolution.combination_space = self.chooser.combination_space
self.resolution.variables = {
name: value[0] for name, value in scope.items() if isinstance(value, tuple)
}
for warning in self.library.all_warnings():
if warning not in self.resolution.warnings:
self.resolution.warnings.append(warning)
return self.resolution
# -- dispatch ---------------------------------------------------------------
def _resolve(self, node: Node, scope: Dict[str, _VariableValue], depth: int,
parent: Optional[int], in_output: bool, track: bool = False) -> str:
if isinstance(node, Text):
if track:
self._output_so_far.append(node.text)
return node.text
if isinstance(node, Sequence):
pieces: List[str] = []
for part in node.parts:
piece = self._resolve(part, scope, depth, parent, in_output, track)
if piece and pieces and not isinstance(part, Text):
pieces[-1] = agree_article(pieces[-1], piece)
pieces.append(piece)
return "".join(pieces)
if depth > MAX_DEPTH:
self._warn(f"nesting deeper than {MAX_DEPTH} at {node.source!r}; left as text (a wildcard probably refers to itself)")
return node.source
if isinstance(node, Variant):
result = self._resolve_variant(node, scope, depth, parent, in_output)
elif isinstance(node, Wildcard):
result = self._resolve_wildcard(node, scope, depth, parent, in_output)
elif isinstance(node, Provider):
result = self._resolve_provider(node, depth, parent, in_output)
elif isinstance(node, VariableAssign):
result = self._resolve_assignment(node, scope, depth, parent)
elif isinstance(node, VariableRef):
result = self._resolve_reference(node, scope, depth, parent, in_output)
elif isinstance(node, Conditional):
result = self._resolve_conditional(node, scope, depth, parent, in_output)
else:
result = node.source
if track:
self._output_so_far.append(result)
return result
# -- helpers ----------------------------------------------------------------
def _apply_pick_hook(self, kind: str, key: str, source: str, candidates: List[str],
chosen: List[int], depth: int, binding: str) -> List[int]:
"""Let the host move a pick. Any hook failure or bad answer keeps the engine's own pick."""
if self.pick_hook is None or not chosen:
return chosen
try:
replacement = self.pick_hook(PickContext(kind, key, source, candidates, list(chosen), depth, binding, self.rng))
except Exception as error:
self._warn(f"pick hook failed at {source}: {error}")
return chosen
if (
replacement is None
or len(replacement) != len(chosen)
or len(set(replacement)) != len(replacement)
or any(not isinstance(i, int) or i < 0 or i >= len(candidates) for i in replacement)
):
return chosen
return list(replacement)
def _warn(self, message: str) -> None:
if message not in self.resolution.warnings:
self.resolution.warnings.append(message)
def _open_slot(self, kind: str, source: str, binding: str, depth: int, parent: Optional[int],
in_output: bool, label: Optional[str] = None) -> Slot:
slot = Slot(index=len(self.resolution.slots), kind=kind, source=source, value="", binding=binding,
label=label, depth=depth, parent=parent, in_output=in_output)
self.resolution.slots.append(slot)
return slot
def _parse_entry(self, text: str) -> Sequence:
tree = self._entry_cache.get(text)
if tree is None:
tree = parse(text, legacy=False)
self._entry_cache[text] = tree
return tree
def _resolve_entries(self, entries: List[Entry], indices: List[int], scope: Dict[str, _VariableValue],
slot: Slot, in_output: bool) -> List[str]:
values: List[str] = []
origins: List[str] = []
for entry_index in indices:
entry = entries[entry_index]
values.append(self._resolve(self._parse_entry(entry.text), scope, slot.depth + 1, slot.index, in_output).strip())
if entry.origin and entry.origin not in origins:
origins.append(entry.origin)
slot.origin = "; ".join(origins)
return values
# -- {variant} --------------------------------------------------------------
def _resolve_variant(self, node: Variant, scope: Dict[str, _VariableValue], depth: int,
parent: Optional[int], in_output: bool) -> str:
single = len(node.options) == 1
only_body = node.options[0].body if single else None
if single and not node.has_count and node.label is None:
# Legacy {category}: a bare name that matches a wildcard file.
if self.legacy and only_body.is_plain_text():
name = only_body.plain_text().strip()
if name and self.library.has(name):
legacy_node = Wildcard(source=node.source, name=name, sampler=node.sampler, binding=node.binding)
return self._resolve_wildcard(legacy_node, scope, depth, parent, in_output, kind="legacy")
# Not a choice at all: keep the braces, still resolve anything inside them.
return "{" + self._resolve(only_body, scope, depth, parent, in_output) + "}"
slot = self._open_slot("variant", node.source, node.binding, depth, parent, in_output, label=node.label)
# Pool size is needed to close an open range ({2-$$...}); for {N$$__wildcard__} the pool is the file.
draws_from_wildcard = (
single and node.has_count and len(only_body.parts) == 1 and isinstance(only_body.parts[0], Wildcard)
)
wildcard_entries = self._entries_for(only_body.parts[0]) if draws_from_wildcard else []
pool_size = len(wildcard_entries) if wildcard_entries else len(node.options)
count_max = pool_size if node.count_max == COUNT_ALL else min(node.count_max, pool_size)
count_min = min(node.count_min, count_max)
count = self.chooser.choose_count(count_min, count_max, node.sampler)
# {2$$__flavours__}: draw several distinct entries from one wildcard.
if draws_from_wildcard:
reference = only_body.parts[0]
entries = wildcard_entries
if entries:
slot.kind = "wildcard"
slot.label = reference.name or reference.tag_expression
slot.option_count = len(entries)
child_scope = self._scope_with_params(scope, reference)
indices = self.chooser.choose([entry.weight for entry in entries], count, node.sampler)
indices = self._apply_pick_hook("wildcard", slot.label or "", node.source,
[entry.text for entry in entries], indices, depth, node.binding)
slot.value = node.separator.join(self._resolve_entries(entries, indices, child_scope, slot, in_output))
return slot.value
slot.option_count = len(node.options)
indices = self.chooser.choose([option.weight for option in node.options], count, node.sampler)
indices = self._apply_pick_hook("variant", node.label or "", node.source,
[option.body.source or option.body.plain_text() for option in node.options],
indices, depth, node.binding)
values = [
self._resolve(node.options[option_index].body, scope, depth + 1, slot.index, in_output)
for option_index in indices
]
slot.value = node.separator.join(values)
return slot.value
# -- __wildcard__ -----------------------------------------------------------
def _entries_for(self, node: Wildcard) -> List[Entry]:
if node.tag_expression:
return self.library.query_tags(node.tag_expression, scope=node.name)
return self.library.lookup(node.name)
@staticmethod
def _scope_with_params(scope: Dict[str, _VariableValue], node: Wildcard) -> Dict[str, _VariableValue]:
if not node.params:
return scope
child = dict(scope)
for key, value in node.params.items():
child[key] = (value, BINDING_CONCEPT)
return child
def _resolve_wildcard(self, node: Wildcard, scope: Dict[str, _VariableValue], depth: int,
parent: Optional[int], in_output: bool, kind: Optional[str] = None) -> str:
entries = self._entries_for(node)
if not entries:
if node.tag_expression:
self._warn(f"no entries match {node.source}; left as text")
else:
self._warn(f"wildcard '{node.name}' not found; left as text")
return node.source
slot_kind = kind or ("tag_query" if node.tag_expression else "wildcard")
slot = self._open_slot(slot_kind, node.source, node.binding, depth, parent, in_output,
label=node.name or node.tag_expression)
slot.option_count = len(entries)
indices = self.chooser.choose([entry.weight for entry in entries], 1, node.sampler)
indices = self._apply_pick_hook("wildcard", node.name or node.tag_expression or "", node.source,
[entry.text for entry in entries], indices, depth, node.binding)
child_scope = self._scope_with_params(scope, node)
slot.value = self._resolve_entries(entries, indices, child_scope, slot, in_output)[0]
return slot.value
# -- __db:...__ / __llm:...__ -------------------------------------------------
def _resolve_provider(self, node: Provider, depth: int, parent: Optional[int], in_output: bool) -> str:
provider = self.providers.get(node.kind)
replacement: Optional[str] = None
if provider is not None:
try:
replacement = provider(node.argument, "".join(self._output_so_far), self.rng)
except Exception as error: # a provider must never break the run
self._warn(f"{node.kind} provider failed for {node.source}: {error}")
slot = self._open_slot("provider", node.source, node.binding, depth, parent, in_output, label=node.kind)
if replacement:
slot.value = replacement.strip()
slot.origin = node.kind
else:
# Nobody could fill it now. Keep the instruction readable and flag it, so an LLM
# rewriter further down the line can fill it in context.
slot.value = node.argument
slot.origin = f"{node.kind} (unfilled)"
self._warn(f"no '{node.kind}' provider filled {node.source}; its instruction text was kept")
return slot.value
# -- ${variables} -----------------------------------------------------------
def _resolve_assignment(self, node: VariableAssign, scope: Dict[str, _VariableValue], depth: int,
parent: Optional[int]) -> str:
if node.immediate:
binding = BINDING_CONCEPT
if len(node.value.parts) == 1 and hasattr(node.value.parts[0], "binding"):
binding = node.value.parts[0].binding
value = self._resolve(node.value, scope, depth + 1, parent, in_output=False).strip()
scope[node.name] = (value, binding)
else:
scope[node.name] = node.value
return ""
def _resolve_reference(self, node: VariableRef, scope: Dict[str, _VariableValue], depth: int,
parent: Optional[int], in_output: bool) -> str:
stored = scope.get(node.name)
if stored is None:
if node.default is not None:
return self._resolve(node.default, scope, depth + 1, parent, in_output)
self._warn(f"variable '{node.name}' is not defined; left as text")
return node.source
if isinstance(stored, tuple):
value, binding = stored
slot = self._open_slot("variable", node.source, binding, depth, parent, in_output, label=node.name)
slot.value = value
return value
slot = self._open_slot("variable", node.source, BINDING_CONCEPT, depth, parent, in_output, label=node.name)
slot.value = self._resolve(stored, scope, depth + 1, slot.index, in_output).strip()
return slot.value
# -- [if ...] ---------------------------------------------------------------
def _resolve_conditional(self, node: Conditional, scope: Dict[str, _VariableValue], depth: int,
parent: Optional[int], in_output: bool) -> str:
try:
compiled = compile_expression(node.condition)
except BooleanExpressionError as error:
self._warn(f"bad condition in {node.source}: {error}; left as text")
return node.source
picked_text = " ".join(slot.value for slot in self.resolution.slots if slot.value).lower()
def term_is_true(term: str) -> bool:
term = term.strip()
if "=" in term:
name, expected = term.split("=", 1)
stored = scope.get(name.strip())
return isinstance(stored, tuple) and stored[0].strip().lower() == expected.strip().lower()
stored = scope.get(term)
if isinstance(stored, tuple):
return stored[0].strip().lower() not in _FALSY
if isinstance(stored, Sequence):
return True
return re.search(r"(?<!\w)" + re.escape(term.lower()) + r"(?!\w)", picked_text) is not None
branch = node.then_branch if evaluate(compiled, term_is_true) else node.else_branch
if branch is None:
return ""
return self._resolve(branch, scope, depth + 1, parent, in_output)
# -- output tidying ---------------------------------------------------------------
_SPACE_RUN = re.compile(r"[ \t]{2,}")
_SPACE_BEFORE_PUNCTUATION = re.compile(r"[ \t]+([,.;])(?![\w])")
_DUPLICATE_COMMAS = re.compile(r",(\s*,)+")
_BLANK_LINES = re.compile(r"\n{3,}")
# "A {animal}" + the entry "a raven" -> "A a raven". Keep the entry's article, in the first one's case.
_DOUBLE_ARTICLE = re.compile(r"\b(a|an|the)[ \t]+(a|an|the)\b", re.IGNORECASE)
def _merge_articles(match: "re.Match") -> str:
first, second = match.group(1), match.group(2)
return second.capitalize() if first[0].isupper() else second
# "a" / "an" agreement where a pick lands right behind an article: "a {owl|cat}" must not read "a owl".
# Applied ONLY at that boundary, never to the user's own words ("plan a or b" stays as written).
# Spelling only approximates sound, so known exceptions are listed and anything unsure is left alone.
_TRAILING_ARTICLE = re.compile(r"(^|[\s(\[\"'])(a|an|A|An)([ \t]+)$")
_FIRST_WORD = re.compile(r"^[\s\"'(]*([A-Za-z][a-z]+)")
_VOWEL_LETTER_CONSONANT_SOUND = ("uni", "use", "usu", "uti", "ute", "ura", "uro", "eu", "ewe", "one", "once", "ubiq", "uku", "ufo")
_CONSONANT_LETTER_VOWEL_SOUND = ("hour", "honest", "honor", "honour", "heir", "herb")
def agree_article(before: str, value: str) -> str:
"""`before` with its trailing a/an corrected for the word `value` starts with. Unsure -> unchanged."""
article = _TRAILING_ARTICLE.search(before)
word = _FIRST_WORD.match(value or "")
if not article or not word:
return before
first = word.group(1).lower()
if first[0] in "aeiou":
wants_an = not first.startswith(_VOWEL_LETTER_CONSONANT_SOUND)
else:
wants_an = first.startswith(_CONSONANT_LETTER_VOWEL_SOUND)
current = article.group(2)
wanted = ("An" if current[0] == "A" else "an") if wants_an else ("A" if current[0] == "A" else "a")
if wanted == current:
return before
return before[:article.start(2)] + wanted + article.group(3)
def tidy(text: str) -> str:
"""Remove the scars that empty picks and variable assignments leave behind. Newlines are kept."""
text = _DUPLICATE_COMMAS.sub(",", text)
text = _DOUBLE_ARTICLE.sub(_merge_articles, text)
text = _SPACE_BEFORE_PUNCTUATION.sub(r"\1", text)
text = _SPACE_RUN.sub(" ", text)
text = "\n".join(line.rstrip() for line in text.split("\n"))
text = _BLANK_LINES.sub("\n\n", text)
return text.strip()