Files
achiandClaude Fable 5 72796ccc45 Complete Milestone 2: OpenAI analysis, image QA, and scene editing
- OpenAI adapter (gpt-5.5 structured outputs for analysis/QA,
  gpt-image-2 images.edit for scene editing) with lazy SDK import,
  env-only credentials, and an injectable client for offline tests
- Capability-split provider protocols; mock provider untouched
- Shared retry service (transient-only classification incl. SDK
  timeout/connection error names, bounded backoff) and cost service
  (verified price table, per-run CostTracker, $10 default ceiling)
- Provider-dropdown nodes: AFS_AnalyzeProduct,
  AFS_EvaluateImageCandidates, AFS_SceneEdit (12 nodes total)
- Backward-compatible cost fields on contracts; SceneEditRecord model
- Live analysis UI workflow; validator now flags unknown custom nodes
- Gated paid smoke tests (AIFS_ENABLE_PAID_TESTS=1): real gpt-5.5
  analysis $0.0084/call, gpt-image-2 edit $0.2191/image
- Verified on ComfyUI 0.28.2 / Python 3.13.12 / torch 2.10.0 (MPS);
  52 free tests pass, all workflows validate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:20:06 +08:00

153 lines
5.1 KiB
Python

"""Validate AI Fashion Studio workflow JSON without importing ComfyUI."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
CUSTOM_NODE_TYPES = {
"AFS_LoadProductAssets",
"AFS_AnalyzeProductMock",
"AFS_VirtualTryOnCandidatesMock",
"AFS_EvaluateImageCandidatesMock",
"AFS_RankCandidates",
"AFS_ImageToVideoMock",
"AFS_EvaluateVideoMock",
"AFS_ExportRun",
"AFS_AnalyzeProduct",
"AFS_EvaluateImageCandidates",
"AFS_SceneEdit",
}
def _unknown_custom_nodes(referenced_types: set) -> list[str]:
"""Flag AFS-prefixed node types this node pack does not register."""
return sorted(
node_type
for node_type in referenced_types
if isinstance(node_type, str)
and node_type.startswith("AFS_")
and node_type not in CUSTOM_NODE_TYPES
)
SECRET_PATTERN = re.compile(
r"(?:sk-[A-Za-z0-9_-]{16,}|bearer\s+[A-Za-z0-9._-]{16,}|api[_-]?key\s*[:=]\s*[^\s\"']+)",
re.IGNORECASE,
)
def validate_api_workflow(path: Path) -> list[str]:
path = Path(path)
errors: list[str] = []
raw = path.read_text(encoding="utf-8")
if SECRET_PATTERN.search(raw):
errors.append("possible embedded secret")
try:
workflow = json.loads(raw)
except json.JSONDecodeError as exc:
return [f"invalid JSON: {exc}"]
if not isinstance(workflow, dict) or not workflow:
return ["API workflow must be a non-empty object"]
node_ids = set(workflow)
referenced_types = set()
has_output = False
for node_id, node in workflow.items():
if not isinstance(node, dict):
errors.append(f"node {node_id} must be an object")
continue
class_type = node.get("class_type")
referenced_types.add(class_type)
if class_type == "AFS_ExportRun":
has_output = True
inputs = node.get("inputs")
if not isinstance(inputs, dict):
errors.append(f"node {node_id} is missing inputs")
continue
for name, value in inputs.items():
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str):
if value[0] not in node_ids:
errors.append(f"node {node_id} input {name} references missing node {value[0]}")
unknown_custom_nodes = _unknown_custom_nodes(referenced_types)
if unknown_custom_nodes:
errors.append(f"unknown custom nodes: {', '.join(unknown_custom_nodes)}")
if not has_output:
errors.append("workflow has no AFS_ExportRun output node")
if len(workflow) > 100:
errors.append("workflow exceeds 100 nodes")
return errors
def validate_ui_workflow(path: Path) -> list[str]:
path = Path(path)
errors: list[str] = []
raw = path.read_text(encoding="utf-8")
if SECRET_PATTERN.search(raw):
errors.append("possible embedded secret")
try:
workflow = json.loads(raw)
except json.JSONDecodeError as exc:
return [f"invalid JSON: {exc}"]
nodes = workflow.get("nodes")
links = workflow.get("links")
if not isinstance(nodes, list) or not nodes:
return ["UI workflow must contain nodes"]
if not isinstance(links, list):
return ["UI workflow must contain links"]
node_ids = {node.get("id") for node in nodes if isinstance(node, dict)}
if len(node_ids) != len(nodes):
errors.append("UI workflow has duplicate or missing node IDs")
for link in links:
if not isinstance(link, list) or len(link) != 6:
errors.append("UI workflow contains a malformed link")
continue
if link[1] not in node_ids or link[3] not in node_ids:
errors.append(f"link {link[0]} references a missing node")
referenced_types = {node.get("type") for node in nodes if isinstance(node, dict)}
unknown_custom_nodes = _unknown_custom_nodes(referenced_types)
if unknown_custom_nodes:
errors.append(f"unknown custom nodes: {', '.join(unknown_custom_nodes)}")
if "AFS_ExportRun" not in referenced_types:
errors.append("workflow has no AFS_ExportRun output node")
if len(nodes) > 100:
errors.append("workflow exceeds 100 nodes")
return errors
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"paths",
nargs="*",
type=Path,
default=[
Path("workflows/api/mock_vertical_slice.json"),
Path("workflows/ui/mock_vertical_slice.json"),
Path("workflows/ui/live_analysis_slice.json"),
],
)
args = parser.parse_args()
failed = False
for path in args.paths:
data = json.loads(path.read_text(encoding="utf-8"))
errors = (
validate_ui_workflow(path)
if isinstance(data, dict) and isinstance(data.get("nodes"), list)
else validate_api_workflow(path)
)
if errors:
failed = True
print(f"FAIL {path}")
for error in errors:
print(f" - {error}")
else:
print(f"OK {path}")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())