Tighten Refiner V2 prompt repair intent matching

This commit is contained in:
digital-garbage
2026-05-08 21:13:51 +03:00
parent a1dc431be5
commit 35eae99318
5 changed files with 343 additions and 20 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [2.3.1] - 2026-05-08
### Fixed
Fixed Refiner V2 Prompt Repair so missing/wrong ratings only repair from the current prompt or explicit user intent, instead of pulling unrelated learned favorite actions, details, quality cues, camera moves, or styles from memory.
Fixed Prompt Repair memory matching so the same word with different neighboring prompt context is treated as different evidence.
Fixed vague raw user intent handling so prompts like `Figure it out` let the enhanced `positive_prompt` drive repair matching when available.
## [2.3.0] - 2026-05-08
### Added
+8
View File
@@ -2,6 +2,14 @@
A set of ComfyUI nodes for experimenting with video generation workflows based on WAN, HunyuanVideo, LTX, and similar models.
## Updates in 2.3.1
Refiner V2 Prompt Repair now keeps all missing/wrong ratings tied to the current prompt or explicit user intent. Learned favorite actions, details, quality cues, camera moves, and styles are not repaired into unrelated requests just because they scored well before.
Prompt Repair now treats the same word in different neighbor contexts as separate evidence, so a liked phrase does not automatically transfer to a different request that happens to share one word.
When the optional raw user intent is vague, such as `Figure it out`, Refiner V2 now treats the enhanced `positive_prompt` as the stronger repair anchor.
## Updates in 2.3.0
Refiner V2 now blocks learned appearance, character, subject, and background concepts from Prompt Repair, Lucky auto-injection, and legacy Void memory unless the current prompt explicitly asks for them.
+110 -9
View File
@@ -7202,6 +7202,85 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
text_words = set(self._v2_phrase_words(text_key))
return bool(text_words) and text_words <= prompt_words
def _v2_repair_intent_roots(self, text):
roots = set()
for word in self._v2_phrase_words(text):
roots.add(word)
if len(word) > 4 and word.endswith("ing"):
stem = word[:-3]
roots.add(stem)
if len(stem) > 2 and stem[-1] == stem[-2]:
roots.add(stem[:-1])
roots.add(stem + "e")
elif len(word) > 3 and word.endswith("ed"):
stem = word[:-2]
roots.add(stem)
roots.add(stem + "e")
elif len(word) > 3 and word.endswith("s"):
roots.add(word[:-1])
return {root for root in roots if len(root) >= 3}
def _v2_repair_candidate_matches_intent(self, text, intent_roots):
if not intent_roots:
return False
candidate_roots = self._v2_repair_intent_roots(text)
return bool(candidate_roots & intent_roots)
def _v2_user_intent_prompt_is_vague(self, prompt):
text = re.sub(r"\s+", " ", str(prompt or "").strip().lower())
if not text:
return True
words = set(self._v2_phrase_words(text))
if not words:
return True
vague_words = {
"figure", "out", "something", "anything", "whatever", "surprise", "random",
"choice", "choose", "decide", "good", "nice", "cool", "best", "you", "your",
"it", "make", "do",
}
if len(words) <= 5 and words <= vague_words:
return True
if re.fullmatch(r"(figure it out|surprise me|anything|whatever|your choice|you decide|do something|make it good|make it cool)", text):
return True
scores = self._v2_heuristic_scores(text)
return len(words) <= 3 and not self._v2_axes_for_scores(scores)
def _v2_repair_reference_context(self, text, reference_phrases):
candidate_roots = self._v2_repair_intent_roots(text)
if not candidate_roots:
return {}
best_phrase = None
best_overlap = 0
for phrase in reference_phrases or []:
if not isinstance(phrase, dict):
continue
overlap = len(candidate_roots & self._v2_repair_intent_roots(phrase.get("text", "")))
if overlap > best_overlap:
best_phrase = phrase
best_overlap = overlap
if not isinstance(best_phrase, dict):
return {}
return best_phrase.get("context", {}) if isinstance(best_phrase.get("context", {}), dict) else {}
def _v2_repair_context_matches_memory(self, text, entry, reference_phrases):
if not isinstance(entry, dict):
return True
context = self._v2_repair_reference_context(text, reference_phrases)
context_words = context.get("context_words", []) if isinstance(context, dict) else []
senses = entry.get("context_senses", {})
if isinstance(senses, dict) and senses:
sense, similarity = self._v2_best_context_sense(entry, context)
if isinstance(sense, dict) and similarity >= 0.30:
return True
return len(self._v2_phrase_words(text)) > 1 and not context_words
learned_context = entry.get("context", {})
learned_words = learned_context.get("context_words", []) if isinstance(learned_context, dict) else []
if learned_words and context_words:
return self._v2_context_similarity(learned_context, context) >= 0.30
if learned_words and len(self._v2_phrase_words(text)) <= 1:
return False
return True
def _v2_repair_prompt_for_missing_axes(
self,
prompt,
@@ -7217,6 +7296,14 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
return prompt, "Prompt repair: none.", []
candidates = {}
intent_roots = self._v2_repair_intent_roots(prompt)
reference_phrases = [
phrase
for phrase in list(current_phrases or []) + list(intent_phrases or [])
if isinstance(phrase, dict)
]
for item in intent_phrases or []:
intent_roots |= self._v2_repair_intent_roots(item.get("text", ""))
def candidate_axes(item):
if not isinstance(item, dict):
@@ -7230,7 +7317,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
axes |= {axis for axis, count in axis_counts.items() if int(count or 0) > 0}
return axes
def add_candidate(text, axes, score, source, cluster=None):
def add_candidate(text, axes, score, source, cluster=None, memory_entry=None):
clean = self._v2_clean_phrase_text(text)
if not clean:
return
@@ -7242,10 +7329,18 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
axes = set(axes or set()) & missing_axes
if not axes or self._v2_prompt_contains_text(prompt, clean):
return
if source != "intent" and not self._v2_repair_candidate_matches_intent(clean, intent_roots):
return
if source != "intent" and not self._v2_repair_context_matches_memory(clean, memory_entry, reference_phrases):
return
safe_cluster = [
self._v2_clean_phrase_text(item)
for item in (cluster or [])
if self._v2_prompt_repair_text_allowed(item)
if (
self._v2_prompt_repair_text_allowed(item) and
self._v2_repair_candidate_matches_intent(item, intent_roots) and
self._v2_repair_context_matches_memory(item, memory_entry, reference_phrases)
)
]
safe_cluster = [item for item in safe_cluster if item]
existing = candidates.get(clean)
@@ -7270,7 +7365,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
continue
axes = candidate_axes(item)
if axes & missing_axes:
add_candidate(item.get("text", ""), axes, 1.75, "previous")
add_candidate(item.get("text", ""), axes, 1.75, "previous", memory_entry=item)
preferred_memory = global_state.get("preferred_context_memory", {}) if isinstance(global_state, dict) else {}
for entry in preferred_memory.values() if isinstance(preferred_memory, dict) else []:
@@ -7295,9 +7390,9 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
)
if cluster:
for offset, item_text in enumerate(cluster[:8]):
add_candidate(item_text, axes, score - offset * 0.03, "preferred_context", cluster=cluster[:8])
add_candidate(item_text, axes, score - offset * 0.03, "preferred_context", cluster=cluster[:8], memory_entry=entry)
else:
add_candidate(entry.get("anchor", ""), axes, score, "preferred_context")
add_candidate(entry.get("anchor", ""), axes, score, "preferred_context", memory_entry=entry)
memory = global_state.get("phrase_memory", {}) if isinstance(global_state, dict) else {}
for text, entry in memory.items():
@@ -7317,19 +7412,24 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
min(0.9, int(entry.get("wrong_count", 0) or 0) * 0.16)
)
if axes & missing_axes and score > 0.60:
add_candidate(entry.get("text", text), axes, score, "memory")
add_candidate(entry.get("text", text), axes, score, "memory", memory_entry=entry)
current_axes = set()
for phrase in current_phrases or []:
current_axes |= candidate_axes(phrase)
missing_not_represented = missing_axes - current_axes
source_priority = {
"intent": 4,
"previous": 3,
"preferred_context": 2,
"memory": 1,
}
ranked = sorted(
candidates.values(),
key=lambda item: (
bool(item["axes"] & missing_not_represented),
item["source"] == "preferred_context",
item["source"] == "intent",
source_priority.get(item["source"], 0),
item["score"],
len(item["text"]),
),
@@ -7607,10 +7707,11 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
analysis_prompt = self._v2_prompt_key(positive_prompt)
intent_prompt = self._v2_prompt_key(user_intent_prompt)
intent_prompt_is_vague = self._v2_user_intent_prompt_is_vague(intent_prompt)
current_prompt_refusal = self._prompt_looks_like_refusal(analysis_prompt)
phrases = [] if current_prompt_refusal else self._v2_classify_phrases(clip, self._ordered_prompt_phrases(analysis_prompt), global_state)
intent_phrases = []
if intent_prompt and not current_prompt_refusal:
if intent_prompt and not intent_prompt_is_vague and not current_prompt_refusal:
intent_phrases = self._v2_classify_phrases(
clip,
self._ordered_prompt_phrases(intent_prompt),
+1 -1
View File
@@ -2,7 +2,7 @@
[project]
name = "ComfyUI-FunPack" # Unique identifier for your node. Immutable after creation.
description = "A set of nodes for experiments with video generation models, including prompt refinement, adaptive LoRA weight workflows, story tools, video stitching, lorebook enhancement and more."
version = "2.3.0" # Custom Node version. Must be semantically versioned.
version = "2.3.1" # Custom Node version. Must be semantically versioned.
license = { text = "GNU GPL v3" }
dependencies = [] # Filled in from requirements.txt
+214 -10
View File
@@ -640,6 +640,7 @@ def test_normal_previous_run_can_train_before_current_refusal_is_discarded(tmp_p
def test_liked_action_detail_context_clusters_repair_missing_axes():
refiner = FunPackVideoRefinerV2()
global_state = {"phrase_memory": {}, "preferred_context_memory": {}}
rich_prompt = "woman walking reaching hand tiny particles rain reflections"
liked_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, [
@@ -656,11 +657,18 @@ def test_liked_action_detail_context_clusters_repair_missing_axes():
refiner._v2_update_phrase_memory(
global_state,
{"prompt": "woman walking reaching hand tiny particles rain reflections", "phrases": liked_phrases},
{"prompt": rich_prompt, "phrases": liked_phrases},
profile,
1,
feedback,
)
assert "Preferred context stored" in refiner._v2_update_preferred_context_memory(
global_state,
{"phrases": liked_phrases},
profile,
2,
feedback,
)
missing_profile = normalize_refiner_v2_rating("Missing details + action")
missing_feedback = refiner._v2_axis_feedback(missing_profile, None)
@@ -669,6 +677,11 @@ def test_liked_action_detail_context_clusters_repair_missing_axes():
prompt_items(refiner, ["woman", "rain"]),
global_state,
)
intent_phrases = refiner._v2_classify_phrases(
None,
refiner._ordered_prompt_phrases(rich_prompt),
global_state,
)
repaired, status, candidates = refiner._v2_repair_prompt_for_missing_axes(
"woman, rain",
@@ -676,21 +689,206 @@ def test_liked_action_detail_context_clusters_repair_missing_axes():
global_state,
None,
missing_feedback,
intent_phrases=intent_phrases,
)
assert "Preferred context stored" in refiner._v2_update_preferred_context_memory(
global_state,
{"phrases": liked_phrases},
profile,
2,
feedback,
)
assert "walking" in repaired
assert "tiny particles" in repaired
assert "preferred_context" in status
assert "intent" in status or "preferred_context" in status
assert len(candidates) >= 2
def test_prompt_repair_does_not_pull_unrequested_memory_for_any_missing_axis():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {
"walking": {
"text": "walking",
"primary": "action",
"effective_category_scores": refiner._v2_heuristic_scores("walking"),
"wanted_axes": {"action": 5},
"score": 5.0,
"liked_count": 5,
},
"tiny particles": {
"text": "tiny particles",
"primary": "details",
"effective_category_scores": refiner._v2_heuristic_scores("tiny particles"),
"wanted_axes": {"details": 5},
"score": 5.0,
"liked_count": 5,
},
"cinematic lighting": {
"text": "cinematic lighting",
"primary": "style",
"effective_category_scores": refiner._v2_heuristic_scores("cinematic lighting"),
"wanted_axes": {"quality": 5},
"score": 5.0,
"liked_count": 5,
},
"glass reflections": {
"text": "glass reflections",
"primary": "details",
"effective_category_scores": refiner._v2_heuristic_scores("glass reflections"),
"wanted_axes": {"details": 2},
"score": 2.0,
"liked_count": 1,
},
},
}
profile = normalize_refiner_v2_rating("Awful")
feedback = refiner._v2_axis_feedback(profile, None)
current_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, ["woman", "glass"]),
global_state,
)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"woman, glass",
current_phrases,
global_state,
None,
feedback,
)
candidate_texts = {candidate["text"] for candidate in candidates}
assert "glass reflections" in repaired
assert "walking" not in repaired
assert "tiny particles" not in repaired
assert "cinematic lighting" not in repaired
assert "walking" not in candidate_texts
assert "tiny particles" not in candidate_texts
assert "cinematic lighting" not in candidate_texts
def test_prompt_repair_treats_same_word_with_different_neighbours_as_different():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {
"turning head": {
"text": "turning head",
"primary": "action",
"effective_category_scores": refiner._v2_heuristic_scores("turning head"),
"wanted_axes": {"action": 4},
"score": 4.0,
"liked_count": 4,
"context_senses": {
"mid|eyes,portrait": {
"context": {
"anchor_words": ["head", "turning"],
"context_words": ["portrait", "eyes"],
"position_bucket": "mid",
"window": 3,
},
"category_weights": refiner._v2_category_template(0.0),
"effective_category_scores": refiner._v2_heuristic_scores("turning head"),
"category_evidence_count": 4,
"occurrence_count": 4,
},
},
},
},
}
profile = normalize_refiner_v2_rating("Missing action")
feedback = refiner._v2_axis_feedback(profile, None)
current_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, ["car", "turning wheel", "street"]),
global_state,
)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"car, turning wheel, street",
current_phrases,
global_state,
None,
feedback,
)
assert "turning head" not in repaired
assert all(candidate["text"] != "turning head" for candidate in candidates)
def test_vague_user_intent_lets_enhanced_positive_prompt_anchor_repair():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {
"walking slowly": {
"text": "walking slowly",
"primary": "action",
"effective_category_scores": refiner._v2_heuristic_scores("walking slowly"),
"wanted_axes": {"action": 3},
"score": 3.0,
"liked_count": 3,
},
},
}
assert refiner._v2_user_intent_prompt_is_vague("Figure it out")
assert not refiner._v2_user_intent_prompt_is_vague("person walking")
profile = normalize_refiner_v2_rating("Missing action")
feedback = refiner._v2_axis_feedback(profile, None)
current_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, ["woman", "walking through glass hallway"]),
global_state,
)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"woman, walking through glass hallway",
current_phrases,
global_state,
None,
feedback,
intent_phrases=[],
)
assert "walking slowly" in repaired
assert any(candidate["text"] == "walking slowly" for candidate in candidates)
def test_complex_user_intent_ranks_before_matching_preferred_memory():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {},
"preferred_context_memory": {
"reach-memory": {
"anchor": "reaching hand slowly",
"axes": {"action": 4},
"context": {},
"phrases": ["reaching hand slowly"],
"score": 10.0,
"liked_count": 8,
},
},
}
profile = normalize_refiner_v2_rating("Missing action")
feedback = refiner._v2_axis_feedback(profile, None)
current_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, ["woman"]),
global_state,
)
intent_phrases = refiner._v2_classify_phrases(
None,
refiner._ordered_prompt_phrases("woman reaching hand toward glass"),
global_state,
)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"woman",
current_phrases,
global_state,
None,
feedback,
intent_phrases=intent_phrases,
)
assert "reaching hand toward glass" in repaired
assert candidates[0]["source"] == "intent"
def test_phrase_clusters_train_more_strongly_than_ngrams_and_tokens():
refiner = FunPackVideoRefinerV2()
global_state = {"phrase_memory": {}, "preferred_context_memory": {}}
@@ -725,7 +923,13 @@ def test_repair_candidates_saved_as_json_lists(tmp_path):
refiner.refine_v2(rich_prompt, FakeClip(), "Perfect", "json-repair-test")
refiner.refine_v2(rich_prompt, FakeClip(), "Perfect", "json-repair-test")
refiner.refine_v2("woman, rain", FakeClip(), "Missing details + action", "json-repair-test")
refiner.refine_v2(
"woman, rain",
FakeClip(),
"Missing details + action",
"json-repair-test",
user_intent_prompt=rich_prompt,
)
state = json.loads(state_path.read_text(encoding="utf-8"))
candidates = state["last_run"]["repair_candidates"]