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.
This commit is contained in:
Vito Sansevero
2026-10-30 14:24:59 -07:00
parent d8d5e63618
commit d20842ac7a
4 changed files with 49 additions and 65 deletions
+6 -5
View File
@@ -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)
+16 -40
View File
@@ -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]]:
+2 -10
View File
@@ -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
)
+25 -10
View File
@@ -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]