Files
EricRollei-Local_LLM_Prompt…/core/builder/merge.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

176 lines
7.3 KiB
Python

"""
Merging the lists behind a slot button into one list of your own.
Most downloaded packs share one core vocabulary ("black" hair is in 13 of 26 hair colour lists)
and add their own tail. merge_entries() folds near-identical entries across the chosen lists
("Black hair", "black", "black-haired" -> one row) and counts how many lists have each one, so
the shared core sorts first and the one-off tail can be pruned by eye. write_merged() saves the
kept entries as <root>/mine/<slot id>/<name>.txt; lists under mine/<slot id>/ are pinned first on
that slot's button (see slots.SlotMatcher).
No ComfyUI dependency.
(c) Eric Hiss. Dual license, see LICENSE.
"""
from __future__ import annotations
import os
import re
import time
from collections import Counter
from dataclasses import dataclass, field
from typing import Dict, Iterable, List, Optional, Sequence, Set
MINE_FOLDER = "mine"
_WEIGHTED = re.compile(r"\(([^():]+):\s*[\d.]+\)") # (auburn hair:1.2) -> auburn hair
_EDGE_PUNCT = re.compile(r"^[\s,.;:!]+|[\s,.;:!]+$")
_FOLD_PUNCT = re.compile(r"[^\w\s']+")
_SPACES = re.compile(r"\s+")
_WORD = re.compile(r"[a-z]+")
_NAME = re.compile(r"[^a-z0-9_\-]+")
def slot_words(*texts: str) -> Set[str]:
"""Words that only restate the slot ("hair", "eyes", "colour") and are ignored when folding."""
words: Set[str] = set()
for text in texts:
for word in _WORD.findall((text or "").lower()):
if len(word) < 3:
continue
stem = word[:-1] if word.endswith("s") and len(word) > 3 else word
words.update({word, stem, stem + "s", stem + "ed", stem + "d"})
if stem in ("color", "colour"):
words.update({"color", "colour", "colors", "colours", "colored", "coloured"})
return words
def clean_text(text: str) -> str:
"""How an entry is shown and saved: weights, underscores and stray edge punctuation removed."""
text = _WEIGHTED.sub(r"\1", text or "")
if "__" not in text: # keep wildcard references intact
text = re.sub(r"(?<=[A-Za-z])_(?=[A-Za-z])", " ", text)
return _SPACES.sub(" ", _EDGE_PUNCT.sub("", text)).strip()
def fold_key(text: str, strip_words: Set[str] = frozenset()) -> str:
"""
The identity of an entry for merging: lower case, punctuation and hyphens as spaces, slot words
at either end dropped ('black-haired' -> 'black' for Hair color), a final plural s dropped.
"""
if "__" in text or "{" in text: # wildcard syntax: only exact repeats fold
return text.strip().lower()
folded = _FOLD_PUNCT.sub(" ", clean_text(text).lower().replace("-", " "))
words = _SPACES.sub(" ", folded).strip().split()
while len(words) > 1 and words[-1] in strip_words:
words.pop()
while len(words) > 1 and words[0] in strip_words:
words.pop(0)
if words and len(words[-1]) > 3 and words[-1].endswith("s") and not words[-1].endswith("ss"):
words[-1] = words[-1][:-1]
return " ".join(words)
@dataclass
class MergedRow:
text: str # the most common spelling (shortest, then fewest capitals)
fold: str
sources: List[int] = field(default_factory=list) # indexes into the source key list
variants: List[str] = field(default_factory=list) # other spellings folded into this row
@property
def lists(self) -> int:
return len(self.sources)
@property
def words(self) -> int:
return len(self.text.split())
def to_dict(self) -> Dict:
return {"text": self.text, "lists": self.lists, "sources": self.sources, "variants": self.variants[:6],
"words": self.words}
def merge_entries(lists: Sequence[Iterable[str]], strip_words: Set[str] = frozenset()) -> List[MergedRow]:
"""
One row per folded entry across the lists, most-shared first (then the order first seen).
lists: the entry texts of each source list, in source order.
"""
spellings: Dict[str, Counter] = {}
sources: Dict[str, Set[int]] = {}
order: Dict[str, int] = {}
for index, texts in enumerate(lists):
for raw in texts:
text = clean_text(raw)
if not text:
continue
key = fold_key(text, strip_words)
if not key:
continue
spellings.setdefault(key, Counter())[text] += 1
sources.setdefault(key, set()).add(index)
order.setdefault(key, len(order))
rows = []
for key, counter in spellings.items():
ranked = sorted(counter.items(), key=lambda item: (-item[1], len(item[0]), sum(c.isupper() for c in item[0]), item[0]))
rows.append(MergedRow(text=ranked[0][0], fold=key, sources=sorted(sources[key]),
variants=[text for text, _count in ranked[1:]]))
rows.sort(key=lambda row: (-row.lists, order[row.fold]))
return rows
def merged_name(name: str) -> str:
"""'My Hair colours!' -> 'my_hair_colours' ('merged' when nothing is left)."""
return _NAME.sub("_", (name or "").strip().lower()).strip("_-")[:60] or "merged"
def merged_path(root: str, slot_id: str, name: str) -> str:
return os.path.join(root, MINE_FOLDER, merged_name(slot_id), merged_name(name) + ".txt")
def merged_key(slot_id: str, name: str) -> str:
return f"{MINE_FOLDER}/{merged_name(slot_id)}/{merged_name(name)}"
def write_merged(path: str, entries: Sequence[str], sources: Sequence[str], slot_label: str = "",
overwrite: bool = False) -> int:
"""Write the kept entries (one per line) with a header naming the source lists. Returns the count."""
if os.path.exists(path) and not overwrite:
raise FileExistsError(path)
kept: List[str] = []
seen: Set[str] = set()
for entry in entries:
text = clean_text(str(entry)).replace("\n", " ")
if text and text.casefold() not in seen:
seen.add(text.casefold())
kept.append(text)
if not kept:
raise ValueError("nothing to save: every entry is unticked")
header = [f"# Merged by the Wildcard Composer on {time.strftime('%Y-%m-%d')}"
+ (f" for the {slot_label} button" if slot_label else "") + f": {len(kept)} entries.",
f"# From {len(sources)} lists (your own selection; check their licences before sharing):"]
header += [f"# {key}" for key in sources]
os.makedirs(os.path.dirname(path), exist_ok=True)
temp = path + ".tmp"
with open(temp, "w", encoding="utf-8") as handle:
handle.write("\n".join(header + kept) + "\n")
os.replace(temp, path)
return len(kept)
def pick_root(source_roots: Sequence[Optional[str]], search_paths: Sequence[str], avoid: str = "") -> Optional[str]:
"""
Where a merged list goes: the wildcard folder most of its sources come from, never the
package's own shipped folder (`avoid`) while another folder exists.
"""
def allowed(root: str) -> bool:
return not avoid or not os.path.normcase(os.path.abspath(root)).startswith(os.path.normcase(os.path.abspath(avoid)))
counted = Counter(root for root in source_roots if root and allowed(root))
if counted:
return counted.most_common(1)[0][0]
for root in search_paths:
if allowed(root):
return root
return search_paths[0] if search_paths else None