From 35eae9931809481d0bf435c5f2cfd099b27173b1 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 8 May 2026 21:13:51 +0300 Subject: [PATCH] Tighten Refiner V2 prompt repair intent matching --- CHANGELOG.md | 10 ++ README.md | 8 + conditioning.py | 119 +++++++++++++-- pyproject.toml | 2 +- tests/test_v2_category_alignment.py | 224 ++++++++++++++++++++++++++-- 5 files changed, 343 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfdf12a0..950ed6ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index fbc2d780..09e3f536 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/conditioning.py b/conditioning.py index b2041afd..73828691 100644 --- a/conditioning.py +++ b/conditioning.py @@ -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), diff --git a/pyproject.toml b/pyproject.toml index 043b7fce..4745f631 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/test_v2_category_alignment.py b/tests/test_v2_category_alignment.py index e8191bf4..95b8b534 100644 --- a/tests/test_v2_category_alignment.py +++ b/tests/test_v2_category_alignment.py @@ -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"]