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:
@@ -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 ·
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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], "")
|
||||
|
||||
Reference in New Issue
Block a user