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.
This commit is contained in:
Vito Sansevero
2026-10-30 13:58:09 -07:00
parent a6d6129ec6
commit d8d5e63618
4 changed files with 132 additions and 2 deletions
+7
View File
@@ -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 ·
+37
View File
@@ -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]]:
+10
View File
@@ -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
)
+78 -2
View File
@@ -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], "")