fix(tracking): follow actual pair output roles and prefer execution evidence (#193)

This commit is contained in:
Vito Sansevero
2026-10-17 07:01:44 -07:00
parent decb69864a
commit f430266a5d
10 changed files with 279 additions and 34 deletions
+46 -10
View File
@@ -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
+35 -7
View File
@@ -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
+2 -2
View File
@@ -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
+12
View File
@@ -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);
}
});
+30
View File
@@ -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)
+14
View File
@@ -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")
+83
View File
@@ -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)
+40 -11
View File
@@ -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.
+8 -4
View File
@@ -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)
)
+9
View File
@@ -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 = `
<!-- File Path -->
<div class="space-y-2">
@@ -1309,6 +1316,8 @@
</div>
</div>
${textSourceLabel ? `<p class="text-xs text-pm-secondary">${escapeHtml(textSourceLabel)}</p>` : ''}
<!-- Prompt Section -->
<div class="space-y-3">
<div class="flex items-center justify-between">