fix(tracking): follow actual pair output roles and prefer execution evidence (#193)
This commit is contained in:
+46
-10
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user