From d20842ac7a5c2fb5e2842ed3d424b60a7d623a3b Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Fri, 30 Oct 2026 14:24:59 -0700 Subject: [PATCH] fix(nodes): fill a Pair prompt's category and tags only when the row is created (#546) An existing prompt never changes on a run, so a cleared category stays cleared. The write now happens in the save's transaction, replacing the fill-if-empty call. --- TESTING.md | 11 ++++--- database/pairs.py | 56 ++++++++++------------------------ prompt_manager_pair.py | 12 ++------ tests/test_prompt_pair_node.py | 35 +++++++++++++++------ 4 files changed, 49 insertions(+), 65 deletions(-) diff --git a/TESTING.md b/TESTING.md index 3cfa0614..abafc620 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2888,12 +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) +### Pair node gives a new prompt its 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. +A Pair save writes the category and tags to the positive prompt only when that save creates the prompt row (maintainer +ruling). An existing prompt never changes on a run: empty, cleared or set by a person, including one first seen as +another pair's negative. The negative stays empty, the saved pair keeps its own metadata, and `save_prompt_pair` writes +no prompt metadata unless called with `fill_new_positive`. No backfill; optional repair is #550. +Tests: `tests/test_prompt_pair_node.py`. A Python change: ComfyUI needs a restart. ### Delete… on the archived shelf (#492) diff --git a/database/pairs.py b/database/pairs.py index f12578d7..aba67ccf 100644 --- a/database/pairs.py +++ b/database/pairs.py @@ -112,8 +112,14 @@ class PairOperations: tags: Optional[List[str]] = None, rating: Optional[int] = None, notes: Optional[str] = None, + fill_new_positive: bool = False, ) -> int: - """Save an ordered pair atomically; repeat saves preserve its metadata.""" + """Save an ordered pair atomically; repeat saves preserve its metadata. + + ``fill_new_positive`` also gives the positive prompt the category and + tags, only if this save inserted its row (#546). An existing prompt + never changes, so a cleared category stays cleared. + """ with self._pair_transaction() as conn: return self._save_pair( conn, @@ -124,6 +130,7 @@ class PairOperations: tags=tags, rating=rating, notes=notes, + fill_new_positive=fill_new_positive, ) def _save_pair( @@ -137,6 +144,7 @@ class PairOperations: tags: Optional[List[str]] = None, rating: Optional[int] = None, notes: Optional[str] = None, + fill_new_positive: bool = False, ) -> int: """Save an ordered pair atomically; repeat saves preserve its metadata.""" if not isinstance(positive_text, str) or not positive_text.strip(): @@ -144,8 +152,13 @@ class PairOperations: if not isinstance(negative_text, str): raise ValueError("Negative prompt text must be a string; use '' for empty") # Validate metadata before any component insertion. - _validate_metadata(title, category, tags, rating, notes) - positive = self._pair_component(conn, positive_text) + tags = _validate_metadata(title, category, tags, rating, notes) + positive, created = self._pair_component_outcome(conn, positive_text) + if created and fill_new_positive: + conn.execute( + "UPDATE prompts SET category = ? WHERE id = ?", (category, positive) + ) + self._sync_prompt_tags(conn, positive, tags or []) negative = ( self._pair_component(conn, negative_text) if negative_text.strip() else None ) @@ -195,43 +208,6 @@ 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 a7967785..4dcb5d2b 100644 --- a/prompt_manager_pair.py +++ b/prompt_manager_pair.py @@ -230,19 +230,11 @@ class PromptManagerPair(PromptManagerFocused): negative_text, category=category.strip() or None, tags=self._parse_tags(tags), + # The Library shows the prompt's own category and tags, not the pair's. + fill_new_positive=True, ) 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 36d3a0be..fff212aa 100644 --- a/tests/test_prompt_pair_node.py +++ b/tests/test_prompt_pair_node.py @@ -159,11 +159,31 @@ class TestPromptPairNode(unittest.TestCase): 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_a_run_on_an_existing_empty_prompt_changes_nothing(self): + self.db.save_prompt("cat", prompt_hash=generate_prompt_hash("cat")) + before = self.db.get_prompt_by_hash(generate_prompt_hash("cat"))["updated_at"] + positive, _ = self.save(category="Animals", tags="studio") + self.assertIsNone(positive["category"]) + self.assertEqual(positive["tags"], []) + self.assertEqual(positive["updated_at"], before) + + def test_a_pair_saved_without_metadata_stays_empty_on_a_later_run(self): + self.save() + positive, _ = self.save(negative="noise", category="Animals", tags="studio") + self.assertIsNone(positive["category"]) + self.assertEqual(positive["tags"], []) + + def test_a_cleared_category_and_tags_stay_cleared(self): + positive, _ = self.save(category="Animals", tags="studio") + self.db.update_prompt_metadata(positive["id"], category="", tags=[]) + positive, _ = self.save(negative="noise", category="Animals", tags="studio") + self.assertEqual(positive["category"], "") + self.assertEqual(positive["tags"], []) + + def test_a_prompt_first_seen_as_a_negative_stays_empty(self): + self.save(text="dog", negative="cat") + positive, _ = self.save(text="cat", negative="blur", category="Animals") + self.assertIsNone(positive["category"]) def test_blank_widgets_leave_the_prompt_empty(self): positive, _ = self.save(category=" ", tags=" ") @@ -180,11 +200,6 @@ class TestPromptPairNode(unittest.TestCase): ) 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]