Compare commits

...
Author SHA1 Message Date
Vito Sansevero ca24fadf27 test(nodes): assert Save pair's metadata in the existing host test (#546)
A separate test raised the pinned host-test total, which the cutover branch also
changes. The assertion now lives in the explicit Save test.
2026-10-30 15:33:17 -07:00
Vito Sansevero 07bdfa559f fix(nodes): Save pair also gives a new prompt its category and tags (#546)
The node's Save pair button posts to /prompt_manager/pairs, which created the
prompt row empty; under the fill-on-create rule a later run could never fill it.
2026-10-30 14:58:11 -07:00
Vito Sansevero d20842ac7a 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.
2026-10-30 14:24:59 -07:00
Vito Sansevero d8d5e63618 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.
2026-10-30 13:58:09 -07:00
7 changed files with 154 additions and 8 deletions
+8
View File
@@ -2888,6 +2888,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 ·
+16 -3
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
)
+2
View File
@@ -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"]
+6 -1
View File
@@ -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})
+3 -2
View File
@@ -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)=>{
+26
View File
@@ -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)
+93 -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,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], "")