* fix(inspector): fall back to the positive's category and tags for recipe-linked images Maintainer ruling D39 (2026-10-21): a recipe-linked image shows the recipe's own category and tags when set, otherwise the linked positive prompt's, each field on its own and without merging tag sets. Set category (#259) and the Images category and Tags filters work on the positive, so the panel now agrees with them. The inspection library replaces metadata_scope with category_scope and tags_scope so the category chip's hidden scope prefix (D21) and the tag chips' filter-button rule (#260 D2) follow the value actually shown. History's media inspector uses the same shared rule. The concept fixture's positive_links workaround is gone: the comparison now matches the concept without it. * test(inspector): assert the D39 fallback tag chips in their saved order * fix(inspector): treat Unicode whitespace as a blank History recipe category * test(inspector): cover mixed-scope panels, invalid library scopes and the skipped positive tags query
475 lines
16 KiB
Python
475 lines
16 KiB
Python
"""Exact inspection-only graph projection and bounded JSON wire encoding."""
|
|
|
|
import json
|
|
import math
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import TYPE_CHECKING, Any, Literal, Optional
|
|
|
|
from .inspection_outcomes import MESSAGE_LENGTH, project_resolution
|
|
|
|
if TYPE_CHECKING:
|
|
from .prompt_inspector import InspectionRead
|
|
|
|
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
MAX_LIBRARY_BYTES = 32 * 1024
|
|
_MAX_SAFE_INTEGER = 2**53 - 1
|
|
_RECORD_KEYS = frozenset(
|
|
{"prompt", "workflow", "parameters", "comfyassets_prompt_pairs"}
|
|
)
|
|
_FORMATS = frozenset({"original-text", "reserialized-json", "invalid", "limited"})
|
|
_PROMPT_NODE_KEYS = frozenset({"class_type", "inputs"})
|
|
MAX_PROJECTION_DEPTH = 64
|
|
# Backstop, not an expected outcome: executable API nodes for a prompt; UI nodes
|
|
# including subgraph definitions for a workflow.
|
|
GRAPH_NODE_CEILING = 20_000
|
|
ProjectionKind = Literal["prompt", "workflow", "carrier"]
|
|
|
|
|
|
class _ProjectionLimit(ValueError):
|
|
"""A present graph cannot be projected within inspection limits."""
|
|
|
|
|
|
def _pairs(items: list[tuple]) -> dict:
|
|
result = {}
|
|
for key, value in items:
|
|
if key in result:
|
|
raise ValueError("Duplicate JSON key")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
class _Nonfinite:
|
|
"""A parsed ``NaN``/``Infinity`` literal: never a fact, projected as null."""
|
|
|
|
|
|
_NONFINITE = _Nonfinite()
|
|
|
|
|
|
def _nonfinite(value: str) -> _Nonfinite:
|
|
return _NONFINITE
|
|
|
|
|
|
def _normalize(value: object, depth: int = 1, *, retain: bool = True) -> object:
|
|
"""Walk bounded source once; unretained values are only validated as JSON."""
|
|
if isinstance(value, (dict, list)):
|
|
if depth > MAX_PROJECTION_DEPTH:
|
|
raise _ProjectionLimit()
|
|
if isinstance(value, dict):
|
|
result = {} if retain else None
|
|
for key, child in value.items():
|
|
normalized = _normalize(child, depth + 1, retain=retain)
|
|
if retain:
|
|
result[key] = normalized
|
|
else:
|
|
result = [] if retain else None
|
|
for child in value:
|
|
normalized = _normalize(child, depth + 1, retain=retain)
|
|
if retain:
|
|
result.append(normalized)
|
|
return result
|
|
if not retain or value is _NONFINITE:
|
|
return None
|
|
if isinstance(value, Decimal):
|
|
if not value.is_finite():
|
|
raise ValueError("Nonfinite JSON number")
|
|
# str keeps large exponents compact and never rounds through a float.
|
|
return str(value)
|
|
if type(value) is int and abs(value) > _MAX_SAFE_INTEGER:
|
|
return str(value)
|
|
return value
|
|
|
|
|
|
def _project_prompt(tree: object, depth: int = 1) -> dict:
|
|
"""Each API node keeps exactly ``class_type`` and ``inputs``.
|
|
|
|
Other node keys (``_meta``, ``is_changed``, future keys) are validated and
|
|
dropped, so a nonfinite value there never reaches the projection.
|
|
"""
|
|
if not isinstance(tree, dict):
|
|
raise ValueError("Prompt graph must be an object of nodes")
|
|
if depth + 1 > MAX_PROJECTION_DEPTH: # the root and its node objects
|
|
raise _ProjectionLimit()
|
|
result = {}
|
|
for node_id, node in tree.items():
|
|
if not isinstance(node, dict):
|
|
result[node_id] = _normalize(node, depth + 1)
|
|
continue
|
|
projected = {}
|
|
for key, child in node.items():
|
|
keep = key in _PROMPT_NODE_KEYS
|
|
normalized = _normalize(child, depth + 2, retain=keep)
|
|
if keep:
|
|
projected[key] = normalized
|
|
result[node_id] = projected
|
|
return result
|
|
|
|
|
|
def _project(tree: object, kind: str, depth: int = 1) -> object:
|
|
if kind == "prompt":
|
|
return _project_prompt(tree, depth)
|
|
if kind == "workflow":
|
|
return _normalize(tree, depth)
|
|
if not isinstance(tree, dict):
|
|
raise ValueError("Malformed carrier")
|
|
result = {}
|
|
for key, member in tree.items():
|
|
if key not in ("prompt", "workflow"):
|
|
_normalize(member, depth + 1, retain=False)
|
|
elif isinstance(member, str):
|
|
result[key] = member # projected by its own kind when read
|
|
elif key == "prompt" and not isinstance(member, dict):
|
|
result[key] = _normalize(member, depth + 1)
|
|
else:
|
|
result[key] = _project(member, key, depth + 1)
|
|
return result
|
|
|
|
|
|
def _strict_projection(text: str, *, kind: ProjectionKind) -> object:
|
|
"""Parse exactly (Decimal, no duplicate keys) and project by record kind."""
|
|
try:
|
|
tree = json.loads(
|
|
text,
|
|
parse_float=Decimal,
|
|
object_pairs_hook=_pairs,
|
|
parse_constant=_nonfinite,
|
|
)
|
|
return _project(tree, kind)
|
|
except RecursionError:
|
|
raise _ProjectionLimit() from None
|
|
except InvalidOperation:
|
|
raise ValueError("JSON decimal exceeds supported range") from None
|
|
|
|
|
|
def _node_count(value: dict, *, workflow: bool) -> int:
|
|
if not workflow:
|
|
return len(value)
|
|
nodes = value.get("nodes")
|
|
count = len(nodes) if isinstance(nodes, list) else 0
|
|
definitions = value.get("definitions")
|
|
subgraphs = definitions.get("subgraphs") if isinstance(definitions, dict) else None
|
|
for subgraph in subgraphs if isinstance(subgraphs, list) else ():
|
|
inner = subgraph.get("nodes") if isinstance(subgraph, dict) else None
|
|
count += len(inner) if isinstance(inner, list) else 0
|
|
return count
|
|
|
|
|
|
def _graph(value: object, *, workflow: bool = False) -> dict:
|
|
if not isinstance(value, dict):
|
|
return {"status": "malformed", "value": None}
|
|
if _node_count(value, workflow=workflow) > GRAPH_NODE_CEILING:
|
|
return {"status": "limited", "value": None}
|
|
return {"status": "parsed", "value": value}
|
|
|
|
|
|
def project_graph(text: str, kind: Literal["prompt", "workflow"]) -> dict:
|
|
"""One record's graph as the inspector projects it: ``{status, value}``.
|
|
|
|
``status`` is ``parsed`` (``value`` is the projected graph the browser
|
|
parser receives), ``limited`` (past the depth or node bound) or
|
|
``malformed``. Shared with the workspace card so both read one bound.
|
|
"""
|
|
try:
|
|
return _graph(_strict_projection(text, kind=kind), workflow=kind == "workflow")
|
|
except _ProjectionLimit:
|
|
return {"status": "limited", "value": None}
|
|
except (ValueError, TypeError, KeyError, OverflowError):
|
|
return {"status": "malformed", "value": None}
|
|
|
|
|
|
def _metadata(inspection: "InspectionRead | None") -> dict:
|
|
formats = (
|
|
{}
|
|
if inspection is None
|
|
else {
|
|
key: value
|
|
for key, value in inspection.record_formats.items()
|
|
if key in _RECORD_KEYS and value in _FORMATS
|
|
}
|
|
)
|
|
if inspection is not None:
|
|
# A record over its read budget has no text: like ``invalid``, raw omits
|
|
# it, and its graph is ``limited`` rather than parsed from nothing.
|
|
for key in inspection.limited_records & _RECORD_KEYS:
|
|
formats[key] = "limited"
|
|
raw = (
|
|
{}
|
|
if inspection is None
|
|
else {
|
|
key: value
|
|
for key, value in inspection.metadata.items()
|
|
if key in _RECORD_KEYS
|
|
and isinstance(value, str)
|
|
and formats.get(key) != "limited"
|
|
}
|
|
)
|
|
result = {
|
|
"status": (
|
|
"unreadable"
|
|
if inspection is None
|
|
else ("available" if raw or formats else "absent")
|
|
),
|
|
"omitted_reason": None,
|
|
"raw": raw,
|
|
"record_formats": formats,
|
|
"parameters": raw.get("parameters"),
|
|
}
|
|
carrier, failed = None, None
|
|
if inspection is not None and inspection.video_carrier_text is not None:
|
|
try:
|
|
carrier = _strict_projection(inspection.video_carrier_text, kind="carrier")
|
|
except _ProjectionLimit:
|
|
failed = "limited"
|
|
except (ValueError, TypeError, OverflowError):
|
|
failed = "malformed"
|
|
for key, field in (("prompt", "prompt_graph"), ("workflow", "workflow_graph")):
|
|
graph = {"status": "absent", "value": None}
|
|
if key in raw or key in formats:
|
|
if failed or formats.get(key) == "limited":
|
|
graph["status"] = failed or "limited"
|
|
elif formats.get(key) == "invalid":
|
|
graph["status"] = "malformed"
|
|
elif carrier is None and formats.get(key) == "reserialized-json":
|
|
graph["status"] = "limited"
|
|
elif carrier is None:
|
|
graph = project_graph(raw.get(key), key)
|
|
else:
|
|
try:
|
|
value = carrier.get(key)
|
|
if isinstance(value, str):
|
|
value = _strict_projection(value, kind=key)
|
|
graph = _graph(value, workflow=key == "workflow")
|
|
except _ProjectionLimit:
|
|
graph["status"] = "limited"
|
|
except (ValueError, TypeError, KeyError, OverflowError):
|
|
graph["status"] = "malformed"
|
|
result[field] = graph
|
|
return result
|
|
|
|
|
|
def _bytes(value: dict) -> bytes:
|
|
# JSON preserves isolated surrogate evidence through escaped code units.
|
|
return json.dumps(
|
|
value, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
|
).encode("utf-8", "backslashreplace")
|
|
|
|
|
|
def _finite(value: object, *, positive: bool = False) -> bool:
|
|
try:
|
|
return (
|
|
type(value) in (int, float)
|
|
and math.isfinite(value)
|
|
and (value > 0 if positive else value >= 0)
|
|
)
|
|
except OverflowError:
|
|
return False
|
|
|
|
|
|
def _file_projection(file: dict, inspection: "InspectionRead | None") -> dict:
|
|
source = dict(file)
|
|
if inspection is not None:
|
|
source.update(
|
|
format=inspection.format, width=inspection.width, height=inspection.height
|
|
)
|
|
result = {"observation": "current-file"}
|
|
for key in ("source_label", "extension", "format"):
|
|
value = source.get(key)
|
|
if value is None and key == "format":
|
|
result[key] = None
|
|
elif isinstance(value, str):
|
|
result[key] = value
|
|
if source.get("media_type") in ("image", "video"):
|
|
result["media_type"] = source["media_type"]
|
|
for key in ("size", "modified_time", "width", "height"):
|
|
if key not in source:
|
|
continue
|
|
value = source[key]
|
|
if value is None or _finite(value, positive=key in ("width", "height")):
|
|
if key not in ("width", "height") or value is None or type(value) is int:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _blank(value: str | bytes | None) -> bool:
|
|
if isinstance(value, bytes):
|
|
value = value.decode("utf-8", "replace")
|
|
return value is None or not value.strip()
|
|
|
|
|
|
def library_metadata(
|
|
positive_category: str | bytes | None,
|
|
positive_tags: list,
|
|
recipe: tuple | None = None,
|
|
) -> dict:
|
|
"""The panel's saved category and tags, each with the scope it came from.
|
|
|
|
D39 (maintainer ruling, 2026-10-21): a recipe-linked image shows the recipe's
|
|
own category and tags when set; otherwise each field separately falls back to
|
|
the linked positive prompt's, the values Set category writes and the Images
|
|
filters match. Tag sets are never merged. ``recipe`` is (category, tags).
|
|
"""
|
|
category, category_scope = positive_category, "positive"
|
|
tags, tags_scope = positive_tags, "positive"
|
|
if recipe is not None:
|
|
if not _blank(recipe[0]):
|
|
category, category_scope = recipe[0], "pair"
|
|
if recipe[1]:
|
|
tags, tags_scope = recipe[1], "pair"
|
|
return {
|
|
"category": category,
|
|
"category_scope": category_scope,
|
|
"tags": tags,
|
|
"tags_scope": tags_scope,
|
|
}
|
|
|
|
|
|
def _library_projection(library: dict) -> dict:
|
|
status = library.get("status")
|
|
result = {
|
|
"status": (
|
|
status
|
|
if status in ("linked", "none", "ambiguous", "unavailable", "limited")
|
|
else "unavailable"
|
|
),
|
|
"scope": "saved-library",
|
|
}
|
|
for key in ("positive_id", "pair_id"):
|
|
value = library.get(key)
|
|
if value is None or type(value) is int and 0 < value <= _MAX_SAFE_INTEGER:
|
|
result[key] = value
|
|
for key in ("title", "category"):
|
|
value = library.get(key)
|
|
if value is None or isinstance(value, str):
|
|
result[key] = value
|
|
for key in ("category_scope", "tags_scope"):
|
|
scope = library.get(key)
|
|
if scope in ("pair", "positive", None):
|
|
result[key] = scope
|
|
tags = library.get("tags")
|
|
result["tags"] = (
|
|
[tag for tag in tags if isinstance(tag, str)] if isinstance(tags, list) else []
|
|
)
|
|
if len(_bytes(result)) > MAX_LIBRARY_BYTES:
|
|
return {
|
|
"status": "limited",
|
|
"scope": "saved-library",
|
|
"positive_id": None,
|
|
"pair_id": None,
|
|
"title": None,
|
|
"category": None,
|
|
"tags": [],
|
|
"category_scope": None,
|
|
"tags_scope": None,
|
|
}
|
|
return result
|
|
|
|
|
|
def _bounded_message(value: Any) -> Optional[str]:
|
|
"""A role line as the client accepts it: 1 to 512 UTF-16 code units."""
|
|
if not isinstance(value, str) or not value:
|
|
return None
|
|
value = value[:MESSAGE_LENGTH]
|
|
while len(value.encode("utf-16-le", "surrogatepass")) > 2 * MESSAGE_LENGTH:
|
|
value = value[:-1]
|
|
return value
|
|
|
|
|
|
def _bounded_messages(prompts: dict) -> dict:
|
|
"""Keep a role ``message`` only as bounded text on a non-resolved role."""
|
|
bounded = {}
|
|
for role, side in prompts.items():
|
|
if isinstance(side, dict) and "message" in side:
|
|
side = {key: item for key, item in side.items() if key != "message"}
|
|
message = _bounded_message(prompts[role]["message"])
|
|
if message is not None and side.get("status") != "resolved":
|
|
side["message"] = message
|
|
bounded[role] = side
|
|
return bounded
|
|
|
|
|
|
def _encode_inspection(
|
|
*,
|
|
identity: dict,
|
|
file: dict,
|
|
prompts: dict,
|
|
resolution: dict,
|
|
inspection: "InspectionRead | None",
|
|
library: dict,
|
|
) -> bytes:
|
|
"""Serialize exactly once per budget step, preserving identity and roles."""
|
|
metadata = _metadata(inspection)
|
|
response = {
|
|
"success": True,
|
|
"inspection_version": 1,
|
|
**identity,
|
|
"file": _file_projection(file, inspection),
|
|
"prompts": _bounded_messages(prompts),
|
|
"resolution": project_resolution(resolution),
|
|
"metadata": metadata,
|
|
"library": _library_projection(library),
|
|
}
|
|
body = _bytes(response)
|
|
# Omission order keeps prompt evidence longest: the workflow graph first,
|
|
# then the prompt graph, then all raw records atomically.
|
|
for field in ("workflow_graph", "prompt_graph"):
|
|
if len(body) <= MAX_RESPONSE_BYTES:
|
|
break
|
|
if metadata[field]["status"] != "absent":
|
|
metadata[field] = {"status": "limited", "value": None}
|
|
body = _bytes(response)
|
|
if len(body) > MAX_RESPONSE_BYTES:
|
|
metadata.update(
|
|
raw={},
|
|
parameters=None,
|
|
status="limited",
|
|
omitted_reason="response-size-limit",
|
|
)
|
|
body = _bytes(response)
|
|
if len(body) > MAX_RESPONSE_BYTES:
|
|
raise ValueError("Inspection response exceeds limits")
|
|
return body
|
|
|
|
|
|
def encode_inspection_response(
|
|
*,
|
|
image: dict,
|
|
root_scope: str,
|
|
file: dict,
|
|
prompts: dict,
|
|
resolution: dict,
|
|
inspection: "InspectionRead | None",
|
|
library: dict,
|
|
) -> bytes:
|
|
"""Preserve the existing rooted wire and its bounded metadata projection."""
|
|
return _encode_inspection(
|
|
identity={"root_scope": root_scope, "image": image},
|
|
file=file,
|
|
prompts=prompts,
|
|
resolution=resolution,
|
|
inspection=inspection,
|
|
library=library,
|
|
)
|
|
|
|
|
|
def encode_history_inspection_response(
|
|
*,
|
|
current_output: dict,
|
|
file: dict,
|
|
prompts: dict,
|
|
resolution: dict,
|
|
inspection: "InspectionRead | None",
|
|
library: dict,
|
|
) -> bytes:
|
|
"""Use occurrence identity with the same current-file evidence projection."""
|
|
return _encode_inspection(
|
|
identity={
|
|
"history_version": 1,
|
|
"history_epoch": current_output["history_epoch"],
|
|
"current_output": current_output,
|
|
},
|
|
file=file,
|
|
prompts=prompts,
|
|
resolution=resolution,
|
|
inspection=inspection,
|
|
library=library,
|
|
)
|