From d8d5e63618ddf5938db9f8b83fc36863676ec482 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Fri, 30 Oct 2026 13:58:09 -0700 Subject: [PATCH] fix(nodes): Pair node fills the prompt's empty category and tags (#546) The Pair node stored category and tags on the pair row only, so the Library showed its prompt as Uncategorized. A save now fills the positive prompt's category and tags where empty and never replaces a value. --- TESTING.md | 7 +++ database/pairs.py | 37 ++++++++++++++++ prompt_manager_pair.py | 10 +++++ tests/test_prompt_pair_node.py | 80 +++++++++++++++++++++++++++++++++- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/TESTING.md b/TESTING.md index 22ed40bd..3cfa0614 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2888,6 +2888,13 @@ with its own negative, a self-pair, each drift, validation); `tests/test_library copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python change: ComfyUI needs a restart. +### Pair node fills the prompt's empty category and tags (#546) + +A Pair save fills the positive prompt's category and tags where each is empty and never replaces a value; a prompt +with any tag keeps exactly those tags. The negative stays empty, the saved pair keeps its own metadata, and +`save_prompt_pair` itself still writes no component metadata. There is no backfill: a prompt fills when a Pair node +next executes. Tests: `tests/test_prompt_pair_node.py`. A Python change: ComfyUI needs a restart. + ### Delete… on the archived shelf (#492) Extends **D160** (coordinator ruling 2026-10-29, cutover item): the archived shelf's bar reads "N selected · Restore · diff --git a/database/pairs.py b/database/pairs.py index a132dbeb..f12578d7 100644 --- a/database/pairs.py +++ b/database/pairs.py @@ -195,6 +195,43 @@ class PairOperations: self._sync_pair_tags(conn, pair_id, tags) return pair_id, True + def fill_empty_prompt_metadata( + self, + prompt_id: int, + category: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> bool: + """Give a prompt a category and tags where it has none (#546). + + Each field fills on its own and never replaces a value already there. + Tags are not additive: a prompt with any tag keeps exactly those tags. + Returns True if the prompt changed. + """ + tags = _validate_metadata(None, category, tags, None, None) + with self._pair_transaction() as conn: + changed = False + if category: + changed = ( + conn.execute( + "UPDATE prompts SET category = ? WHERE id = ? " + "AND (category IS NULL OR TRIM(category) = '')", + (category, prompt_id), + ).rowcount + == 1 + ) + has_tags = conn.execute( + "SELECT 1 FROM prompt_tags WHERE prompt_id = ? LIMIT 1", (prompt_id,) + ).fetchone() + if tags and not has_tags: + self._sync_prompt_tags(conn, prompt_id, tags) + changed = True + if changed: + conn.execute( + "UPDATE prompts SET updated_at = ? WHERE id = ?", + (utc_now_iso(), prompt_id), + ) + return changed + def _read_pair_rows( self, conn: sqlite3.Connection, where: str, params: List[Any], suffix: str = "" ) -> List[Dict[str, Any]]: diff --git a/prompt_manager_pair.py b/prompt_manager_pair.py index e1bf8e45..a7967785 100644 --- a/prompt_manager_pair.py +++ b/prompt_manager_pair.py @@ -233,6 +233,16 @@ class PromptManagerPair(PromptManagerFocused): ) pair = self.db.get_prompt_pair(pair_id) prompt_id = pair["positive_prompt_id"] + # The Library shows the prompt's own category and tags, not the pair's. + # A failure here must not cost the run its count or image link. + try: + self.db.fill_empty_prompt_metadata( + prompt_id, + category=category.strip() or None, + tags=self._parse_tags(tags), + ) + except Exception as error: + self.logger.warning(f"Could not fill prompt metadata: {error}") resolved_positive = ( resolve_text_input(prompt, node_id, "text") if known else None ) diff --git a/tests/test_prompt_pair_node.py b/tests/test_prompt_pair_node.py index 671d4cdc..36d3a0be 100644 --- a/tests/test_prompt_pair_node.py +++ b/tests/test_prompt_pair_node.py @@ -13,6 +13,7 @@ except ImportError: # discovered with tests/ as the top-level directory from database.operations import PromptDatabase from prompt_manager_pair import PromptManagerPair +from utils.hashing import generate_prompt_hash from utils.usage_tracking import PendingFirstUse @@ -109,14 +110,89 @@ class TestPromptPairNode(unittest.TestCase): ), ("cat", "blur", "Animals", ["studio"]), ) - self.assertIsNone( - self.db.get_prompt_by_id(pair["positive_prompt_id"])["category"] + self.assertEqual( + self.db.get_prompt_by_id(pair["positive_prompt_id"])["category"], + "Animals", ) tracked = self.node.prompt_tracker.set_current_prompt.call_args.kwargs self.assertEqual(tracked["additional_data"]["pair_id"], pair["id"]) self.assertEqual(tracked["additional_data"]["negative_text"], " blur ") self.assertEqual(tracked["additional_data"]["positive_storage_text"], "cat") + def save(self, text="cat", negative="blur", **widgets): + self.node.encode_prompt( + self.clip, + text, + negative_text=negative, + prompt=pair_graph(text, negative), + unique_id="1", + **widgets, + ) + hash_of = generate_prompt_hash + return ( + self.db.get_prompt_by_hash(hash_of(text)), + self.db.get_prompt_by_hash(hash_of(negative)), + ) + + def test_first_save_gives_the_positive_its_category_and_tags(self): + positive, negative = self.save(category=" Animals ", tags="studio, macro") + self.assertEqual(positive["category"], "Animals") + self.assertCountEqual(positive["tags"], ["studio", "macro"]) + self.assertIsNone(negative["category"]) + self.assertEqual(negative["tags"], []) + + def test_a_later_save_never_changes_a_category_or_tags_already_set(self): + self.save(category="Animals", tags="studio") + positive, _ = self.save(negative="noise", category="Pets", tags="other") + self.assertEqual(positive["category"], "Animals") + self.assertEqual(positive["tags"], ["studio"]) + + def test_a_save_keeps_metadata_a_person_set_on_the_prompt(self): + text = "cat" + self.db.save_prompt( + text, + category="Mine", + tags=["kept"], + prompt_hash=generate_prompt_hash(text), + ) + positive, _ = self.save(category="Animals", tags="studio") + self.assertEqual(positive["category"], "Mine") + self.assertEqual(positive["tags"], ["kept"]) + + def test_category_and_tags_fill_independently_where_empty(self): + self.save(category="Animals") + positive, _ = self.save(negative="noise", category="Pets", tags="studio") + self.assertEqual(positive["category"], "Animals") + self.assertEqual(positive["tags"], ["studio"]) + + def test_blank_widgets_leave_the_prompt_empty(self): + positive, _ = self.save(category=" ", tags=" ") + self.assertIsNone(positive["category"]) + self.assertEqual(positive["tags"], []) + + def test_a_filled_prompt_does_not_change_the_saved_pair(self): + self.save(category="Animals", tags="studio") + positive, _ = self.save(category="Pets", tags="other") + pairs = self.db.list_prompt_pairs()["pairs"] + self.assertEqual( + [(pair["category"], pair["tags"]) for pair in pairs], + [("Animals", ["studio"])], + ) + self.assertEqual(positive["category"], "Animals") + + def test_a_failed_fill_still_tracks_the_run(self): + self.db.fill_empty_prompt_metadata = Mock(side_effect=RuntimeError("locked")) + self.save(category="Animals") + self.node.prompt_tracker.set_current_prompt.assert_called_once() + + def test_library_shows_filled_metadata_without_repeating_a_tag(self): + self.save(category="Animals", tags="studio") + item = self.db.list_library()["items"][0] + self.assertEqual(item["category"], "Animals") + self.assertEqual(item["tags"], ["studio"]) + tag = self.db.list_vocabulary_tags(limit=5)["tags"][0] + self.assertEqual(tag["prompt_count"], 1) + def test_empty_negative_encodes_empty_text_and_stores_null_component(self): result = self.node.encode_prompt(self.clip, "cat", negative_text="") self.assertEqual(result[3], "")