A PromptSearchList wired straight into CLIPTextEncode left the legacy-parser
fallback tracing the positive to the node's search query ("fox"). Both the
output scan and the live monitor stored that query as a prompt and linked
every image of the batch to it.
The fallback now leaves such files unlinked (the usual unresolved outcome)
when its answer is the search text of a PromptSearchList upstream of a
sampler's positive. An empty search text, and a search feeding only the
negative, keep their previous outcome. The parity gate states f22 as a
deliberate departure from the #170 parser, and the README says to wire the
Search List through Prompt Manager Text to get the images linked.
This commit is contained in:
@@ -167,6 +167,8 @@ Searches your library by text, category, tags and minimum rating, then outputs t
|
||||
|
||||

|
||||
|
||||
To get the images of a Search List batch linked to their prompts, wire the Search List output into a Prompt Manager Text node, not straight into CLIP Text Encode. Wired straight in, the images stay unlinked: the search text is never saved as a prompt.
|
||||
|
||||
## Install
|
||||
|
||||
**Requirements:** ComfyUI and Python 3.12 or newer. `watchdog` and `Pillow` are the only dependencies of their own; the rest comes with ComfyUI.
|
||||
|
||||
@@ -564,6 +564,66 @@ class TestUndecidedGraph(unittest.TestCase):
|
||||
self.assertEqual(decision.review, "untraced")
|
||||
|
||||
|
||||
def _search_list(text="fox", side="positive"):
|
||||
"""f22: a PromptSearchList batch (no PromptManager node) feeds CLIPTextEncode."""
|
||||
graph = parity.build("f22")["prompt"]
|
||||
graph["10"]["inputs"]["text"] = text
|
||||
if side == "negative":
|
||||
# The search feeds the negative; the positive is typed text.
|
||||
graph["6"]["inputs"]["text"] = parity.POS
|
||||
graph["7"]["inputs"]["text"] = ["10", 0]
|
||||
return graph
|
||||
|
||||
|
||||
class TestSearchTextIsNeverAPrompt(unittest.TestCase):
|
||||
"""#548: the PromptSearchList query is a search, never the prompt."""
|
||||
|
||||
def test_the_search_text_is_never_linked_or_stored(self):
|
||||
for policy in Policy:
|
||||
with self.subTest(policy=policy):
|
||||
decision = _decide(
|
||||
_graph(_search_list()), policy=policy, lookup=_Lookup()
|
||||
)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertIsNone(decision.text)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
|
||||
def test_a_search_text_from_a_primitive_is_never_linked(self):
|
||||
graph = _search_list()
|
||||
graph["10"]["inputs"]["text"] = ["30", 0]
|
||||
graph["30"] = {"class_type": "PrimitiveString", "inputs": {"value": "fox"}}
|
||||
for policy in Policy:
|
||||
with self.subTest(policy=policy):
|
||||
decision = _decide(_graph(graph), policy=policy, lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertIsNone(decision.text)
|
||||
|
||||
def test_an_empty_search_text_is_unchanged(self):
|
||||
"""Regression guard (passes on the base): nothing to exclude."""
|
||||
for policy in Policy:
|
||||
with self.subTest(policy=policy):
|
||||
decision = _decide(
|
||||
_graph(_search_list("")), policy=policy, lookup=_Lookup()
|
||||
)
|
||||
self.assertEqual((decision.kind, decision.text), ("never", None))
|
||||
|
||||
def test_without_a_sampler_any_search_list_query_is_excluded(self):
|
||||
graph = _search_list()
|
||||
del graph["3"]
|
||||
self.assertTrue(file_prompt._search_text(graph, " fox "))
|
||||
self.assertFalse(file_prompt._search_text(graph, "a fox"))
|
||||
self.assertFalse(file_prompt._search_text(None, "fox"))
|
||||
|
||||
def test_a_search_on_the_negative_keeps_the_typed_positive(self):
|
||||
"""Regression guard (passes on the base): only the positive's source
|
||||
is excluded, so the legacy answer from typed text still links."""
|
||||
graph = _search_list(side="negative")
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.text), ("positive", parity.POS))
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
|
||||
|
||||
class TestStepsWithoutAnUndecidedGraph(unittest.TestCase):
|
||||
def test_step_9_ambiguous_parameters_only(self):
|
||||
meta = {
|
||||
|
||||
@@ -108,6 +108,15 @@ LEGACY_WRONG = {
|
||||
"a cozy library with tall shelves",
|
||||
"legacy shows branch 1; the file name names branch 4's saver",
|
||||
),
|
||||
# #548, the one shape where nothing is read on purpose: legacy shows a
|
||||
# PromptSearchList's search text ("fox") as the positive, but that is the
|
||||
# library query, not the prompt the batch ran. The scan and the monitor
|
||||
# never store or link it either (utils.file_prompt._search_text).
|
||||
("inspection_parity", "f22"): (
|
||||
None,
|
||||
"legacy shows the PromptSearchList search text; each item is decided "
|
||||
"at run time, so no positive is read (#548)",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +158,24 @@ class LegacyParityGateTest(unittest.TestCase):
|
||||
self.assertEqual(text, expected)
|
||||
self.assertNotEqual(text, legacy)
|
||||
|
||||
def test_the_search_list_query_is_never_stored_either(self):
|
||||
"""#548: the scan and the monitor depart from legacy for f22 too."""
|
||||
from utils.file_prompt import Policy, decide_file_prompt
|
||||
|
||||
graph, workflow = corpora()["inspection_parity"]["f22"]
|
||||
self.assertEqual(legacy_positive(graph, workflow), "fox")
|
||||
for policy in Policy:
|
||||
with self.subTest(policy=policy):
|
||||
decision = decide_file_prompt(
|
||||
{"prompt": graph, "workflow": workflow},
|
||||
file_name="f22.png",
|
||||
file_size=None,
|
||||
in_output=True,
|
||||
lookup=lambda: None,
|
||||
policy=policy,
|
||||
)
|
||||
self.assertEqual((decision.kind, decision.text), ("never", None))
|
||||
|
||||
def test_no_graph_leaves_the_legacy_agreement(self):
|
||||
recorded = json.loads(AGREEMENT_PATH.read_text(encoding="utf-8"))
|
||||
current = {name: agreeing(graphs) for name, graphs in corpora().items()}
|
||||
|
||||
@@ -90,6 +90,18 @@ class TestPairScan(AdminAPITestCase):
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_a_prompt_search_list_query_is_never_stored_as_a_prompt(self):
|
||||
# #548: the search text was stored and every batch image linked to it.
|
||||
g = sampled_graph()
|
||||
g["10"] = {
|
||||
"class_type": "PromptSearchList",
|
||||
"inputs": {"text": "fox", "category": "", "tags": "", "limit": 50},
|
||||
}
|
||||
g["1"]["inputs"]["text"] = ["10", 0]
|
||||
self.assertEqual(self._scanned("search.png", g), "unresolved")
|
||||
self.assertEqual(self._texts(), [])
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
|
||||
def test_numeric_widgets_are_never_stored_as_a_prompt(self):
|
||||
# The pre-redesign scan stored "2048" from a resolution widget here.
|
||||
g = {
|
||||
|
||||
+48
-5
@@ -230,6 +230,9 @@ def _undecided_graph(found: _File) -> Decision:
|
||||
if switched is not None:
|
||||
# The legacy answer is a switched-off node's own text (#90)
|
||||
return Decision(switched, canonical=found.canonical)
|
||||
if text is not None and _search_text(found.canonical["prompt"], text):
|
||||
# The query of a Search List batch is a search, never a prompt (#548)
|
||||
return _never(found, _unlinked(found), try_recorded_run=True)
|
||||
if text is not None and _joined_text(found.canonical["prompt"], text):
|
||||
# The legacy parser joins prepend + text + append: the text a
|
||||
# PromptManager node stores cannot be told from it (#343).
|
||||
@@ -311,6 +314,8 @@ _NOT_TEXT_INPUTS = frozenset(
|
||||
}
|
||||
)
|
||||
_MAX_SWITCH_VISITS = 5000
|
||||
# The batch node whose ``text`` input is a library search query (#548).
|
||||
SEARCH_LIST_TYPE = "PromptSearchList"
|
||||
|
||||
|
||||
def _negative_name(key: Any) -> bool:
|
||||
@@ -328,10 +333,28 @@ def _switched_off_upstream(graph: Any) -> Optional[str]:
|
||||
"""
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
roots = _positive_roots(graph)
|
||||
if not roots:
|
||||
upstream = _positive_upstream(graph)
|
||||
if upstream is None:
|
||||
ids = _prompt_manager_ids(graph)
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in ids)
|
||||
found = [
|
||||
node_id
|
||||
for node_id in upstream
|
||||
if graph[node_id].get("class_type") in PROMPT_MANAGER_TYPES
|
||||
]
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in found)
|
||||
|
||||
|
||||
def _positive_upstream(graph: dict) -> Optional[set]:
|
||||
"""The ids of the nodes upstream of a sampler's positive, or None when
|
||||
the graph has no recognisable sampler.
|
||||
|
||||
Follows every input except negative and non-text ones (#90's walk follows
|
||||
only prompt-like names).
|
||||
"""
|
||||
roots = _positive_roots(graph)
|
||||
if not roots:
|
||||
return None
|
||||
seen, found = set(), set()
|
||||
stack = [str(link[0]) for link in roots]
|
||||
while stack and len(seen) < _MAX_SWITCH_VISITS:
|
||||
@@ -342,8 +365,7 @@ def _switched_off_upstream(graph: Any) -> Optional[str]:
|
||||
node = graph.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("class_type") in PROMPT_MANAGER_TYPES:
|
||||
found.add(node_id)
|
||||
found.add(node_id)
|
||||
inputs = node.get("inputs")
|
||||
for key, value in (inputs.items() if isinstance(inputs, dict) else ()):
|
||||
if key in _NOT_TEXT_INPUTS or _negative_name(key):
|
||||
@@ -351,7 +373,28 @@ def _switched_off_upstream(graph: Any) -> Optional[str]:
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
if isinstance(value[0], (str, int)) and isinstance(value[1], int):
|
||||
stack.append(str(value[0]))
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in found)
|
||||
return found
|
||||
|
||||
|
||||
def _search_text(graph: Any, text: str) -> bool:
|
||||
"""Whether ``text`` is the search query of a PromptSearchList node that
|
||||
feeds a positive (any such node when no sampler is recognised).
|
||||
|
||||
The legacy parser traces a positive wired from a Search List to the
|
||||
node's ``text`` input, which is the database search, never the prompt
|
||||
the batch ran (#548). Each item is counted by the node at run time.
|
||||
"""
|
||||
if not isinstance(graph, dict):
|
||||
return False
|
||||
upstream = _positive_upstream(graph)
|
||||
ids = graph if upstream is None else upstream
|
||||
wanted = text.strip()
|
||||
return any(
|
||||
isinstance(graph[node_id], dict)
|
||||
and graph[node_id].get("class_type") == SEARCH_LIST_TYPE
|
||||
and resolve_text(graph, node_id) == wanted
|
||||
for node_id in ids
|
||||
)
|
||||
|
||||
|
||||
def _off_text(graph: Any, text: str) -> Optional[str]:
|
||||
|
||||
Reference in New Issue
Block a user