433 lines
13 KiB
Python
433 lines
13 KiB
Python
"""
|
|
Wildcard expansion helpers for A1111-style prompt text.
|
|
|
|
Supported syntax:
|
|
- ``__wildcard__`` for files like ``data/wildcards/wildcard.txt``
|
|
- Nested directories via dot notation, e.g. ``__characters.anime__``
|
|
- Nested wildcard expansion inside selected lines
|
|
- Dynamic prompt choices like ``{a|b}``, ``{1-2$$a|b|c}``, and ``{20%a|b}``
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import random
|
|
import re
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable, Dict, Iterable, List, Optional, Tuple
|
|
|
|
logger = logging.getLogger("A1111PromptNode")
|
|
|
|
DEFAULT_WILDCARDS_DIR = Path(__file__).resolve().parent.parent / "data" / "wildcards"
|
|
WILDCARD_PATTERN = re.compile(r"__([^_]+(?:_[^_]+)*)__")
|
|
DYNAMIC_PROMPT_PATTERN = re.compile(r"\{([^{}]*)\}")
|
|
MAX_WILDCARD_EXPANSION_DEPTH = 32
|
|
|
|
|
|
def _get_wildcards_dir(wildcards_dir: Optional[str] = None) -> Path:
|
|
return Path(wildcards_dir) if wildcards_dir else DEFAULT_WILDCARDS_DIR
|
|
|
|
|
|
def _iter_wildcard_files(wildcards_dir: Path) -> Iterable[Path]:
|
|
if not wildcards_dir.exists():
|
|
return []
|
|
return wildcards_dir.rglob("*.txt")
|
|
|
|
|
|
def _display_name_for_file(path: Path, wildcards_dir: Path) -> str:
|
|
relative = path.relative_to(wildcards_dir).with_suffix("")
|
|
return ".".join(relative.parts)
|
|
|
|
|
|
def _normalize_wildcard_name(name: str) -> str:
|
|
return name.strip().replace("\\", ".").replace("/", ".").strip(".").lower()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WildcardFileIndex:
|
|
files: Tuple[Path, ...]
|
|
by_name: Dict[str, Path]
|
|
by_leaf: Dict[str, Tuple[Path, ...]]
|
|
|
|
|
|
_wildcard_index_cache: Dict[Path, WildcardFileIndex] = {}
|
|
_wildcard_index_lock = threading.Lock()
|
|
|
|
|
|
def get_wildcard_file_index(
|
|
wildcards_dir: Optional[str | Path] = None,
|
|
refresh: bool = False,
|
|
) -> WildcardFileIndex:
|
|
base_dir = _get_wildcards_dir(
|
|
str(wildcards_dir) if wildcards_dir is not None else None
|
|
).resolve()
|
|
|
|
with _wildcard_index_lock:
|
|
cached = _wildcard_index_cache.get(base_dir)
|
|
if cached is not None and not refresh:
|
|
return cached
|
|
|
|
files = tuple(_iter_wildcard_files(base_dir))
|
|
by_name: Dict[str, Path] = {}
|
|
leaf_lists: Dict[str, List[Path]] = {}
|
|
for file_path in files:
|
|
normalized_name = _normalize_wildcard_name(
|
|
_display_name_for_file(file_path, base_dir)
|
|
)
|
|
by_name.setdefault(normalized_name, file_path)
|
|
leaf_lists.setdefault(file_path.stem.lower(), []).append(file_path)
|
|
|
|
index = WildcardFileIndex(
|
|
files=files,
|
|
by_name=by_name,
|
|
by_leaf={
|
|
leaf_name: tuple(paths) for leaf_name, paths in leaf_lists.items()
|
|
},
|
|
)
|
|
_wildcard_index_cache[base_dir] = index
|
|
return index
|
|
|
|
|
|
def _resolve_wildcard_file(
|
|
wildcard_name: str, wildcards_dir: Optional[str] = None
|
|
) -> Optional[Path]:
|
|
base_dir = _get_wildcards_dir(wildcards_dir)
|
|
if not base_dir.exists():
|
|
return None
|
|
|
|
normalized_name = _normalize_wildcard_name(wildcard_name)
|
|
if not normalized_name:
|
|
return None
|
|
|
|
direct_path = base_dir.joinpath(*normalized_name.split(".")).with_suffix(".txt")
|
|
if direct_path.is_file():
|
|
return direct_path
|
|
|
|
index = get_wildcard_file_index(base_dir)
|
|
indexed_path = index.by_name.get(normalized_name)
|
|
if indexed_path is not None:
|
|
return indexed_path
|
|
|
|
leaf_matches = index.by_leaf.get(normalized_name, ())
|
|
if len(leaf_matches) == 1:
|
|
return leaf_matches[0]
|
|
if len(leaf_matches) > 1:
|
|
logger.warning(
|
|
"[A1111 Prompt] Ambiguous leaf wildcard name '%s'; use the full path to disambiguate.",
|
|
wildcard_name,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _read_wildcard_lines(path: Path) -> List[str]:
|
|
options: List[str] = []
|
|
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
for raw_line in handle:
|
|
line = raw_line.strip()
|
|
if line and not line.startswith("#"):
|
|
options.append(line)
|
|
return options
|
|
|
|
|
|
def _get_dynamic_variant_weight(variant: str) -> int:
|
|
split_variant = variant.split("%", 1)
|
|
if len(split_variant) == 2:
|
|
try:
|
|
return int(split_variant[0])
|
|
except ValueError:
|
|
return 0
|
|
return 0
|
|
|
|
|
|
def _strip_dynamic_variant_weight(variant: str) -> str:
|
|
split_variant = variant.split("%", 1)
|
|
if len(split_variant) == 2:
|
|
return split_variant[1]
|
|
return variant
|
|
|
|
|
|
def _parse_dynamic_range(range_str: Optional[str], num_variants: int) -> tuple[int, int]:
|
|
if range_str is None:
|
|
return 1, 1
|
|
|
|
parts = range_str.split("-")
|
|
if len(parts) == 1:
|
|
value = min(int(parts[0]), num_variants)
|
|
return value, value
|
|
if len(parts) == 2:
|
|
low = int(parts[0]) if parts[0] else 0
|
|
high = min(int(parts[1]), num_variants) if parts[1] else num_variants
|
|
return min(low, high), max(low, high)
|
|
raise ValueError(f"Unexpected dynamic prompt range: {range_str}")
|
|
|
|
|
|
def _expand_dynamic_prompt_match(
|
|
match: re.Match[str], chooser: random.Random | random.Random
|
|
) -> str:
|
|
combinations_str = match.group(1)
|
|
variants = [segment.strip() for segment in combinations_str.split("|")]
|
|
if not variants:
|
|
return ""
|
|
|
|
weights = [_get_dynamic_variant_weight(variant) for variant in variants]
|
|
variants = [_strip_dynamic_variant_weight(variant) for variant in variants]
|
|
|
|
splits = variants[0].split("$$", 1)
|
|
quantity_spec: Optional[str] = None
|
|
if len(splits) == 2:
|
|
quantity_spec = splits[0].strip()
|
|
variants[0] = splits[1].strip()
|
|
|
|
try:
|
|
low_range, high_range = _parse_dynamic_range(quantity_spec, len(variants))
|
|
except ValueError:
|
|
return match.group(0)
|
|
|
|
if high_range <= 0:
|
|
return ""
|
|
|
|
quantity = chooser.randint(low_range, high_range)
|
|
if quantity <= 0:
|
|
return ""
|
|
|
|
total_weight = sum(weights)
|
|
zero_weight_count = weights.count(0)
|
|
if zero_weight_count > 0 and total_weight < 100:
|
|
remaining_weight = max(0, 100 - total_weight)
|
|
fill_weight = remaining_weight / zero_weight_count if zero_weight_count else 0
|
|
weights = [fill_weight if weight == 0 else weight for weight in weights]
|
|
elif all(weight == 0 for weight in weights):
|
|
weights = [1] * len(weights)
|
|
|
|
available_variants = list(variants)
|
|
available_weights = list(weights)
|
|
picked: List[str] = []
|
|
|
|
for _ in range(min(quantity, len(available_variants))):
|
|
choice = chooser.choices(available_variants, weights=available_weights, k=1)[0]
|
|
picked.append(choice)
|
|
index = available_variants.index(choice)
|
|
available_variants.pop(index)
|
|
available_weights.pop(index)
|
|
|
|
return ", ".join(picked)
|
|
|
|
|
|
def _expand_dynamic_prompts(
|
|
text: str, chooser: random.Random | random.Random
|
|
) -> tuple[str, bool]:
|
|
changed = False
|
|
|
|
def replace_match(match: re.Match[str]) -> str:
|
|
nonlocal changed
|
|
replacement = _expand_dynamic_prompt_match(match, chooser)
|
|
if replacement != match.group(0):
|
|
changed = True
|
|
return replacement
|
|
|
|
return DYNAMIC_PROMPT_PATTERN.sub(replace_match, text), changed
|
|
|
|
|
|
def _select_highest_scoring_text(
|
|
options: List[str], scorer: Callable[[str], int]
|
|
) -> str:
|
|
if not options:
|
|
return ""
|
|
return max(options, key=lambda option: (scorer(option), len(option), option))
|
|
|
|
|
|
def _expand_dynamic_prompt_match_max(
|
|
match: re.Match[str],
|
|
scorer: Callable[[str], int],
|
|
expander: Callable[[str], str],
|
|
) -> str:
|
|
combinations_str = match.group(1)
|
|
variants = [segment.strip() for segment in combinations_str.split("|")]
|
|
if not variants:
|
|
return ""
|
|
|
|
variants = [_strip_dynamic_variant_weight(variant) for variant in variants]
|
|
|
|
splits = variants[0].split("$$", 1)
|
|
quantity_spec: Optional[str] = None
|
|
if len(splits) == 2:
|
|
quantity_spec = splits[0].strip()
|
|
variants[0] = splits[1].strip()
|
|
|
|
try:
|
|
_, high_range = _parse_dynamic_range(quantity_spec, len(variants))
|
|
except ValueError:
|
|
return match.group(0)
|
|
|
|
if high_range <= 0:
|
|
return ""
|
|
|
|
expanded_variants = [expander(variant) for variant in variants]
|
|
ranked_variants = sorted(
|
|
expanded_variants,
|
|
key=lambda variant: (scorer(variant), len(variant), variant),
|
|
reverse=True,
|
|
)
|
|
return ", ".join(ranked_variants[: min(high_range, len(ranked_variants))])
|
|
|
|
|
|
def _expand_wildcards_for_token_count(
|
|
text: str,
|
|
wildcards_dir: Optional[str],
|
|
scorer: Callable[[str], int],
|
|
depth_remaining: int,
|
|
) -> str:
|
|
if not text or depth_remaining <= 0:
|
|
return text
|
|
|
|
current = text
|
|
|
|
for _ in range(depth_remaining):
|
|
changed = False
|
|
|
|
def replace_wildcard(match: re.Match[str]) -> str:
|
|
nonlocal changed
|
|
options = get_wildcard_options(match.group(1), wildcards_dir=wildcards_dir)
|
|
if not options:
|
|
return match.group(0)
|
|
|
|
changed = True
|
|
expanded_options = [
|
|
_expand_wildcards_for_token_count(
|
|
option, wildcards_dir, scorer, depth_remaining - 1
|
|
)
|
|
for option in options
|
|
]
|
|
return _select_highest_scoring_text(expanded_options, scorer)
|
|
|
|
expanded = WILDCARD_PATTERN.sub(replace_wildcard, current)
|
|
|
|
def replace_dynamic(match: re.Match[str]) -> str:
|
|
nonlocal changed
|
|
|
|
def expand_variant(variant: str) -> str:
|
|
return _expand_wildcards_for_token_count(
|
|
variant, wildcards_dir, scorer, depth_remaining - 1
|
|
)
|
|
|
|
replacement = _expand_dynamic_prompt_match_max(
|
|
match,
|
|
scorer=scorer,
|
|
expander=expand_variant,
|
|
)
|
|
if replacement != match.group(0):
|
|
changed = True
|
|
return replacement
|
|
|
|
expanded = DYNAMIC_PROMPT_PATTERN.sub(replace_dynamic, expanded)
|
|
expanded = _normalize_prompt_spacing(expanded)
|
|
if not changed or expanded == current:
|
|
return expanded
|
|
current = expanded
|
|
|
|
return current
|
|
|
|
|
|
def _normalize_prompt_spacing(text: str) -> str:
|
|
text = re.sub(r"[ \t]*,[ \t]*", ", ", text)
|
|
text = re.sub(r"[ \t]{2,}", " ", text)
|
|
return text.strip()
|
|
|
|
|
|
def expand_wildcards(
|
|
text: str,
|
|
wildcards_dir: Optional[str] = None,
|
|
rng: Optional[random.Random] = None,
|
|
max_depth: int = MAX_WILDCARD_EXPANSION_DEPTH,
|
|
) -> str:
|
|
"""
|
|
Expand ``__wildcard__`` syntax in prompt text.
|
|
|
|
Missing wildcard files are left untouched so the user can spot the problem
|
|
in the prompt preview instead of silently losing text.
|
|
"""
|
|
if not text:
|
|
return text
|
|
|
|
chooser = rng or random
|
|
current = text
|
|
|
|
for _ in range(max_depth):
|
|
changed = False
|
|
|
|
def replace_match(match: re.Match[str]) -> str:
|
|
nonlocal changed
|
|
options = get_wildcard_options(match.group(1), wildcards_dir=wildcards_dir)
|
|
if not options:
|
|
return match.group(0)
|
|
changed = True
|
|
return chooser.choice(options)
|
|
|
|
expanded = WILDCARD_PATTERN.sub(replace_match, current)
|
|
expanded, dynamic_changed = _expand_dynamic_prompts(expanded, chooser)
|
|
expanded = _normalize_prompt_spacing(expanded)
|
|
changed = changed or dynamic_changed
|
|
if not changed or expanded == current:
|
|
return expanded
|
|
current = expanded
|
|
|
|
if WILDCARD_PATTERN.search(current):
|
|
logger.warning(
|
|
"[A1111 Prompt] Wildcard expansion depth limit reached; possible recursive loop in prompt: %s",
|
|
current[:200],
|
|
)
|
|
return current
|
|
|
|
|
|
def expand_wildcards_for_token_count(
|
|
text: str,
|
|
wildcards_dir: Optional[str] = None,
|
|
scorer: Optional[Callable[[str], int]] = None,
|
|
max_depth: int = MAX_WILDCARD_EXPANSION_DEPTH,
|
|
) -> str:
|
|
"""
|
|
Deterministically expand wildcards and dynamic prompts for worst-case token counting.
|
|
|
|
Runtime wildcard expansion remains random. This helper is only for UI estimates
|
|
and chooses the highest-scoring option at each wildcard/dynamic prompt branch.
|
|
"""
|
|
score_text = scorer or (lambda value: len(value))
|
|
return _expand_wildcards_for_token_count(
|
|
text,
|
|
wildcards_dir=wildcards_dir,
|
|
scorer=score_text,
|
|
depth_remaining=max_depth,
|
|
)
|
|
|
|
|
|
def get_wildcard_options(
|
|
wildcard_name: str, wildcards_dir: Optional[str] = None
|
|
) -> List[str]:
|
|
"""
|
|
Get all options for a given wildcard file.
|
|
|
|
Returns an empty list when the wildcard file does not exist.
|
|
"""
|
|
resolved_file = _resolve_wildcard_file(wildcard_name, wildcards_dir=wildcards_dir)
|
|
if resolved_file is None:
|
|
return []
|
|
return _read_wildcard_lines(resolved_file)
|
|
|
|
|
|
def list_available_wildcards(wildcards_dir: Optional[str] = None) -> List[str]:
|
|
"""
|
|
List wildcard names available under the configured wildcard directory.
|
|
"""
|
|
base_dir = _get_wildcards_dir(wildcards_dir)
|
|
if not base_dir.exists():
|
|
return []
|
|
|
|
wildcard_names = [
|
|
_display_name_for_file(file_path, base_dir)
|
|
for file_path in _iter_wildcard_files(base_dir)
|
|
]
|
|
return sorted(wildcard_names, key=lambda name: name.lower())
|