diff --git a/TESTING.md b/TESTING.md index fcf6a83b..aaa3cf0a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2924,6 +2924,14 @@ 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 gives a new prompt its category and tags (#546) + +A Pair save, by a run or the node's Save pair button, 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`, `tests/test_api_pairs.py`, `tests/comfyui/pair-node.spec.mjs`. 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..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 ) diff --git a/prompt_manager_pair.py b/prompt_manager_pair.py index e1bf8e45..4dcb5d2b 100644 --- a/prompt_manager_pair.py +++ b/prompt_manager_pair.py @@ -230,6 +230,8 @@ 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"] diff --git a/py/api/pairs.py b/py/api/pairs.py index 775f0e8a..102271a4 100644 --- a/py/api/pairs.py +++ b/py/api/pairs.py @@ -102,7 +102,12 @@ class PairRoutesMixin: validate_prompt_text(negative) fields = metadata_fields(data) pair_id = await self._run_in_executor( - self.db.save_prompt_pair, positive, negative, **fields + self.db.save_prompt_pair, + positive, + negative, + # The node's Save pair button: as a run, a new prompt gets the metadata. + fill_new_positive=True, + **fields, ) pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id) return web.json_response({"success": True, "pair": pair}) diff --git a/tests/comfyui/pair-node.spec.mjs b/tests/comfyui/pair-node.spec.mjs index c9a4c485..aa8a2610 100644 --- a/tests/comfyui/pair-node.spec.mjs +++ b/tests/comfyui/pair-node.spec.mjs @@ -55,9 +55,10 @@ test('paired default layout uses equal editors and follows real host themes',asy } }); -test('paired explicit Save persists an empty negative through actual API and preserves local draft',async({page,request},testInfo)=>{ - const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByRole('button',{name:'Save pair',exact:true}).click(); +test('paired explicit Save persists an empty negative through actual API, gives a new prompt its metadata and preserves local draft',async({page,request},testInfo)=>{ + const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByText('Metadata · category / tags',{exact:true}).click();await root.getByRole('combobox',{name:'Category',exact:true}).fill('Saved category');await root.getByRole('combobox',{name:'Tags',exact:true}).fill('saved-tag');await root.getByRole('button',{name:'Save pair',exact:true}).click(); await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();const response=await request.get(`/prompt_manager/pairs?text=${encodeURIComponent(text)}`);expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:text,negative_text:''})]));expect(await values(page,id)).toMatchObject({text,negative_text:''}); + const library=await (await request.get(`/prompt_manager/library?text=${encodeURIComponent(text)}&limit=10`)).json();expect(library.items).toEqual(expect.arrayContaining([expect.objectContaining({category:'Saved category',tags:['saved-tag']})])); }); test('paired library filters search both texts, persist on reload and keep editor metadata separate',async({page,request},testInfo)=>{ diff --git a/tests/test_api_pairs.py b/tests/test_api_pairs.py index 846e9964..2dcbb414 100644 --- a/tests/test_api_pairs.py +++ b/tests/test_api_pairs.py @@ -1,6 +1,7 @@ """Pair routes preserve shared text and expose portable combinations.""" from tests.test_api_prompts import PromptAPITestCase +from utils.hashing import generate_prompt_hash class TestPairAPI(PromptAPITestCase): @@ -9,6 +10,31 @@ class TestPairAPI(PromptAPITestCase): payload.update(overrides) return await self.client.post("/prompt_manager/pairs", json=payload) + async def _prompt_saved_by_route(self, text, **fields): + response = await self._create(positive_text=text, **fields) + self.assertEqual(response.status, 200) + return self.api.db.get_prompt_by_hash(generate_prompt_hash(text)) + + async def test_a_new_prompt_gets_the_saved_category_and_tags(self): + prompt = await self._prompt_saved_by_route( + "route cat", category="Animals", tags=["studio"] + ) + self.assertEqual(prompt["category"], "Animals") + self.assertEqual(prompt["tags"], ["studio"]) + + async def test_saving_never_changes_an_existing_prompt(self): + self.api.db.save_prompt( + "route dog", + category="Mine", + tags=["kept"], + prompt_hash=generate_prompt_hash("route dog"), + ) + prompt = await self._prompt_saved_by_route( + "route dog", category="Animals", tags=["studio"] + ) + self.assertEqual(prompt["category"], "Mine") + self.assertEqual(prompt["tags"], ["kept"]) + async def test_create_reuse_read_and_search(self): response = await self._create(tags=["studio"], title="Portrait") self.assertEqual(response.status, 200) diff --git a/tests/test_prompt_pair_node.py b/tests/test_prompt_pair_node.py index 671d4cdc..fff212aa 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,104 @@ 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_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=" ") + 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_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], "")