- 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>
214 lines
12 KiB
Python
214 lines
12 KiB
Python
"""
|
|
Concept Survival Check.
|
|
|
|
Wildcard picks are concepts, not strings: the LLM rewriter may reword them as long as the meaning
|
|
survives. This node measures whether it did. Connect the prompt an enhancer produced and the
|
|
llm_instructions that went with it (from a Wildcard Processor or Semantic Variation node).
|
|
|
|
Two ways of judging, chosen with `method`:
|
|
|
|
embedding Cuts the prompt into windows of words and scores each concept against them in embedding
|
|
space. Fast, no LLM. Measured with Qwen3-VL-Embedding-2B (dev/try_directions_and_survival.py):
|
|
for THINGS, MATERIALS and PLACES it separates well - a faithful paraphrase scores about
|
|
0.69-0.74, the same scene with the pick dropped 0.43-0.60. For RELATIONS and CONDITIONS
|
|
("lit only from below", "the weather is indoors") it does not: an embedding hears the
|
|
topic, not who does what to whom, so window light scores as high as light from below.
|
|
llm Asks the LLM backend, concept by concept, whether the prompt states the idea and which
|
|
words carry it. Slower, needs a model loaded, but it reads relations.
|
|
both The LLM decides; the embedding score is shown next to it. If the LLM call fails the
|
|
embedding verdict is used.
|
|
|
|
Verbatim picks are always checked by exact match.
|
|
|
|
Outputs: a report, all_survived (BOOLEAN, for gating a retry), the missing items, and a JSON map of
|
|
concept -> carrying phrase, score and verdict.
|
|
|
|
Author: Eric Hiss (GitHub: EricRollei)
|
|
License: Dual License (Non-Commercial and Commercial Use). See LICENSE.
|
|
"""
|
|
|
|
import json
|
|
import traceback
|
|
|
|
from .core.variability import score_concepts
|
|
from .variability_support import EMBEDDER_CHOICES, get_embedder
|
|
from .wildcard_support import concepts_from_instructions, verbatim_from_instructions
|
|
|
|
DEFAULT_THRESHOLD = 0.62
|
|
METHODS = ["embedding", "llm", "both"]
|
|
|
|
JUDGE_SYSTEM = (
|
|
"You check whether ideas survived a rewrite. You are given a PROMPT and a list of CONCEPTS. For each concept "
|
|
"decide whether the prompt actually states that idea, in any wording. A related topic does not count: a pigeon "
|
|
"is not a barn owl, light from a window is not light from below, rain outside is not rain indoors. "
|
|
"Answer with JSON only, no other text, in this shape: "
|
|
'{"results": [{"n": 1, "concept": "<the concept exactly as given>", "present": true, "phrase": "<the exact words from the prompt that carry it>"}]} '
|
|
"One result per concept, in the same order, with its number in n. "
|
|
"Use present=false and an empty phrase when the idea is missing."
|
|
)
|
|
|
|
|
|
def judge_with_llm(prompt, concepts, backend, endpoint, model):
|
|
"""{concept: (present, phrase)} from the LLM backend, or None when the call or the JSON fails."""
|
|
from .core.profiles.validate import clean_llm_output, extract_json_object
|
|
from .llm_backend import LLMBackend
|
|
|
|
numbered = "\n".join(f"{index}. {concept}" for index, concept in enumerate(concepts, 1))
|
|
user = f"PROMPT:\n{prompt}\n\nCONCEPTS:\n{numbered}"
|
|
llm = LLMBackend(backend, endpoint, model or None, 0.0)
|
|
response = llm.send_prompt(JUDGE_SYSTEM, user, max_tokens=1500)
|
|
if not response.get("success"):
|
|
return None, response.get("error", "LLM call failed")
|
|
try:
|
|
parsed = extract_json_object(clean_llm_output(response.get("response", ""), keep_json=True))
|
|
return _match_verdicts(parsed.get("results", []), concepts), ""
|
|
except Exception as error:
|
|
return None, f"the judge's answer was not usable JSON ({error})"
|
|
|
|
|
|
def _words(text):
|
|
import re
|
|
return set(re.findall(r"[a-z]{3,}", (text or "").lower()))
|
|
|
|
|
|
def _match_verdicts(results, concepts):
|
|
"""{concept.lower(): (present, phrase)}. Matched by the concept's number, then by its exact text, then by word
|
|
overlap, then by position. Keying on the exact text alone lost every verdict whenever the judge echoed a
|
|
concept slightly differently (live 2026-10-01, two-sentence surreal concepts: six of nine runs reported
|
|
'[none]' for anomalies that were plainly in the prompt)."""
|
|
verdicts, unmatched = {}, []
|
|
lowered = [concept.strip().lower() for concept in concepts]
|
|
for position, item in enumerate(results if isinstance(results, list) else []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
value = (bool(item.get("present")), str(item.get("phrase", "") or ""))
|
|
key = None
|
|
try:
|
|
number = int(item.get("n"))
|
|
if 1 <= number <= len(concepts):
|
|
key = lowered[number - 1]
|
|
except (TypeError, ValueError):
|
|
pass
|
|
text = str(item.get("concept", "")).strip().lower()
|
|
if key is None and text in lowered:
|
|
key = text
|
|
if key is None and text:
|
|
best = max(lowered, key=lambda concept: len(_words(concept) & _words(text)), default=None)
|
|
if best is not None and len(_words(best) & _words(text)) >= max(2, len(_words(best)) // 3):
|
|
key = best
|
|
if key is None:
|
|
unmatched.append((position, value))
|
|
continue
|
|
verdicts.setdefault(key, value)
|
|
for position, value in unmatched: # last resort: same position as the concept list
|
|
if position < len(lowered) and lowered[position] not in verdicts:
|
|
verdicts[lowered[position]] = value
|
|
return verdicts
|
|
|
|
|
|
class ConceptSurvivalCheck:
|
|
CATEGORY = "Eric Prompt Enhancers"
|
|
FUNCTION = "check"
|
|
RETURN_TYPES = ("STRING", "BOOLEAN", "STRING", "STRING")
|
|
RETURN_NAMES = ("report", "all_survived", "missing", "carried_by_json")
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"prompt": ("STRING", {"forceInput": True, "tooltip": "The rewritten prompt to check."}),
|
|
"llm_instructions": ("STRING", {
|
|
"forceInput": True,
|
|
"tooltip": "The llm_instructions output of the Wildcard Processor or Semantic Variation node that made the picks.",
|
|
}),
|
|
"threshold": ("FLOAT", {
|
|
"default": DEFAULT_THRESHOLD, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"tooltip": (
|
|
"Embedding method: a concept survived when its best-matching phrase scores at least this.\n"
|
|
"Measured with Qwen3-VL-Embedding-2B on things, materials and places: faithful paraphrase\n"
|
|
"0.69-0.74, same scene with the pick dropped 0.43-0.60, unrelated prompt 0.26-0.45.\n"
|
|
"Other embedders sit elsewhere: read the report, then set this."
|
|
),
|
|
}),
|
|
"embedder": (EMBEDDER_CHOICES, {
|
|
"default": "auto",
|
|
"tooltip": "auto = connected clip, else Qwen3-VL-Embedding, else open_clip. lexical cannot judge meaning.",
|
|
}),
|
|
},
|
|
"optional": {
|
|
"clip": ("CLIP", {"tooltip": "Optional. Used as the embedder when `embedder` is auto or graph_clip."}),
|
|
"method": (METHODS, {
|
|
"default": "embedding",
|
|
"tooltip": (
|
|
"embedding = fast, no LLM; reliable for things, materials and places, NOT for relations\n"
|
|
" such as 'lit only from below' (an embedding hears the topic, not the relation).\n"
|
|
"llm = ask the LLM backend whether each idea is stated; reads relations; needs a model loaded.\n"
|
|
"both = the LLM decides, the embedding score is shown beside it."
|
|
),
|
|
}),
|
|
"backend": (["lm_studio", "ollama", "qwen3_vl"], {"default": "lm_studio", "tooltip": "For method llm / both."}),
|
|
"endpoint": ("STRING", {"default": "http://localhost:1234/v1", "tooltip": "For method llm / both."}),
|
|
"model": ("STRING", {"default": "", "tooltip": "For method llm / both. Blank = whatever model is loaded."}),
|
|
},
|
|
}
|
|
|
|
def check(self, prompt, llm_instructions, threshold, embedder, clip=None, method="embedding",
|
|
backend="lm_studio", endpoint="http://localhost:1234/v1", model=""):
|
|
try:
|
|
concepts = concepts_from_instructions(llm_instructions)
|
|
verbatim = verbatim_from_instructions(llm_instructions)
|
|
if not concepts and not verbatim:
|
|
return ("No concepts or verbatim picks in llm_instructions - nothing to check.", True, "", "{}")
|
|
|
|
lines = []
|
|
scores = []
|
|
if concepts and method in ("embedding", "both"):
|
|
chosen_embedder, notes = get_embedder(embedder, clip)
|
|
scores = score_concepts(chosen_embedder, prompt or "", concepts, float(threshold))
|
|
lines.append(f"embedder: {chosen_embedder.describe()} threshold: {float(threshold):.2f}")
|
|
if chosen_embedder.name.startswith("lexical"):
|
|
lines.append("WARNING: the lexical embedder compares spelling, not meaning. Concept scores here say little.")
|
|
lines.extend(notes)
|
|
by_concept = {score.concept: score for score in scores}
|
|
|
|
verdicts = None
|
|
if concepts and method in ("llm", "both"):
|
|
verdicts, problem = judge_with_llm(prompt or "", concepts, backend, endpoint, model)
|
|
if verdicts is None:
|
|
lines.append(f"LLM judge unavailable: {problem}" + ("; using the embedding verdict" if scores else ""))
|
|
if not scores: # method = llm and it failed: fall back rather than report nothing
|
|
chosen_embedder, notes = get_embedder(embedder, clip)
|
|
scores = score_concepts(chosen_embedder, prompt or "", concepts, float(threshold))
|
|
by_concept = {score.concept: score for score in scores}
|
|
lines.append(f"fell back to embedder: {chosen_embedder.describe()} threshold: {float(threshold):.2f}")
|
|
|
|
carried_by = {}
|
|
missing_concepts = []
|
|
for concept in concepts:
|
|
score = by_concept.get(concept)
|
|
judged = verdicts.get(concept.strip().lower()) if verdicts else None
|
|
if judged is not None:
|
|
survived, phrase, judge = judged[0], judged[1], "llm"
|
|
elif score is not None:
|
|
survived, phrase, judge = score.survived, score.carried_by, "embedding"
|
|
else:
|
|
survived, phrase, judge = False, "", "none"
|
|
if not survived:
|
|
missing_concepts.append(concept)
|
|
number = f"{score.score:.2f}" if score is not None else " -- "
|
|
shown = f'"{phrase}"' if phrase else ("(nearest: \"" + score.carried_by + "\")" if score is not None and score.carried_by else "(nothing)")
|
|
lines.append(f" {'ok ' if survived else 'MISSING'} {number} [{judge}] {concept} <- {shown}")
|
|
carried_by[concept] = {"phrase": phrase, "score": round(score.score, 3) if score is not None else None,
|
|
"survived": survived, "judged_by": judge}
|
|
|
|
lowered = (prompt or "").lower()
|
|
missing_verbatim = [value for value in verbatim if value.lower() not in lowered]
|
|
for value in verbatim:
|
|
lines.append(f" {'MISSING' if value in missing_verbatim else 'ok '} exact \"{value}\"")
|
|
|
|
missing = missing_concepts + missing_verbatim
|
|
return ("\n".join(lines), not missing, ", ".join(missing), json.dumps(carried_by, ensure_ascii=False))
|
|
except Exception as error: # a check must never break the graph
|
|
traceback.print_exc()
|
|
return (f"Concept Survival Check error: {error}", True, "", "{}")
|