Files
ComfyAssets-ComfyUI_PromptM…/utils/file_prompt.py
T
Vito Sansevero 4b4fa7f8ff fix(ingestion): keep the file-name saver anchor out of the ladder (#390)
The #390 ruling queues a file that only its saver's file name resolves for
Needs review, never linking it. inspection_resolution gains name_anchor;
the ladder passes False, so such a file falls to the legacy floor in the
scan (and to Needs review) and is never stored by the monitor, while the
recorded saver still decides. The inspector keeps the name anchor.
2026-10-28 06:29:23 -07:00

402 lines
14 KiB
Python

"""The per-file prompt decision shared by the output scan and the monitor (#247).
``decide_file_prompt`` is the ladder: which prompt one output file names,
from its metadata alone plus, when the graph cannot decide, the save node
recorded history says wrote it. It does no file or database I/O; the caller
reads the metadata, the file size and history (through ``lookup``) and
stores the ``Decision``.
The default resolver profile decides first, as ingestion always has. Only
for a file it leaves ambiguous or unresolved does the ladder ask the
inspector's resolve (``inspection_resolution``: the inspection profile, then
the recorded saver anchor; never the file-name saver anchor, whose files go
to Needs review by the #390 ruling), then the #170 browser parser (the legacy floor).
The policies differ in one place: for a file the inspector calls ambiguous,
the scan keeps the legacy floor and the monitor stores nothing. Junk text
(``junk_text``) is never stored, whichever step found it.
"""
import json
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Mapping, Optional
from .generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_page_json,
)
from .prompt_inspector import inspection_resolution
from .prompt_pair_resolver import _numeric, resolve_prompt_pair, sampler_less_prompt
from .review_capture import reviewable
try:
from ..database.pair_ingestion import blank_positive
except ImportError:
from database.pair_ingestion import blank_positive
class Policy(Enum):
SCAN = "scan"
MONITOR = "monitor"
PAIR = "pair"
POSITIVE = "positive"
HISTORY = "history"
NEVER = "never"
KINDS = (PAIR, POSITIVE, HISTORY, NEVER)
_UNDECIDED = ("ambiguous", "unresolved")
@dataclass(frozen=True)
class Decision:
"""What to store for one file (see the design's "The ladder").
``kind``: one of ``KINDS``. ``text``: the storage text of the positive.
``canonical``: the parsed metadata stored with the link. ``resolution``:
the resolution that decided a ``pair``, else the default profile's
result. ``source``: which step named the text (log lines and the scan's
outcome detail). ``review``: ``ambiguous`` or ``untraced`` when the
default profile was ambiguous (the scan's Needs review lists).
``try_recorded_run``: the scan tries the recorded run's stored prompt
when nothing else links. ``unlinked_status``: the scan status when
nothing links (None: no prompt metadata).
"""
kind: str
text: Optional[str] = None
canonical: dict = field(default_factory=dict)
resolution: Optional[dict] = None
source: str = "none"
review: Optional[str] = None
try_recorded_run: bool = False
unlinked_status: Optional[str] = None
@dataclass(frozen=True)
class _File:
"""The ladder's inputs beside the metadata."""
metadata: Mapping
canonical: dict
resolution: dict
review: Optional[str]
file_name: Optional[str]
in_output: bool
lookup: Optional[Callable[[], Any]]
policy: Policy
def decide_file_prompt(
metadata: Any,
*,
file_name: Optional[str],
file_size: Optional[int],
in_output: bool,
lookup: Optional[Callable[[], Any]],
policy: Policy,
) -> Decision:
"""The ladder for one file's raw ``metadata``.
``prompt``/``workflow`` may be JSON strings (the scan) or parsed values
(the monitor). ``lookup`` returns the file's ``RecordedOutput`` or None;
it is None when history must not be asked yet. It is called at most
once, and only for a graph the default profile leaves undecided.
"""
if not metadata:
return Decision(NEVER, try_recorded_run=True)
canonical = _parsed(metadata)
if not canonical.get("prompt") and not canonical.get("parameters"):
return Decision(NEVER, canonical=canonical, try_recorded_run=True)
_with_size(canonical, file_size)
resolution = resolve_prompt_pair(canonical)
if blank_positive(resolution):
# Only prepend/append text: no stored prompt can own it (#342)
resolution = {**resolution, "state": "unresolved"}
found = _File(
metadata,
canonical,
resolution,
_review(resolution),
file_name,
in_output,
lookup,
policy,
)
return _gated(_decide(found))
def _decide(found: _File) -> Decision:
state = found.resolution["state"]
graph = found.canonical.get("prompt")
if state == "resolved":
positive = found.resolution["positive"]
text = positive.get("storage_text", positive["text"])
return _decision(found, PAIR, text, "resolver")
if graph and state in _UNDECIDED:
return _undecided_graph(found)
if state == "ambiguous":
return _never(found, _unlinked(found))
if state == "unresolved" and not isinstance(found.canonical.get("parameters"), str):
return _never(found, "unresolved")
if graph:
return _sampler_less(found, graph)
text = _a1111_positive(found.metadata)
if text is None:
return _never(found, "found")
return _decision(found, POSITIVE, text, "a1111")
def _undecided_graph(found: _File) -> Decision:
recorded = found.lookup() if found.lookup is not None else None
saver = recorded.saver_id if recorded is not None else None
inspected = inspection_resolution(
found.metadata, found.file_name, saver, name_anchor=False
)
text = _inspected_text(inspected.resolution)
if text is not None:
source = "recorded-run" if inspected.by_record else "inspector"
return _decision(found, POSITIVE, text, source)
if found.policy is Policy.MONITOR:
if found.lookup is None and found.in_output:
return _decision(found, HISTORY, None, "none")
if not inspected.malformed and inspected.resolution["state"] == "ambiguous":
return _never(found, _unlinked(found), try_recorded_run=True)
text = graph_prompt_text(found.metadata)
if text is not None:
return _decision(found, POSITIVE, text, "legacy-parser")
return _never(found, _unlinked(found), try_recorded_run=True)
def _sampler_less(found: _File, graph: Any) -> Decision:
# No node takes a positive or negative input (a guider or wrapper graph):
# the one wired text, never a guess (#252).
wired = sampler_less_prompt(graph)
if wired["text"] is not None:
return _decision(found, POSITIVE, wired["text"], "sampler-less")
if isinstance(found.canonical.get("parameters"), str):
# A metadata saver also wrote A1111 text: its positive prompt.
text = _a1111_positive(found.metadata)
if text is not None:
return _decision(found, POSITIVE, text, "a1111")
status = "unresolved" if wired["state"] == "ambiguous" else "found"
return _never(found, status, try_recorded_run=True)
def _gated(decision: Decision) -> Decision:
"""Junk text is never stored; the file reports ``found`` (step 13)."""
if decision.kind not in (PAIR, POSITIVE):
return decision
junk = junk_text(decision.text)
if junk is None:
return decision
return Decision(
NEVER,
canonical=decision.canonical,
resolution=decision.resolution,
source=f"junk:{junk}",
review=decision.review,
try_recorded_run=True,
unlinked_status="found",
)
def _decision(found: _File, kind: str, text: Optional[str], source: str) -> Decision:
return Decision(
kind,
text=text,
canonical=found.canonical,
resolution=found.resolution,
source=source,
review=found.review,
)
def _never(found: _File, status: str, *, try_recorded_run: bool = False) -> Decision:
return Decision(
NEVER,
canonical=found.canonical,
resolution=found.resolution,
review=found.review,
try_recorded_run=try_recorded_run,
unlinked_status=status,
)
def _review(resolution: dict) -> Optional[str]:
if resolution["state"] != "ambiguous":
return None
return "ambiguous" if reviewable(resolution) else "untraced"
def _unlinked(found: _File) -> str:
"""Ambiguous without a choice is reported unresolved."""
if found.review == "untraced":
return "unresolved"
return found.resolution["state"]
def _inspected_text(resolution: dict) -> Optional[str]:
"""The storage text of a positive the inspector shows, else None."""
if resolution.get("state") in _UNDECIDED:
return None
positive = resolution.get("positive")
if not isinstance(positive, dict):
return None
text = positive.get("storage_text", positive.get("text"))
return text if isinstance(text, str) and text.strip() else None
def _parsed(metadata: Mapping) -> dict:
canonical = dict(metadata)
for key in ("prompt", "workflow"):
if isinstance(canonical.get(key), str):
try:
canonical[key] = json.loads(canonical[key])
except (ValueError, TypeError):
# Preserve malformed metadata for conservative resolver rejection.
pass
return canonical
def _with_size(canonical: dict, file_size: Optional[int]) -> None:
"""Record the file's size in ``file_info`` unless the metadata carries one.
Stored as ``generated_images.file_size``; the "new" scan scope skips a
known file only while its size is unchanged.
"""
info = canonical.get("file_info")
info = dict(info) if isinstance(info, dict) else {}
if info.get("size") is None and file_size is not None:
info["size"] = file_size
if info:
canonical["file_info"] = info
def _a1111_positive(metadata: Mapping) -> Optional[str]:
text = parse_comfyui_prompt(metadata)["positive_prompt"]
return text.strip() if text and text.strip() else None
_LORA_TAG = re.compile(r"<lora:[^>]*>")
_LORA_SEPARATORS = " \t\r\n\f\v,;|"
_MODEL_FILE = re.compile(r"\S*\.(?:safetensors|ckpt|pt|pth|bin|gguf|sft)", re.I)
def junk_text(text: Any) -> Optional[str]:
"""The #252 junk class of ``text``, or None when it may be a prompt."""
stripped = text.strip() if isinstance(text, str) else ""
if not stripped:
return "blank"
if _numeric(stripped):
return "number"
if not _LORA_TAG.sub("", stripped).strip(_LORA_SEPARATORS):
return "lora-only"
if _MODEL_FILE.fullmatch(stripped):
return "model-file"
return None
def graph_prompt_text(metadata: Mapping) -> Optional[str]:
"""The positive prompt the browser parser traces from the graphs, or None.
Records parse as the legacy pages parsed them (NaN reads as null).
"""
records = {}
for key in ("prompt", "workflow"):
value = metadata.get(key)
records[key] = parse_page_json(value) if isinstance(value, str) else value
try:
positive = extract_generation_params(records)["positivePrompt"]
except GenerationParamsError:
return None
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
return None
return positive.strip() or None
_COMMON_FIELDS = (
"positive",
"negative",
"steps",
"cfg",
"sampler",
"scheduler",
"seed",
)
def _json_or_raw(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def parse_comfyui_prompt(metadata: Mapping) -> dict:
"""Parse ComfyUI and A1111 prompt data from metadata.
Accepts the file's strings or parsed values: a parsed ``prompt`` or
``workflow`` is kept as it is and a non-string ``parameters`` is ignored.
"""
result = {
"prompt": None,
"workflow": None,
"parameters": {},
"positive_prompt": None,
"negative_prompt": None,
}
params = metadata.get("parameters")
if isinstance(params, str):
_parse_a1111(params, result)
if result["positive_prompt"] is None:
for key in ("prompt", "workflow"):
if key in metadata:
result[key] = _json_or_raw(metadata[key])
for name in _COMMON_FIELDS:
if name in metadata:
result["parameters"][name] = _json_or_raw(metadata[name])
return result
_SETTINGS_FIELDS = ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed")
def _parse_a1111(params: str, result: dict) -> None:
"""A1111/Forge ``parameters``: a possibly multi-line positive, then a
"Negative prompt:" section, then a trailing "Steps: ..." settings line.
Taking the first line as the positive would truncate a multi-line prompt,
so the text is split on the settings line instead.
"""
body = params
settings = ""
found = re.search(r"\nSteps:\s", body)
if found:
settings = body[found.start() + 1 :].strip()
body = body[: found.start()]
negative = None
found = re.search(r"(?im)^\s*negative prompt:\s*", body)
if found:
negative = body[found.end() :].strip()
body = body[: found.start()]
elif body.lower().lstrip().startswith("negative prompt:"):
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
body = ""
result["positive_prompt"] = body.strip() or None
if negative:
result["negative_prompt"] = negative
result["parameters"]["parameters"] = params
for name in _SETTINGS_FIELDS if settings else ():
found = re.search(
r"(?i)(?:^|,\s*)" + re.escape(name) + r":\s*([^,]+)", settings
)
if found:
key = name.lower().replace(" ", "_")
result["parameters"][key] = found.group(1).strip()