Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca24fadf27 | ||
|
|
07bdfa559f | ||
|
|
d20842ac7a | ||
|
|
d8d5e63618 |
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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)=>{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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], "")
|
||||
|
||||
Reference in New Issue
Block a user