fix(ingestion): a switched-off PromptManager node behind a pass-through stores nothing (#90)

The #90 graph walk follows only prompt-like inputs, so a PromptManagerText
with Save to library off reaching the encoder through another input name
(easy showAnything.anything, a custom node's value) was invisible to it,
and the inspector's resolve stored its text. The ladder now checks the
positive every step names: a resolved or inspected positive whose path
passes a node that is off, or a legacy answer that is such a node's own
text, makes the file not_saved.
This commit is contained in:
Vito Sansevero
2026-10-28 12:46:57 -07:00
parent 02ce5e79bc
commit cf371094b7
4 changed files with 131 additions and 3 deletions
+5 -1
View File
@@ -5285,7 +5285,11 @@ workflows without a PromptManager node. It does no file or database I/O.
node had Save to library off is `not_saved`, and one whose switch is wired
from a node the file cannot show is `not_saved_wired`: nothing is written, no
recorded-run fallback, no Needs review item. A resolved pair whose negative's
node is off stores the positive alone (source `opted-out-negative`).
node is off stores the positive alone (source `opted-out-negative`). The
#90 graph walk follows only prompt-like inputs, so the text every later step
names is checked too: a resolved or inspected positive whose path passes a
node that is off (`side_opted_out`), or a legacy answer that is such a
node's own text, makes the file `not_saved`.
- **Order.** The default profile decides first, exactly as before (resolved
pair, A1111, sampler-less). Only a graph it leaves ambiguous or unresolved
reaches the inspector's resolve (`inspection_resolution` in
+68 -1
View File
@@ -166,6 +166,23 @@ class TestStepsBeforeResolving(unittest.TestCase):
self.assertEqual((reads, decision.canonical["file_info"]), ([1], {"size": 9}))
def _off_behind(middle, key):
"""A switched-off PromptManagerText reaching the encoder through
``middle``'s ``key`` input."""
graph = save_tests.run_graph()
graph["1"] = {
"class_type": "PromptManagerText",
"inputs": {"text": "SECRET OFF TEXT", "save_to_library": False},
}
graph["7"] = {"class_type": middle, "inputs": {key: ["1", 0]}}
graph["8"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
}
graph["3"]["inputs"]["positive"] = ["8", 0]
return graph
class TestSaveToLibrarySteps(unittest.TestCase):
"""#90: a PromptManager node with Save to library off stores nothing."""
@@ -184,9 +201,59 @@ class TestSaveToLibrarySteps(unittest.TestCase):
def test_step_2_a_wired_switch_is_not_saved_wired(self):
graph = save_tests.run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
decision = _decide(_graph(graph))
lookup = _Lookup("9")
decision = _decide(_graph(graph), lookup=lookup)
self.assertEqual(decision.kind, "not_saved_wired")
self.assertFalse(decision.try_recorded_run)
self.assertIsNone(decision.review)
self.assertEqual(lookup.calls, 0)
def test_step_8_1_an_off_node_behind_a_pass_through_is_not_saved(self):
"""The #90 graph walk does not follow ``anything`` or ``value``; the
inspector does, so its positive is checked against the switch."""
for middle, key in (("easy showAnything", "anything"), ("MyNode", "value")):
with self.subTest(middle=middle):
graph = _off_behind(middle, key)
for policy in Policy:
decision = _decide(_graph(graph), policy=policy, lookup=_Lookup())
self.assertEqual(decision.kind, "not_saved")
self.assertIsNone(decision.text)
def test_step_8_4_a_legacy_answer_from_an_off_node_is_not_saved(self):
graph = _off_behind("MyNode", "value")
graph["9"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "on text"}}
graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}}
graph["5"]["inputs"]["positive"] = ["9", 0]
decision = _decide(_graph(graph), lookup=_Lookup())
self.assertEqual(decision.kind, "not_saved")
self.assertIsNone(decision.text)
def test_steps_5_and_7_check_the_resolved_positive_too(self):
"""A resolved positive through an off node the #90 walk missed."""
cases = {
"pair": save_tests.run_graph(save=False),
"negative off": save_tests.run_graph(save=False, negative_save=False),
}
for name, graph in cases.items():
with (
self.subTest(name=name),
mock.patch.object(
file_prompt, "metadata_opted_out", return_value=False
),
):
self.assertEqual(_decide(_graph(graph)).kind, "not_saved")
def test_step_5_a_junk_positive_is_never_stored(self):
"""Pinned: the positive alone still passes the junk gate, and a blank
positive (never storable) stores nothing either."""
graph = save_tests.run_graph(save=True, negative_save=False)
graph["1"]["inputs"]["text"] = "2048"
decision = _decide(_graph(graph))
self.assertEqual((decision.kind, decision.source), ("never", "junk:number"))
self.assertEqual(decision.unlinked_status, "found")
graph["1"]["inputs"]["text"] = ""
decision = _decide(_graph(graph))
self.assertEqual((decision.kind, decision.text), ("never", None))
def test_step_2_a_workflow_only_opt_out_comes_before_step_3(self):
workflow = {
+18
View File
@@ -590,3 +590,21 @@ class TestScanDecision(AdminAPITestCase):
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
self.assertEqual(calls, [1])
def test_an_off_node_behind_a_pass_through_stores_nothing(self):
"""#90 regression guard for the scan (main stored nothing here)."""
from tests.test_save_to_library import run_graph
g = run_graph(save=False)
g["1"]["class_type"] = "PromptManagerText"
g["7"] = {"class_type": "easy showAnything", "inputs": {"anything": ["1", 0]}}
g["8"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
}
g["3"]["inputs"]["positive"] = ["8", 0]
path = self._path("off.png")
meta = {"prompt": json.dumps(g)}
self.assertEqual(self.api._ingest_scanned_file(path, meta), "not_saved")
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
self.assertEqual(self._links(path), [])
+40 -1
View File
@@ -28,7 +28,12 @@ from .generation_params import (
extract_generation_params,
parse_page_json,
)
from .prompt_graph import metadata_opted_out, side_opted_out
from .prompt_graph import (
PROMPT_MANAGER_TYPES,
metadata_opted_out,
save_setting,
side_opted_out,
)
from .prompt_inspector import inspection_resolution
from .prompt_pair_resolver import _numeric, resolve_prompt_pair, sampler_less_prompt
from .review_capture import reviewable
@@ -127,6 +132,12 @@ def decide_file_prompt(
return Decision(NEVER, canonical=canonical, try_recorded_run=True)
_with_size(canonical, file_size)
resolution = resolve_prompt_pair(canonical)
if resolution["state"] == "resolved" and side_opted_out(
canonical.get("prompt"), resolution["positive"]
):
# The positive passes a node that is off, though the #90 graph walk
# did not reach it (steps 5 and 7)
return Decision(NOT_SAVED, canonical=canonical)
if resolution["state"] == "resolved" and side_opted_out(
canonical.get("prompt"), resolution["negative"]
):
@@ -187,6 +198,11 @@ def _undecided_graph(found: _File) -> Decision:
found.metadata, found.file_name, saver, name_anchor=False
)
text = _inspected_text(inspected.resolution)
if text is not None and side_opted_out(
found.canonical["prompt"], inspected.resolution["positive"]
):
# The inspector follows pass-throughs the #90 graph walk does not
return Decision(NOT_SAVED, canonical=found.canonical)
if text is not None:
source = "recorded-run" if inspected.by_record else "inspector"
return _decision(found, POSITIVE, text, source)
@@ -196,6 +212,9 @@ def _undecided_graph(found: _File) -> Decision:
if not inspected.malformed and inspected.resolution["state"] == "ambiguous":
return _never(found, _unlinked(found))
text = graph_prompt_text(found.metadata)
if text is not None and _off_text(found.canonical["prompt"], text):
# The legacy answer is a switched-off node's own text (#90)
return Decision(NOT_SAVED, canonical=found.canonical)
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).
@@ -223,6 +242,26 @@ def _sampler_less(found: _File, graph: Any) -> Decision:
_UNDECIDED_SOURCES = ("inspector", "recorded-run", "legacy-parser")
def _off_text(graph: Any, text: str) -> bool:
"""Whether ``text`` is the literal text of a PromptManager-family node
whose Save to library is not on."""
if not isinstance(graph, dict):
return False
wanted = text.strip()
for node_id, node in graph.items():
if not isinstance(node, dict) or node.get("class_type") not in (
PROMPT_MANAGER_TYPES
):
continue
inputs = node.get("inputs")
literal = inputs.get("text") if isinstance(inputs, dict) else None
if not isinstance(literal, str) or literal.strip() != wanted:
continue
if save_setting(graph, node_id) is not True:
return True
return False
def _joined_text(graph: Any, text: str) -> bool:
"""Whether ``text`` may hold a PromptManager node's prepend or append.