From aff31f899dc6f6e183e0de27d5f4449b1777322f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 31 Oct 2026 14:46:04 -0700 Subject: [PATCH] fix(monitor): never store PromptSearchList search text as a prompt (#548) 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. --- README.md | 2 ++ tests/test_file_prompt.py | 60 ++++++++++++++++++++++++++++++++ tests/test_legacy_parity_gate.py | 27 ++++++++++++++ tests/test_pair_scan.py | 12 +++++++ utils/file_prompt.py | 53 +++++++++++++++++++++++++--- 5 files changed, 149 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e71786e3..cc783519 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tests/test_file_prompt.py b/tests/test_file_prompt.py index 8b525659..2ecb2d3d 100644 --- a/tests/test_file_prompt.py +++ b/tests/test_file_prompt.py @@ -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 = { diff --git a/tests/test_legacy_parity_gate.py b/tests/test_legacy_parity_gate.py index 4b7a7980..b5cf1d59 100644 --- a/tests/test_legacy_parity_gate.py +++ b/tests/test_legacy_parity_gate.py @@ -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()} diff --git a/tests/test_pair_scan.py b/tests/test_pair_scan.py index 3f91f41d..aa0c5f1f 100644 --- a/tests/test_pair_scan.py +++ b/tests/test_pair_scan.py @@ -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 = { diff --git a/utils/file_prompt.py b/utils/file_prompt.py index 0061277f..230c6c99 100644 --- a/utils/file_prompt.py +++ b/utils/file_prompt.py @@ -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]: