Files
EricRollei-Local_LLM_Prompt…/concept_survival_node.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

176 lines
10 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": [{"concept": "<the concept exactly as given>", "present": true, "phrase": "<the exact words from the prompt that carry it>"}]} '
"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))
verdicts = {}
for item in parsed.get("results", []):
verdicts[str(item.get("concept", "")).strip().lower()] = (bool(item.get("present")), str(item.get("phrase", "") or ""))
return verdicts, ""
except Exception as error:
return None, f"the judge's answer was not usable JSON ({error})"
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, "", "{}")