From 2bda944581f940c8b1cce0c0290075d41397f2fa Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 19 Oct 2026 13:50:57 -0700 Subject: [PATCH] feat(inspect): explain why a prompt could not be resolved --- py/api/inspection_service.py | 16 +- tests/browser/prompt-inspector.spec.mjs | 11 + tests/js/prompt-blocks.test.js | 49 +++ tests/js/workspace-inspection-state.test.js | 42 +++ tests/test_api_prompt_inspector.py | 20 +- tests/test_inspection_outcomes.py | 355 ++++++++++++++++++++ utils/inspection_outcomes.py | 109 ++++++ utils/inspection_response.py | 4 +- utils/prompt_inspector.py | 73 ++-- utils/prompt_pair_resolver.py | 15 +- web/js/workspace/inspection-state.mjs | 10 +- web/js/workspace/prompt-blocks.mjs | 2 +- 12 files changed, 660 insertions(+), 46 deletions(-) create mode 100644 tests/test_inspection_outcomes.py create mode 100644 utils/inspection_outcomes.py diff --git a/py/api/inspection_service.py b/py/api/inspection_service.py index 0b15bd2..83ce648 100644 --- a/py/api/inspection_service.py +++ b/py/api/inspection_service.py @@ -9,6 +9,7 @@ try: MetadataReadError, inspect_prompt_evidence, read_inspection_file, + unreadable_evidence, ) from ...utils.video_metadata import is_video_path except ImportError: @@ -16,6 +17,7 @@ except ImportError: MetadataReadError, inspect_prompt_evidence, read_inspection_file, + unreadable_evidence, ) from utils.video_metadata import is_video_path @@ -73,19 +75,7 @@ def inspect_current_file( if any(getattr(before, key) != getattr(after, key) for key in fields): raise CurrentFileChanged if inspection is None: - evidence = { - "prompts": { - role: {"status": "unresolved", "text": None, "source": None} - for role in ("positive", "negative") - }, - "resolution": { - "state": "unresolved", - "sampler_id": None, - "reason": "Media metadata could not be read", - "reason_code": None, - "node": None, - }, - } + evidence = unreadable_evidence() else: role_saved = ( None if saved and saved.get("association_status") == "ambiguous" else saved diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index e509be9..c211c8b 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -232,6 +232,17 @@ test('unverified saved associations explain uncertainty and cannot be sent',asyn await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0); }); +test('unresolved roles name the blocking node as text and offer no prompt actions',async({page})=>{ + const line='Prompt uses a node the inspector does not support ().'; + await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},prompts:{ + positive:{status:'unresolved',text:null,source:null,message:line},negative:{status:'unresolved',text:null,source:null,message:line}, + }}})); + await page.goto(workspace);await select(page,'0:uncertain.png'); + for (const name of ['Positive','Negative']) await expect(role(details(page),name)).toContainText(line); + await expect(details(page).locator('img[onerror]')).toHaveCount(0); + expect(await page.evaluate(()=>window.injected)).toBeUndefined(); + await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0); +}); test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{ await page.goto(workspace);await select(page,'0:shared/same.png'); diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js index dea91bd..b32c6be 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -81,3 +81,52 @@ test('a zeroed conditioning negative shows as an explicit empty negative with it assert.deepEqual(sent,[{positive:'positive',negative:''}]); }finally{scope.dispose();f.restore();} }); + +const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})}); +const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent); +test('unsupported node status names the class',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + const line='Prompt uses a node the inspector does not support (DualCFGGuider).'; + try{ + module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}}); + assert.deepEqual(statusLines(f,container),[line,line]); + assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined).length,0,'no action implies a prompt exists'); + }finally{scope.dispose();f.restore();} +}); +test('runtime and composite statuses',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + const runtime='Prompt is chosen at run time (PromptSearchList).',composite='Prompt combines several texts (ConditioningCombine).'; + try{ + const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(runtime),negative:unresolved(runtime)}}); + assert.deepEqual(statusLines(f,container),[runtime,runtime]); + view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}}); + assert.deepEqual(statusLines(f,container),[composite]); + assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1); + }finally{scope.dispose();f.restore();} +}); +test('limit status',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + const line='Prompt exceeds inspection limits.'; + try{ + module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}}); + assert.deepEqual(statusLines(f,container),[line,line]); + }finally{scope.dispose();f.restore();} +}); +test('roles without a usable line keep the status copy, and a candidate is never shown as a prompt',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + try{ + module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(''),negative:{status:'ambiguous',text:'candidate text',source:null}}}); + assert.deepEqual(statusLines(f,container),['Prompt could not be resolved.','Multiple possible prompts. Source could not be determined.']); + assert.equal(f.walk(container,node=>node.textContent==='candidate text').length,0); + }finally{scope.dispose();f.restore();} +}); +test('class names in status lines render as text, never markup',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + const line='Prompt uses a node the inspector does not support ().'; + try{ + module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}}); + assert.deepEqual(statusLines(f,container),[line,line]); + assert.equal(f.walk(container,node=>node.tagName==='img').length,0); + assert.equal(f.walk(container,node=>Object.hasOwn(node,'innerHTML')).length,0); + }finally{scope.dispose();f.restore();} +}); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index f327e23..fcb8e4d 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -225,6 +225,48 @@ test('record accepts closed reason codes and rejects contradictory ones', () => } }); +test('resolution validator accepts closed reason codes and a bounded node record, rejects others', () => { + const toRecord = contract('toInspectionRecord'); + for (const reason_code of ['unsupported-node', 'runtime-value', 'composite']) { + for (const node of [{ id: '7', class_type: 'N'.repeat(200) }, { id: '7'.repeat(200), class_type: null }, null]) { + const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', reason_code, node }); + assert.doesNotThrow(() => toRecord(data, image(), extractGenerationFacts), JSON.stringify({ reason_code, node })); + } + } + for (const change of [ + { reason_code: 'unsupported-node', node: { id: '7', class_type: 'N'.repeat(201) } }, + { reason_code: 'unsupported-node', node: { id: '7'.repeat(201), class_type: 'X' } }, + { reason_code: 'unsupported-node', node: { id: '', class_type: 'X' } }, + { reason_code: 'unsupported-node', node: { id: '7', class_type: 'X', extra: true } }, + { reason_code: 'unsupported-node', node: [] }, + { reason_code: 'malformed', node: { id: '7', class_type: 'X' } }, + { reason_code: 'work-limit', node: { id: '7', class_type: 'X' } }, + { reason_code: 'no-sampler', node: { id: '7', class_type: 'X' } }, + { reason_code: 'Unsupported-Node', node: null }, + { reason_code: '', node: null }, + ]) { + const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', ...change }); + assert.throws(() => toRecord(data, image(), extractGenerationFacts), TypeError, JSON.stringify(change)); + } +}); + +test('role outcome lines are bounded text on non-resolved roles only', () => { + const toRecord = contract('toInspectionRecord'); + const line = 'Prompt uses a node the inspector does not support ().'; + const data = envelope(); data.prompts.negative = { status: 'unresolved', text: null, source: null, message: line }; + assert.equal(toRecord(data, image(), extractGenerationFacts).prompts.negative.message, line); + for (const [role, record] of [ + ['positive', { status: 'resolved', text: 'exact', source: 'execution', message: 'Hidden reason.' }], + ['negative', { status: 'unresolved', text: null, source: null, message: '' }], + ['negative', { status: 'unresolved', text: null, source: null, message: 7 }], + ['negative', { status: 'unresolved', text: null, source: null, message: null }], + ['negative', { status: 'unresolved', text: null, source: null, message: 'x'.repeat(513) }], + ]) { + const bad = envelope(); bad.prompts[role] = record; + assert.throws(() => toRecord(bad, image(), extractGenerationFacts), TypeError, JSON.stringify(record)); + } +}); + test('record rejects root path media and invalid versioned fields with retryable TypeError', () => { const toRecord = contract('toInspectionRecord'); for (const mutate of [ diff --git a/tests/test_api_prompt_inspector.py b/tests/test_api_prompt_inspector.py index f36c962..cc41e1b 100644 --- a/tests/test_api_prompt_inspector.py +++ b/tests/test_api_prompt_inspector.py @@ -244,10 +244,10 @@ class TestPromptInspector(ImageAPITestCase): async def test_corrupt_malformed_and_no_metadata_do_not_invent_text(self): path = self.output_dir / "image.png" - for content, expected in ( - (b"broken", "unresolved"), - (None, "absent"), - ("bad-json", "unresolved"), + for content, expected, message in ( + (b"broken", "unresolved", "Metadata in this file could not be read."), + (None, "absent", None), + ("bad-json", "unresolved", "Prompt metadata in this file is malformed."), ): if isinstance(content, bytes): path.write_bytes(content) @@ -256,6 +256,7 @@ class TestPromptInspector(ImageAPITestCase): _, result = await self.inspect() self.assertEqual(result["prompts"]["positive"]["status"], expected) self.assertIsNone(result["prompts"]["positive"]["text"]) + self.assertEqual(result["prompts"]["negative"].get("message"), message) async def test_compressed_international_and_post_pixel_metadata(self): import struct @@ -790,6 +791,9 @@ class TestPromptInspector(ImageAPITestCase): result["resolution"]["reason"], "No supported sampler pair found (saw FutureSampler)", ) + self.assertEqual( + result["prompts"]["positive"]["message"], "No supported sampler was found." + ) async def test_inspect_route_exposes_reason_code_and_blocking_node(self): # f06a's encoder is supported since Task 2.4; an unknown encoder is not. @@ -803,6 +807,14 @@ class TestPromptInspector(ImageAPITestCase): self.assertEqual( resolution["node"], {"id": "6", "class_type": "FutureTextEncode"} ) + self.assertEqual( + {role: side.get("message") for role, side in result["prompts"].items()}, + dict.fromkeys( + ("positive", "negative"), + "Prompt uses a node the inspector does not support " + "(FutureTextEncode).", + ), + ) png(self.output_dir / "image.png", prompt="{broken") _, result = await self.inspect() self.assertEqual(result["resolution"]["reason_code"], "malformed") diff --git a/tests/test_inspection_outcomes.py b/tests/test_inspection_outcomes.py new file mode 100644 index 0000000..2420ea9 --- /dev/null +++ b/tests/test_inspection_outcomes.py @@ -0,0 +1,355 @@ +"""Non-resolved prompt outcomes carry one short, truthful line per role.""" + +import json +import unittest +from dataclasses import replace +from unittest import mock + +from utils.inspection_outcomes import ( + CLASS_NAME_LENGTH, + REASON_CODES, + outcome_message, + project_resolution, +) +from utils.inspection_response import encode_inspection_response +from utils.prompt_inspector import inspect_prompt_evidence +from utils.prompt_pair_resolver import ( + EMPTY_POSITIVE_REASON, + INSPECTION_PROFILE, + MULTISTAGE_UNATTRIBUTED_REASON, + SEMANTICS_UNAVAILABLE_REASON, +) + +LIMITS = "Prompt exceeds inspection limits." + + +def _encode(text: str) -> dict: + return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["9", 0]}} + + +def _latent() -> dict: + return { + "class_type": "EmptyLatentImage", + "inputs": {"width": 8, "height": 8, "batch_size": 1}, + } + + +def _ksampler(positive: str = "7") -> dict: + return { + "class_type": "KSampler", + "inputs": { + "positive": [positive, 0], + "negative": ["6", 0], + "latent_image": ["4", 0], + "model": ["9", 0], + "seed": 1, + "steps": 2, + "cfg": 1.0, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + }, + } + + +def _dual_cfg() -> dict: + return { + "1": _encode("positive"), + "6": _encode("negative"), + "2": { + "class_type": "DualCFGGuider", + "inputs": { + "cond1": ["1", 0], + "cond2": ["1", 0], + "negative": ["6", 0], + "model": ["9", 0], + }, + }, + "3": { + "class_type": "SamplerCustomAdvanced", + "inputs": { + "guider": ["2", 0], + "noise": ["9", 0], + "sampler": ["9", 0], + "sigmas": ["9", 0], + "latent_image": ["4", 0], + }, + }, + "4": _latent(), + } + + +def _runtime() -> dict: + return { + "1": {"class_type": "PromptSearchList", "inputs": {"search": "cat"}}, + "7": { + "class_type": "CLIPTextEncode", + "inputs": {"text": ["1", 0], "clip": ["9", 0]}, + }, + "6": _encode("negative"), + "3": _ksampler(), + "4": _latent(), + } + + +def _composite() -> dict: + graph = _runtime() + graph["7"] = { + "class_type": "ConditioningCombine", + "inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["8", 0]}, + } + graph["8"] = _encode("other") + return graph + + +def _messages(evidence: dict) -> dict: + return {role: side.get("message") for role, side in evidence["prompts"].items()} + + +def _statuses(evidence: dict) -> dict: + return {role: side["status"] for role, side in evidence["prompts"].items()} + + +class TestOutcomeMessages(unittest.TestCase): + def assert_unresolved_with(self, evidence: dict, message: str) -> None: + self.assertEqual( + _statuses(evidence), {"positive": "unresolved", "negative": "unresolved"} + ) + self.assertEqual( + _messages(evidence), {"positive": message, "negative": message} + ) + + def test_unsupported_node_status_names_the_class(self): + evidence = inspect_prompt_evidence({"prompt": _dual_cfg()}) + self.assertEqual(evidence["resolution"]["reason_code"], "unsupported-node") + self.assert_unresolved_with( + evidence, + "Prompt uses a node the inspector does not support (DualCFGGuider).", + ) + + def test_runtime_and_composite_statuses(self): + self.assert_unresolved_with( + inspect_prompt_evidence({"prompt": _runtime()}), + "Prompt is chosen at run time (PromptSearchList).", + ) + self.assert_unresolved_with( + inspect_prompt_evidence({"prompt": _composite()}), + "Prompt combines several texts (ConditioningCombine).", + ) + + def test_limit_status(self): + tight = replace(INSPECTION_PROFILE, per_candidate_visits=1, total_visits=1) + evidence = inspect_prompt_evidence({"prompt": _runtime()}, profile=tight) + self.assertEqual(evidence["resolution"]["reason_code"], "work-limit") + self.assert_unresolved_with(evidence, LIMITS) + for code in ("work-limit", "depth-limit", "text-limit", "graph-ceiling"): + self.assertEqual( + outcome_message({"state": "unresolved", "reason_code": code}), LIMITS + ) + + def test_every_closed_code_and_known_uncoded_reason_has_a_line(self): + for code in REASON_CODES: + message = outcome_message({"state": "unresolved", "reason_code": code}) + self.assertIsInstance(message, str, code) + self.assertTrue(message.endswith("."), code) + self.assertNotIn("could not be resolved", message) + for reason, expected in ( + (EMPTY_POSITIVE_REASON, "Positive prompt is empty."), + ( + SEMANTICS_UNAVAILABLE_REASON, + "The inspector's node table is unavailable.", + ), + ( + MULTISTAGE_UNATTRIBUTED_REASON, + "Several sampler stages were found; their prompts were not compared.", + ), + ( + "Media metadata could not be read", + "Metadata in this file could not be read.", + ), + ): + resolution = {"state": "unresolved", "reason": reason, "reason_code": None} + self.assertEqual(outcome_message(resolution), expected) + + def test_named_codes_without_a_class_omit_the_name(self): + for code, line in ( + ("unsupported-node", "Prompt uses a node the inspector does not support."), + ("runtime-value", "Prompt is chosen at run time."), + ("composite", "Prompt combines several texts."), + ): + for node in (None, {"id": "7", "class_type": None}): + resolution = {"state": "unresolved", "reason_code": code, "node": node} + self.assertEqual(outcome_message(resolution), line) + + def test_class_name_is_bounded(self): + resolution = { + "state": "unresolved", + "reason_code": "unsupported-node", + "node": {"id": "7", "class_type": "N" * 500}, + } + self.assertEqual( + outcome_message(resolution), + "Prompt uses a node the inspector does not support " + f"({'N' * CLASS_NAME_LENGTH}).", + ) + self.assertEqual(CLASS_NAME_LENGTH, 200) + + def test_resolved_ambiguous_absent_and_unknown_outcomes_carry_no_line(self): + for resolution in ( + {"state": "resolved", "reason_code": None}, + {"state": "ambiguous", "reason_code": "multistage-differs"}, + {"state": "ambiguous", "reason_code": None, "reason": "Multiple"}, + {"state": "absent", "reason_code": None, "reason": "No parsed"}, + {"state": "unresolved", "reason_code": "guess"}, + {"state": "unresolved", "reason_code": None, "reason": "Anything else"}, + None, + ): + self.assertIsNone(outcome_message(resolution), resolution) + + def test_inspector_lines_for_no_sampler_malformed_and_empty_positive(self): + evidence = inspect_prompt_evidence({"prompt": {"1": _encode("x")}}) + self.assert_unresolved_with(evidence, "No supported sampler was found.") + evidence = inspect_prompt_evidence({"prompt": "not-json"}) + self.assert_unresolved_with( + evidence, "Prompt metadata in this file is malformed." + ) + graph = _runtime() + graph["7"] = _encode(" ") + evidence = inspect_prompt_evidence({"prompt": graph}) + self.assertEqual(evidence["resolution"]["reason"], EMPTY_POSITIVE_REASON) + self.assert_unresolved_with(evidence, "Positive prompt is empty.") + + def test_a_resolved_pair_with_an_empty_positive_explains_only_that_role(self): + side = {"text": " ", "provenance": {"text_source": "graph-inferred"}} + negative = {"text": "kept", "provenance": {"text_source": "graph-inferred"}} + resolved = { + "state": "resolved", + "positive": side, + "negative": negative, + "sampler_id": "3", + "reason": "", + "reason_code": None, + "node": None, + } + with mock.patch( + "utils.prompt_inspector.resolve_prompt_pair", return_value=resolved + ): + evidence = inspect_prompt_evidence({"prompt": {}}) + self.assertEqual( + evidence["prompts"]["positive"], + { + "status": "unresolved", + "text": None, + "source": None, + "message": "Positive prompt is empty.", + }, + ) + self.assertEqual(evidence["prompts"]["negative"]["text"], "kept") + self.assertNotIn("message", evidence["prompts"]["negative"]) + + def test_candidates_and_absence_keep_their_status_lines(self): + graph = _runtime() + graph["7"] = _encode("positive") + graph["5"] = _ksampler() + evidence = inspect_prompt_evidence({"prompt": graph}) + self.assertEqual(evidence["resolution"]["state"], "ambiguous") + self.assertEqual(_messages(evidence), {"positive": None, "negative": None}) + evidence = inspect_prompt_evidence({}) + self.assertEqual(_messages(evidence), {"positive": None, "negative": None}) + graph = _runtime() + graph["7"] = _encode("positive") + evidence = inspect_prompt_evidence({"prompt": graph}) + self.assertEqual(evidence["resolution"]["state"], "resolved") + self.assertEqual(_messages(evidence), {"positive": None, "negative": None}) + + +class TestResolutionProjection(unittest.TestCase): + def test_closed_codes_and_named_nodes_pass_unchanged(self): + for code in REASON_CODES: + resolution = { + "state": "unresolved", + "sampler_id": "3", + "reason": "r", + "reason_code": code, + "node": None, + } + self.assertEqual(project_resolution(resolution), resolution) + named = { + "state": "unresolved", + "sampler_id": None, + "reason": "r", + "reason_code": "composite", + "node": {"id": "7", "class_type": None}, + } + self.assertEqual(project_resolution(named), named) + + def test_unknown_codes_and_unbounded_nodes_fail_closed(self): + base = {"state": "unresolved", "sampler_id": None, "reason": "r"} + for code, node in ( + ("guess", None), + (1, None), + ("malformed", {"id": "7", "class_type": "X"}), + (None, {"id": "7", "class_type": "X"}), + ("unsupported-node", {"id": 7, "class_type": "X"}), + ("unsupported-node", {"id": "", "class_type": "X"}), + ("unsupported-node", {"id": "7"}), + ("unsupported-node", {"id": "7", "class_type": 7}), + ("unsupported-node", {"id": "7", "class_type": "X", "extra": 1}), + ("unsupported-node", "X"), + ): + projected = project_resolution({**base, "reason_code": code, "node": node}) + self.assertIsNone(projected["node"], (code, node)) + if code not in REASON_CODES: + self.assertIsNone(projected["reason_code"], code) + for state in ("resolved", "absent"): + projected = project_resolution( + {**base, "state": state, "reason_code": "work-limit", "node": None} + ) + self.assertIsNone(projected["reason_code"]) + long_name = project_resolution( + { + **base, + "reason_code": "runtime-value", + "node": {"id": "7" * 300, "class_type": "N" * 300}, + } + )["node"] + self.assertEqual( + long_name, {"id": "7" * CLASS_NAME_LENGTH, "class_type": "N" * 200} + ) + unnamed = project_resolution( + { + **base, + "reason_code": "composite", + "node": {"id": "7", "class_type": ""}, + } + )["node"] + self.assertEqual(unnamed, {"id": "7", "class_type": None}) + + def test_wire_carries_role_lines_and_projected_resolution(self): + evidence = inspect_prompt_evidence({"prompt": _dual_cfg()}) + resolution = dict(evidence["resolution"], reason_code="guess") + body = json.loads( + encode_inspection_response( + image={"root_index": 0, "path": "a.png", "filename": "a.png"}, + root_scope="a" * 64, + file={ + "observation": "current-file", + "source_label": "output", + "media_type": "image", + "extension": ".png", + "size": 1, + "modified_time": 1, + }, + prompts=evidence["prompts"], + resolution=resolution, + inspection=None, + library={"status": "none", "tags": []}, + ) + ) + self.assertEqual(body["prompts"], evidence["prompts"]) + self.assertIsNone(body["resolution"]["reason_code"]) + self.assertIsNone(body["resolution"]["node"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/inspection_outcomes.py b/utils/inspection_outcomes.py new file mode 100644 index 0000000..27eadf6 --- /dev/null +++ b/utils/inspection_outcomes.py @@ -0,0 +1,109 @@ +"""User-facing lines for non-resolved prompt outcomes, from the closed vocabulary. + +The inspector shows one line per unresolved role in place of the generic +"Prompt could not be resolved." Ambiguous and absent roles keep their status +copy, so a candidate is never presented as a confirmed prompt. The wire +projection accepts exactly the closed ``reason_code`` vocabulary and names a +blocking node only beside a code that names one. +""" + +from typing import Any, Optional + +from .prompt_pair_resolver import ( + EMPTY_POSITIVE_REASON, + MULTISTAGE_UNATTRIBUTED_REASON, + SEMANTICS_UNAVAILABLE_REASON, +) + +REASON_CODES = frozenset( + { + "work-limit", + "depth-limit", + "text-limit", + "graph-ceiling", + "unsupported-node", + "runtime-value", + "composite", + "no-sampler", + "malformed", + "multistage-differs", + } +) +LIMIT_CODES = frozenset({"work-limit", "depth-limit", "text-limit", "graph-ceiling"}) +# Codes whose outcome names the blocking node: ``node: {id, class_type}``. +NAMED_CODES = frozenset({"unsupported-node", "runtime-value", "composite"}) +# Bound for a node ID or class name on the wire and in a line. +CLASS_NAME_LENGTH = 200 +UNREADABLE_METADATA_REASON = "Media metadata could not be read" +_NODE_KEYS = frozenset({"id", "class_type"}) + +_NAMED_LINES = { + "unsupported-node": "Prompt uses a node the inspector does not support", + "runtime-value": "Prompt is chosen at run time", + "composite": "Prompt combines several texts", +} +_CODED_LINES = { + **dict.fromkeys(LIMIT_CODES, "Prompt exceeds inspection limits."), + "no-sampler": "No supported sampler was found.", + "malformed": "Prompt metadata in this file is malformed.", + "multistage-differs": "Sampler stages have different prompts.", +} +# Uncoded outcomes, keyed by the resolver's exact reason. +_UNCODED_LINES = { + EMPTY_POSITIVE_REASON: "Positive prompt is empty.", + MULTISTAGE_UNATTRIBUTED_REASON: ( + "Several sampler stages were found; their prompts were not compared." + ), + SEMANTICS_UNAVAILABLE_REASON: "The inspector's node table is unavailable.", + UNREADABLE_METADATA_REASON: "Metadata in this file could not be read.", +} + + +def _text(value: Any) -> Optional[str]: + return value if isinstance(value, str) else None + + +def _class_name(node: Any) -> Optional[str]: + if not isinstance(node, dict): + return None + name = node.get("class_type") + return name[:CLASS_NAME_LENGTH] if isinstance(name, str) and name else None + + +def outcome_message(resolution: Any) -> Optional[str]: + """One line for an unresolved outcome, or ``None`` to keep the status copy.""" + if not isinstance(resolution, dict) or resolution.get("state") != "unresolved": + return None + code = _text(resolution.get("reason_code")) + if code in _NAMED_LINES: + name = _class_name(resolution.get("node")) + return f"{_NAMED_LINES[code]} ({name})." if name else f"{_NAMED_LINES[code]}." + if code is None: + return _UNCODED_LINES.get(_text(resolution.get("reason"))) + return _CODED_LINES.get(code) + + +def _node(code: Optional[str], node: Any) -> Optional[dict]: + if code not in NAMED_CODES or not isinstance(node, dict) or set(node) != _NODE_KEYS: + return None + node_id, name = node["id"], node["class_type"] + if not isinstance(node_id, str) or not node_id: + return None + if name is not None and not isinstance(name, str): + return None + return { + "id": node_id[:CLASS_NAME_LENGTH], + "class_type": name[:CLASS_NAME_LENGTH] if name else None, + } + + +def project_resolution(resolution: dict) -> dict: + """Fail closed: an unknown or contradictory code and its node are dropped.""" + code = _text(resolution.get("reason_code")) + if code not in REASON_CODES or resolution.get("state") in ("resolved", "absent"): + code = None + return { + **resolution, + "reason_code": code, + "node": _node(code, resolution.get("node")), + } diff --git a/utils/inspection_response.py b/utils/inspection_response.py index b8a4669..357e97b 100644 --- a/utils/inspection_response.py +++ b/utils/inspection_response.py @@ -5,6 +5,8 @@ import math from decimal import Decimal, InvalidOperation from typing import TYPE_CHECKING, Literal +from .inspection_outcomes import project_resolution + if TYPE_CHECKING: from .prompt_inspector import InspectionRead @@ -329,7 +331,7 @@ def _encode_inspection( **identity, "file": _file_projection(file, inspection), "prompts": prompts, - "resolution": resolution, + "resolution": project_resolution(resolution), "metadata": metadata, "library": _library_projection(library), } diff --git a/utils/prompt_inspector.py b/utils/prompt_inspector.py index f38aa9a..5646b98 100644 --- a/utils/prompt_inspector.py +++ b/utils/prompt_inspector.py @@ -13,8 +13,10 @@ from typing import Any from PIL import Image +from .inspection_outcomes import UNREADABLE_METADATA_REASON, outcome_message from .pair_execution import EXECUTION_KEY from .prompt_pair_resolver import ( + EMPTY_POSITIVE_REASON, INSPECTION_PROFILE, ResolverProfile, resolve_prompt_pair, @@ -400,6 +402,39 @@ def _side(status: str, text: str | None = None, source: str | None = None) -> di return {"status": status, "text": text, "source": source} +_EMPTY_POSITIVE = outcome_message( + {"state": "unresolved", "reason": EMPTY_POSITIVE_REASON} +) + + +def _explained(prompts: dict, resolution: dict) -> dict: + """Each unresolved role carries the outcome's line; other roles are kept.""" + message = outcome_message(resolution) + if message is None: + return prompts + return { + role: ( + {**side, "message": message} + if side["status"] == "unresolved" and "message" not in side + else side + ) + for role, side in prompts.items() + } + + +def unreadable_evidence() -> dict: + """Both roles when the file's metadata could not be read at all.""" + resolution = { + "state": "unresolved", + "sampler_id": None, + "reason": UNREADABLE_METADATA_REASON, + "reason_code": None, + "node": None, + } + prompts = {role: _side("unresolved") for role in ("positive", "negative")} + return {"prompts": _explained(prompts, resolution), "resolution": resolution} + + def inspect_prompt_evidence( metadata: dict, saved: dict | None = None, @@ -447,21 +482,20 @@ def inspect_prompt_evidence( }, } canonical = dict(metadata) + malformed = { + "prompts": _explained( + {role: _side("unresolved") for role in ("positive", "negative")}, + resolution, + ), + "resolution": resolution, + } if "prompt" not in canonical and "parameters" not in canonical: - return { - "prompts": {role: _side("unresolved") for role in ("positive", "negative")}, - "resolution": resolution, - } + return malformed if isinstance(canonical.get("prompt"), str): try: canonical["prompt"] = json.loads(canonical["prompt"]) except (ValueError, RecursionError): - return { - "prompts": { - role: _side("unresolved") for role in ("positive", "negative") - }, - "resolution": resolution, - } + return malformed resolved = resolve_prompt_pair(canonical, profile=profile) state = resolved["state"] result = {} @@ -475,18 +509,19 @@ def inspect_prompt_evidence( result[role] = _side( "resolved", side["text"], side["provenance"]["text_source"] ) + elif state == "resolved" and isinstance(side, dict): + # A resolved pair withholds only an empty positive. + result[role] = {**_side("unresolved"), "message": _EMPTY_POSITIVE} else: result[role] = _side(state if state != "resolved" else "unresolved") - return { - "prompts": result, - "resolution": { - "state": state, - "sampler_id": resolved.get("sampler_id"), - "reason": resolved.get("reason", ""), - "reason_code": resolved.get("reason_code"), - "node": resolved.get("node"), - }, + resolution = { + "state": state, + "sampler_id": resolved.get("sampler_id"), + "reason": resolved.get("reason", ""), + "reason_code": resolved.get("reason_code"), + "node": resolved.get("node"), } + return {"prompts": _explained(result, resolution), "resolution": resolution} def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict: diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py index b6bef3e..f343602 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -151,6 +151,9 @@ _ZEROED_SOURCE = "zeroed-conditioning" _MAX_BRANCH_CANDIDATES = 32 # Statically undecidable text (a search, a list output): never pick an item. _RUNTIME_REASON = "Text is decided at run time" +# Uncoded outcomes the inspector explains by their exact reason. +EMPTY_POSITIVE_REASON = "Positive prompt is empty" +MULTISTAGE_UNATTRIBUTED_REASON = "Multistage sampler ancestry is ambiguous" _INCOMPATIBLE_SLOT = "Output slot has an incompatible type" # Trace outcomes that leave an input unverifiable against execution evidence. _UNVERIFIABLE_CODES = frozenset({"unsupported-node", "runtime-value"}) @@ -1189,7 +1192,7 @@ class _GraphResolver: inputs.get(conditioning["positive"]), sampler_id, guider_id, depth ) if not positive["text"].strip(): - raise _Unresolved("Positive prompt is empty") + raise _Unresolved(EMPTY_POSITIVE_REASON) negative_name = conditioning["negative"] if negative_name is None or negative_name not in inputs: return _result( @@ -1227,7 +1230,7 @@ class _GraphResolver: name, inputs = self.node(node_id) if name in self.stage_classes: # ``multistage-differs`` is reserved for when stage texts are compared. - raise _Unresolved("Multistage sampler ancestry is ambiguous") + raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON) if name in ("EmptyLatentImage", "EmptySD3LatentImage") and slot == 0: return if name == "Reroute" and slot == 0 and "value" in inputs: @@ -1287,7 +1290,7 @@ class _GraphResolver: if name in self.stage_classes: # ``multistage-differs`` is reserved for when stage texts are # compared (PR3); until then no stage's prompt is chosen. - raise _Unresolved("Multistage sampler ancestry is ambiguous") + raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON) if self.stage_shaped(name, inputs): raise self.unsupported("Unsupported latent ancestry", node_id) self.enqueue(queue, queued, inputs, depth + 1) @@ -1344,7 +1347,7 @@ def _parameters(value: Any, max_text_length: int) -> dict: positive, found, negative = body.partition(delimiter) positive = positive.strip() if not positive: - return _result("unresolved", reason="Positive prompt is empty") + return _result("unresolved", reason=EMPTY_POSITIVE_REASON) provenance = { "format": "a1111", "field": "parameters", @@ -1424,7 +1427,7 @@ def resolve_prompt_pair( return _legacy_result(result) if profile.legacy else result -_SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable" +SEMANTICS_UNAVAILABLE_REASON = "Inspection node semantics are unavailable" def _inspection_semantics() -> Optional[Any]: @@ -1495,7 +1498,7 @@ def _resolve( if profile.semantics: semantics = _inspection_semantics() if semantics is None: - return _result("unresolved", reason=_SEMANTICS_UNAVAILABLE) + return _result("unresolved", reason=SEMANTICS_UNAVAILABLE_REASON) normalized = {str(node_id): node for node_id, node in graph.items()} if len(normalized) != len(graph): return _result( diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 3997a1d..9b7fb6b 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -9,6 +9,10 @@ const FACT_FIELDS = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model']; const RECORD_FORMATS = ['original-text', 'reserialized-json', 'invalid', 'limited']; const TEXTLESS_FORMATS = ['invalid', 'limited']; const REASON_CODES = ['work-limit', 'depth-limit', 'text-limit', 'graph-ceiling', 'unsupported-node', 'runtime-value', 'composite', 'no-sampler', 'malformed', 'multistage-differs']; +// Only these codes name the blocking node; node IDs and class names are bounded. +const NAMED_CODES = ['unsupported-node', 'runtime-value', 'composite']; +const NAME_LENGTH = 200, MESSAGE_LENGTH = 512; +const boundedText = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= limit; const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); const validScope = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) && value.length === 64; @@ -77,7 +81,8 @@ function promptsFor(value) { if (record === undefined) continue; if (!object(record) || typeof record.status !== 'string' || (record.text !== undefined && record.text !== null && typeof record.text !== 'string') || - (record.status === 'resolved' && !Object.hasOwn(confirmed, role))) throw new TypeError('Invalid inspection role'); + (record.status === 'resolved' && !Object.hasOwn(confirmed, role)) || + (record.message !== undefined && (record.status === 'resolved' || !boundedText(record.message, MESSAGE_LENGTH)))) throw new TypeError('Invalid inspection role'); } return value; } @@ -141,7 +146,8 @@ function resolutionFor(value) { const code = value.reason_code ?? null, node = value.node ?? null; // Codes explain a non-resolved outcome; a blocking node is named only with its code. if ((code !== null && (!REASON_CODES.includes(code) || ['resolved', 'absent'].includes(value.state))) || - (node !== null && (code === null || !object(node) || typeof node.id !== 'string' || (node.class_type !== null && typeof node.class_type !== 'string')))) throw new TypeError('Invalid inspection resolution reason'); + (node !== null && (!NAMED_CODES.includes(code) || !object(node) || Object.keys(node).sort().join() !== 'class_type,id' || + !boundedText(node.id, NAME_LENGTH) || (node.class_type !== null && !boundedText(node.class_type, NAME_LENGTH))))) throw new TypeError('Invalid inspection resolution reason'); return value; } diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index f221b67..f3a3bcb 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -114,7 +114,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role heading.append(actions); const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status'); block.append(status); - } else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted')); + } else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'muted')); container.append(block); } if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {