fix(monitor): never store PromptSearchList search text as a prompt (#548) (#576)

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:
Vito
2026-10-31 22:25:01 +00:00
committed by GitHub
parent 93bf24f47d
commit c3c75c3369
5 changed files with 149 additions and 5 deletions
+2
View File
@@ -167,6 +167,8 @@ Searches your library by text, category, tags and minimum rating, then outputs t
![Prompt Search List node producing three images](assets/readme/node-search.webp)
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.
+60
View File
@@ -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 = {
+27
View File
@@ -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()}
+12
View File
@@ -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
View File
@@ -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]: