From f430266a5d754cf3223ba00fb2a59b4151e12950 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 17 Oct 2026 07:01:44 -0700 Subject: [PATCH] fix(tracking): follow actual pair output roles and prefer execution evidence (#193) --- database/operations.py | 56 ++++++++++++++++++---- prompt_manager_pair.py | 42 ++++++++++++++--- py/api/pairs.py | 4 +- tests/js/pair-metadata.test.js | 12 +++++ tests/test_api_pairs.py | 30 ++++++++++++ tests/test_pair_ingestion.py | 14 ++++++ tests/test_pair_output_roles.py | 83 +++++++++++++++++++++++++++++++++ utils/prompt_graph.py | 51 +++++++++++++++----- utils/usage_tracking.py | 12 +++-- web/js/gallery.js | 9 ++++ 10 files changed, 279 insertions(+), 34 deletions(-) create mode 100644 tests/test_pair_output_roles.py diff --git a/database/operations.py b/database/operations.py index 1c02af5..732d49b 100644 --- a/database/operations.py +++ b/database/operations.py @@ -2041,18 +2041,54 @@ class PromptDatabase(PairOperations): if len(pair_ids) == 1 else None ) - contexts = { - ( - item["provenance"].get("positive", {}).get("text"), - item["provenance"].get("negative", {}).get("text"), + candidates = [] + for item in associations: + evidence = item["provenance"] + positive_side = evidence.get("positive") + negative_side = evidence.get("negative") + if not isinstance(positive_side, dict) or not isinstance( + negative_side, dict + ): + continue + positive, negative = positive_side.get("text"), negative_side.get( + "text" ) - for item in associations - } + if not isinstance(positive, str) or not isinstance(negative, str): + continue + sources = [] + for side in (positive_side, negative_side): + provenance = side.get("provenance") + sources.append( + provenance.get("text_source") + if isinstance(provenance, dict) + else None + ) + score = sources.count("execution") + source = ( + "execution" + if score == 2 + else ( + "mixed" + if score + else ( + "embedded-parameters" + if sources == ["embedded-parameters"] * 2 + else "graph-inferred" + ) + ) + ) + candidates.append((score, positive, negative, source)) context = None - if len(contexts) == 1: - positive, negative = next(iter(contexts)) - if isinstance(positive, str) and isinstance(negative, str): - context = {"positive_text": positive, "negative_text": negative} + if candidates and pair: + best_score = max(item[0] for item in candidates) + contexts = {item[1:] for item in candidates if item[0] == best_score} + if len(contexts) == 1: + positive, negative, source = next(iter(contexts)) + context = { + "positive_text": positive, + "negative_text": negative, + "text_source": source, + } tags = self._get_prompt_tags(conn, row[0]) try: workflow = json.loads(row[5]) if row[5] else None diff --git a/prompt_manager_pair.py b/prompt_manager_pair.py index 6c85dde..75ae89b 100644 --- a/prompt_manager_pair.py +++ b/prompt_manager_pair.py @@ -9,7 +9,7 @@ try: from .prompt_manager import IO, InputTypeDict, PromptManagerFocused from .utils.hashing import generate_prompt_hash from .utils.pair_execution import record_pair_execution - from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_nodes + from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components from .utils.prompt_pair_resolver import resolve_text_input from .utils.usage_tracking import ( PENDING_FIRST_USE, @@ -20,7 +20,7 @@ except ImportError: from prompt_manager import IO, InputTypeDict, PromptManagerFocused from utils.hashing import generate_prompt_hash from utils.pair_execution import record_pair_execution - from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_nodes + from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components from utils.prompt_pair_resolver import resolve_text_input from utils.usage_tracking import ( PENDING_FIRST_USE, @@ -101,7 +101,16 @@ class PromptManagerPair(PromptManagerFocused): pair_id = prompt_id = None known = isinstance(prompt, dict) and unique_id is not None node_id = str(unique_id) - is_positive = node_id in run_prompt_nodes(prompt) if known else True + used_fields = ( + { + field + for source, field in run_prompt_components(prompt) + if source == node_id + } + if known + else {"text"} + ) + is_positive = "text" in used_fields pair_used = node_id in intrinsic_pair_nodes(prompt) if known else True try: pair_id = self.db.save_prompt_pair( @@ -131,12 +140,31 @@ class PromptManagerPair(PromptManagerFocused): pair_pending = getattr( self, "pending_pair_first_use", PENDING_PAIR_FIRST_USE ) - if is_positive: - runs = positive_pending.consume(generate_prompt_hash(text)) + int( - not positive_seen + counted = set() + for field, component_text, resolved_text, component_id in ( + ("text", text, resolved_positive, prompt_id), + ( + "negative_text", + negative_text, + resolved_negative, + pair["negative_prompt_id"], + ), + ): + component_hash = generate_prompt_hash(component_text) + if ( + field not in used_fields + or not component_id + or component_hash in counted + ): + continue + counted.add(component_hash) + seen_at_queue = ( + resolved_text is not None + and generate_prompt_hash(resolved_text) == component_hash ) + runs = positive_pending.consume(component_hash) + int(not seen_at_queue) if runs: - self.db.record_prompt_use(prompt_id, times=runs) + self.db.record_prompt_use(component_id, times=runs) if pair_used: runs = pair_pending.consume(pair_run_key(text, negative_text)) + int( not pair_seen diff --git a/py/api/pairs.py b/py/api/pairs.py index acbde50..5dfbce7 100644 --- a/py/api/pairs.py +++ b/py/api/pairs.py @@ -155,9 +155,9 @@ class PairRoutesMixin: updated = await self._run_in_executor( self.db.update_prompt_pair_metadata, pair_id, **fields ) - if not updated: - return missing_pair() pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id) + if not updated and not pair: + return missing_pair() return web.json_response({"success": True, "pair": pair}) @pair_response diff --git a/tests/js/pair-metadata.test.js b/tests/js/pair-metadata.test.js index 8958bca..0d0b91c 100644 --- a/tests/js/pair-metadata.test.js +++ b/tests/js/pair-metadata.test.js @@ -47,3 +47,15 @@ test('recorded image pair overrides unrelated PNG fallback, including an empty n assert.equal(manager.last.negativePrompt,negative); } }); + +test('gallery labels graph-inferred text separately from recorded execution text', () => { + const context=vm.createContext({document:{addEventListener(){}},window:{},console,escapeHtml:value=>String(value)}); + const Manager=vm.runInContext(fs.readFileSync(path.join(__dirname,'../../web/js/gallery.js'),'utf8')+'\nGalleryManager;',context); + const manager=Object.create(Manager.prototype); + manager.formatPromptText=value=>String(value||'');manager.setupMetadataActions=()=>{}; + for (const [source,label] of [['graph-inferred','Recovered from workflow metadata'],['execution','Recorded during execution'],['embedded-parameters','Read from embedded parameters'],['mixed','Execution and workflow metadata']]) { + const panel={}; + manager.displayMetadata({positivePrompt:'cat',negativePrompt:'blur',textSource:source},panel); + assert.ok(panel.innerHTML.includes(label),source); + } +}); diff --git a/tests/test_api_pairs.py b/tests/test_api_pairs.py index bf95c66..846e996 100644 --- a/tests/test_api_pairs.py +++ b/tests/test_api_pairs.py @@ -122,3 +122,33 @@ class TestPairAPI(PromptAPITestCase): self.assertEqual((await response.json())["pairs"][0]["id"], first["id"]) response = await self.client.get("/prompt_manager/pairs?sort=invalid") self.assertEqual(response.status, 400) + + async def test_null_metadata_patch_is_noop_for_existing_pair(self): + pair = (await (await self._create(rating=4)).json())["pair"] + response = await self.client.patch( + f'/prompt_manager/pairs/{pair["id"]}', json={"rating": None} + ) + self.assertEqual(response.status, 200) + self.assertEqual((await response.json())["pair"]["rating"], 4) + response = await self.client.patch( + "/prompt_manager/pairs/99999", json={"rating": None} + ) + self.assertEqual(response.status, 404) + + async def test_database_failures_are_generic_and_bad_json_is_rejected(self): + from unittest.mock import patch + + with patch.object( + self.api.db, + "save_prompt_pair", + side_effect=RuntimeError("/private/db/path"), + ): + response = await self._create() + self.assertEqual(response.status, 500) + self.assertNotIn("/private", await response.text()) + response = await self.client.post( + "/prompt_manager/pairs", + data="{", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status, 400) diff --git a/tests/test_pair_ingestion.py b/tests/test_pair_ingestion.py index 952df4f..af94809 100644 --- a/tests/test_pair_ingestion.py +++ b/tests/test_pair_ingestion.py @@ -140,3 +140,17 @@ class TestPairIngestion(unittest.TestCase): ) self.assertEqual(result["status"], "conflict") self.assertEqual(self.db.list_prompt_pairs()["total"], 1) + + def test_execution_evidence_wins_over_a_prior_inferred_context(self): + inferred = resolved() + for side in ("positive", "negative"): + inferred[side]["provenance"]["text_source"] = "graph-inferred" + ingest_resolved_pair(self.db, self.path, {}, inferred) + execution = resolved("cat, lora_trigger", "blur") + execution["positive"]["storage_text"] = "cat" + for side in ("positive", "negative"): + execution[side]["provenance"]["text_source"] = "execution" + ingest_resolved_pair(self.db, self.path, {}, execution) + info = self.db.get_image_prompt_info(self.path) + self.assertEqual(info["pair_context"]["positive_text"], "cat, lora_trigger") + self.assertEqual(info["pair_context"]["text_source"], "execution") diff --git a/tests/test_pair_output_roles.py b/tests/test_pair_output_roles.py new file mode 100644 index 0000000..1fa364d --- /dev/null +++ b/tests/test_pair_output_roles.py @@ -0,0 +1,83 @@ +"""Actual sampler wires determine which component counts as its positive text.""" + +import unittest + +from utils.hashing import generate_prompt_hash +from utils.usage_tracking import queued_prompt_runs, queued_pair_runs + + +def paired_graph(positive_slot=0, negative_slot=1): + return { + "1": { + "class_type": "PromptManagerPair", + "inputs": {"text": "cat", "negative_text": "blur"}, + }, + "2": { + "class_type": "KSampler", + "inputs": { + "model": ["3", 0], + "positive": ["1", positive_slot], + "negative": ["1", negative_slot], + }, + }, + } + + +class TestPairOutputRoles(unittest.TestCase): + def test_swapped_conditioning_counts_actual_positive_component_only(self): + graph = paired_graph(1, 0) + self.assertEqual( + queued_prompt_runs(graph), [(generate_prompt_hash("blur"), "blur")] + ) + self.assertEqual(queued_pair_runs(graph), []) + + def test_negative_string_encoded_as_positive_counts_actual_string_component(self): + graph = paired_graph() + graph["4"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["1", 3]}} + graph["2"]["inputs"]["positive"] = ["4", 0] + self.assertEqual( + queued_prompt_runs(graph), [(generate_prompt_hash("blur"), "blur")] + ) + self.assertEqual(queued_pair_runs(graph), []) + + def test_two_sampler_positive_roles_count_each_component_once(self): + graph = paired_graph() + graph["5"] = { + "class_type": "KSampler", + "inputs": {"model": ["3", 0], "positive": ["1", 1], "negative": ["1", 0]}, + } + self.assertEqual( + {text for _, text in queued_prompt_runs(graph)}, {"cat", "blur"} + ) + + +class TestPairRoleExecution(unittest.TestCase): + def setUp(self): + from tests.test_prompt_pair_node import TestPromptPairNode + + TestPromptPairNode.setUp(self) + + def test_new_swapped_component_consumes_its_queued_pending_use(self): + self.node.pending_first_use.add(generate_prompt_hash("blur")) + self.node.encode_prompt( + self.clip, "cat", "blur", prompt=paired_graph(1, 0), unique_id="1" + ) + pair = self.db.list_prompt_pairs()["pairs"][0] + self.assertEqual( + self.db.get_prompt_by_id(pair["negative_prompt_id"])["run_count"], 1 + ) + self.assertEqual( + self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 0 + ) + self.assertEqual(pair["run_count"], 0) + + def test_unknown_negative_link_used_as_positive_counts_at_execution(self): + graph = paired_graph(1, 0) + graph["1"]["inputs"]["negative_text"] = ["8", 0] + graph["8"] = {"class_type": "UnknownBatch", "inputs": {}} + self.node.encode_prompt(self.clip, "cat", "blur", prompt=graph, unique_id="1") + pair = self.db.list_prompt_pairs()["pairs"][0] + self.assertEqual( + self.db.get_prompt_by_id(pair["negative_prompt_id"])["run_count"], 1 + ) + self.assertEqual(pair["run_count"], 0) diff --git a/utils/prompt_graph.py b/utils/prompt_graph.py index c555775..fc1ee36 100644 --- a/utils/prompt_graph.py +++ b/utils/prompt_graph.py @@ -9,7 +9,7 @@ link-tracing rules of web/js/comfy-metadata.js. """ import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple PROMPT_MANAGER_TYPES = frozenset( { @@ -99,15 +99,12 @@ def has_sampler(graph: Any) -> bool: return isinstance(graph, dict) and bool(_positive_roots(graph)) -def positive_prompt_nodes(graph: Any) -> List[str]: - """Ids of PromptManager family nodes that feed a positive input. - - Ordered by discovery, each id once. Malformed graphs yield an empty list. - """ +def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]: + """Node/input identities reaching actual sampler positive wires.""" if not isinstance(graph, dict): return [] - found: List[str] = [] + found: List[Tuple[str, str]] = [] seen = set() stack = list(reversed(_positive_roots(graph))) while stack and len(seen) < MAX_VISITS: @@ -119,16 +116,48 @@ def positive_prompt_nodes(graph: Any) -> List[str]: node = graph.get(node_id) if not isinstance(node, dict): continue - if node.get("class_type") == "PromptManagerPair" and slot not in (0, 2): - continue if node.get("class_type") in PROMPT_MANAGER_TYPES: - if node_id not in found: - found.append(node_id) + field = "text" + if node.get("class_type") == "PromptManagerPair": + field = { + 0: "text", + 1: "negative_text", + 2: "text", + 3: "negative_text", + }.get(slot) + if field is None: + continue + identity = (node_id, field) + if identity not in found: + found.append(identity) continue stack.extend(reversed(_upstream_links(_inputs(node)))) return found +def positive_prompt_nodes(graph: Any) -> List[str]: + """Nodes whose main editor reaches a sampler's positive input.""" + return list( + dict.fromkeys( + node_id + for node_id, field in positive_prompt_components(graph) + if field == "text" + ) + ) + + +def run_prompt_components(graph: Any) -> List[Tuple[str, str]]: + """Actual positive components, retaining standalone main-editor fallback.""" + components = positive_prompt_components(graph) + if components or not isinstance(graph, dict) or has_sampler(graph): + return components + return [ + (str(node_id), "text") + for node_id, node in graph.items() + if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES + ] + + def run_prompt_nodes(graph: Any) -> List[str]: """Prompt nodes that represent a run: the positive ones, if any can be found. diff --git a/utils/usage_tracking.py b/utils/usage_tracking.py index 674b1fe..2acbdf2 100644 --- a/utils/usage_tracking.py +++ b/utils/usage_tracking.py @@ -29,13 +29,17 @@ from typing import Any, Callable, List, Optional, Tuple try: from .hashing import generate_prompt_hash from .logging_config import get_logger - from .prompt_graph import intrinsic_pair_nodes, resolve_text, run_prompt_nodes + from .prompt_graph import intrinsic_pair_nodes, resolve_text, run_prompt_components from .prompt_pair_resolver import resolve_text_input from .pair_execution import clear_pair_execution except ImportError: from utils.hashing import generate_prompt_hash from utils.logging_config import get_logger - from utils.prompt_graph import intrinsic_pair_nodes, resolve_text, run_prompt_nodes + from utils.prompt_graph import ( + intrinsic_pair_nodes, + resolve_text, + run_prompt_components, + ) from utils.prompt_pair_resolver import resolve_text_input from utils.pair_execution import clear_pair_execution @@ -123,10 +127,10 @@ def queued_prompt_runs(graph: Any) -> List[Run]: """ runs: List[Run] = [] seen = set() - for node_id in run_prompt_nodes(graph): + for node_id, field in run_prompt_components(graph): node = graph.get(node_id, {}) if isinstance(graph, dict) else {} text = ( - resolve_text_input(graph, node_id, "text") + resolve_text_input(graph, node_id, field) if node.get("class_type") == "PromptManagerPair" else resolve_text(graph, node_id) ) diff --git a/web/js/gallery.js b/web/js/gallery.js index 4b9563d..56596f7 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -1221,6 +1221,7 @@ if (databasePrompt) { // Create metadata object from database prompt metadata = { + textSource: databasePrompt.pair_context?.text_source, positivePrompt: databasePrompt.pair_context?.positive_text ?? databasePrompt.text, negativePrompt: databasePrompt.pair_context?.negative_text ?? databasePrompt.pair?.negative_text ?? 'No negative prompt found', checkpoint: 'Unknown', @@ -1289,6 +1290,12 @@ return; } + const textSourceLabel = new Map([ + ['execution','Recorded during execution'], + ['graph-inferred','Recovered from workflow metadata'], + ['embedded-parameters','Read from embedded parameters'], + ['mixed','Execution and workflow metadata'], + ]).get(metadata.textSource); const html = `
@@ -1309,6 +1316,8 @@
+ ${textSourceLabel ? `

${escapeHtml(textSourceLabel)}

` : ''} +