Import sap-architecture skill
This commit is contained in:
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LLM-friendly iteration helper for the nudge workflow.
|
||||
|
||||
The point of this script is to give a multimodal LLM (Claude Sonnet 4.x in
|
||||
Cursor, GPT-5, etc.) the three things it needs after every edit to plan its
|
||||
next move:
|
||||
|
||||
1. A fresh PNG of the candidate it just edited.
|
||||
2. A PNG of the SAP target template it should converge toward.
|
||||
3. A compact, prioritized text breakdown — score, lowest dimensions,
|
||||
and concrete next-step suggestions tied to specific cells.
|
||||
|
||||
Usage:
|
||||
|
||||
iterate.py <candidate.drawio>
|
||||
iterate.py <candidate.drawio> --target <reference.drawio>
|
||||
|
||||
When --target is omitted we score against the full bundled corpus and
|
||||
pick the closest match (this is what the LLM gets after a fresh scaffold,
|
||||
since the target template is well known then).
|
||||
|
||||
The script writes its artifacts under .cache/sap-architecture-iter/<stem>/
|
||||
so consecutive iterations overwrite cleanly without polluting the project.
|
||||
|
||||
Output is structured for an LLM reader:
|
||||
|
||||
─── SAP DIAGRAM ITERATION ───
|
||||
candidate : docs/architecture/foo.drawio
|
||||
target : .../ac_RA0029_AgenticAI_root.drawio (auto-picked)
|
||||
current : 78.4 / 100 ← changed +3.2 since last iterate
|
||||
pass gate : 90.0
|
||||
|
||||
📷 Read these images with your vision tool to plan the next edit:
|
||||
candidate : .cache/.../foo.candidate.png
|
||||
reference : .cache/.../ac_RA0029_AgenticAI_root.reference.png
|
||||
diff : .cache/.../foo.diff.html (browser-renderable side-by-side)
|
||||
|
||||
⚠ Lowest-scoring dimensions (fix worst first):
|
||||
zones 45% cand=4 ref=8 ← biggest gap
|
||||
icons 55% cand=5 ref=11
|
||||
pill_vocab 60% novelty="PROMPT" — replace with "TRUST" / "Authenticate"
|
||||
edge_pal 33% missing #CC00DC pink, #5D36FF indigo on edges
|
||||
|
||||
✏ Next concrete edit (do ONE, then re-run iterate.py):
|
||||
1. Add 4 zone containers using the same arcSize=16, strokeWidth=1.5
|
||||
style. Look at the reference PNG for placement.
|
||||
2. Add 6 BTP service icons via:
|
||||
python3 .../scripts/extract_icon.py "<service>" --x ... --y ...
|
||||
3. Replace pill text "PROMPT" with "TRUST" (cells matching arcSize=50).
|
||||
|
||||
⏪ Last iteration: -2.3 (you regressed). Inspect what changed and
|
||||
consider rolling back the last edit if it wasn't intentional.
|
||||
|
||||
The HTML diff is the same artifact `render_compare.py` produces, but
|
||||
with caching keyed off file mtime so iterate.py is fast to re-run.
|
||||
|
||||
Exit code:
|
||||
0 — score >= --min-score (passes the gate)
|
||||
1 — score below the gate (more iteration needed)
|
||||
2 — render or compare failed
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
THIS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(THIS_DIR))
|
||||
|
||||
import compare as _compare # noqa: E402
|
||||
import render as _render # noqa: E402
|
||||
import select_reference as _sel # noqa: E402
|
||||
|
||||
|
||||
CACHE_ROOT = Path(".cache") / "sap-architecture-iter"
|
||||
|
||||
|
||||
def find_target(candidate: Path, refs_dir: Path) -> tuple[Path, float, str]:
|
||||
"""Pick the closest SAP reference template for the candidate.
|
||||
|
||||
Strategy: corpus fingerprint scoring is more reliable than text matching
|
||||
because the candidate's labels often diverge from its source template
|
||||
after edits. We score the candidate against every bundled reference, take
|
||||
the top by fingerprint, and (if the fingerprint match is decisive) use
|
||||
it. For ambiguous matches we cross-check with the textual selector.
|
||||
"""
|
||||
refs = sorted(refs_dir.rglob("*.drawio"))
|
||||
cand_fp = _compare.fingerprint(candidate)
|
||||
|
||||
fingerprint_scores: list[tuple[float, Path]] = []
|
||||
for p in refs:
|
||||
try:
|
||||
ref_fp = _compare.fingerprint(p)
|
||||
s = _compare.compare(ref_fp, cand_fp).score
|
||||
except Exception:
|
||||
continue
|
||||
fingerprint_scores.append((s, p))
|
||||
fingerprint_scores.sort(key=lambda x: -x[0])
|
||||
|
||||
if fingerprint_scores and fingerprint_scores[0][0] >= 90:
|
||||
return fingerprint_scores[0][1], fingerprint_scores[0][0], "corpus fingerprint match (high confidence)"
|
||||
|
||||
# Mid-confidence: cross-check fingerprint top-5 with textual selector top-3
|
||||
cand_text = candidate.read_text(encoding="utf-8", errors="ignore")
|
||||
head = cand_text[:2000]
|
||||
textual = sorted(
|
||||
(_sel.score(p, f"{candidate.stem} {head}") for p in refs),
|
||||
key=lambda c: (-c.score, c.path),
|
||||
)[:5]
|
||||
if textual:
|
||||
textual_top = {Path(c.path) for c in textual[:3]}
|
||||
for s, p in fingerprint_scores[:5]:
|
||||
if p in textual_top:
|
||||
return p, s, "fingerprint+textual agreement"
|
||||
|
||||
if fingerprint_scores:
|
||||
return fingerprint_scores[0][1], fingerprint_scores[0][0], "best fingerprint match"
|
||||
if textual:
|
||||
return Path(textual[0].path), float(textual[0].score), "textual selector fallback"
|
||||
return refs[0], 0.0, "first available reference (no signal)"
|
||||
|
||||
|
||||
def cache_dir_for(candidate: Path) -> Path:
|
||||
cache = CACHE_ROOT / candidate.stem
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
return cache
|
||||
|
||||
|
||||
def render_if_stale(cli: str, src: Path, dst: Path, scale: float, border: int) -> bool:
|
||||
"""Render src.drawio to dst.png only if dst is older than src."""
|
||||
if dst.exists() and dst.stat().st_mtime >= src.stat().st_mtime:
|
||||
return False
|
||||
rc = _render.render_one(cli, src, dst, "png", scale, border, transparent=False, quiet=True)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"render of {src} failed (rc={rc})")
|
||||
return True
|
||||
|
||||
|
||||
def write_diff_html(
|
||||
out_dir: Path,
|
||||
candidate: Path,
|
||||
target: Path,
|
||||
cand_png: Path,
|
||||
ref_png: Path,
|
||||
score: float,
|
||||
breakdown: dict,
|
||||
diffs: list[str],
|
||||
suggestions: list[str],
|
||||
) -> Path:
|
||||
"""Reuse render_compare.py's HTML template (delegates so we keep one source of truth)."""
|
||||
# Easiest: just shell out to render_compare.py. It already writes review.html.
|
||||
# We pass --out-dir so it writes alongside our cached PNGs.
|
||||
rc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(THIS_DIR / "render_compare.py"),
|
||||
str(target),
|
||||
str(candidate),
|
||||
"--out-dir",
|
||||
str(out_dir),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if rc.returncode != 0:
|
||||
# Non-fatal — we already have the PNGs and score; HTML is a bonus.
|
||||
return out_dir / "review.html"
|
||||
return out_dir / "review.html"
|
||||
|
||||
|
||||
def collect_validator_warnings(candidate: Path) -> dict[str, list[str]]:
|
||||
"""Run validate.py and group warnings by category for the LLM.
|
||||
|
||||
We treat icon/edge align warnings as the highest-priority feedback for
|
||||
the LLM — they pinpoint exact cell IDs to fix and are the visible
|
||||
failures (oversized icons, icons-on-text, edges-through-cells).
|
||||
"""
|
||||
out: dict[str, list[str]] = {"icon_oversized": [], "icon_overlap": [], "edge_through": [], "other": []}
|
||||
rc = subprocess.run(
|
||||
[sys.executable, str(THIS_DIR / "validate.py"), str(candidate), "--json"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if rc.returncode not in (0, 1):
|
||||
return out
|
||||
try:
|
||||
data = json.loads(rc.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return out
|
||||
if not isinstance(data, list) or not data:
|
||||
return out
|
||||
report = data[0]
|
||||
for w in (report.get("warnings") or []):
|
||||
msg = w.get("msg", "")
|
||||
cell = w.get("cell", "")
|
||||
line = f"[{cell}] {msg}" if cell else msg
|
||||
if "oversized" in msg and "icon" in msg:
|
||||
out["icon_oversized"].append(line)
|
||||
elif "icon" in msg and "overlaps card" in msg:
|
||||
out["icon_overlap"].append(line)
|
||||
elif "edge" in msg and "passes through" in msg:
|
||||
out["edge_through"].append(line)
|
||||
else:
|
||||
out["other"].append(line)
|
||||
return out
|
||||
|
||||
|
||||
def actionable_suggestions(breakdown: dict, ref_fp, cand_fp, raw_diffs: list[str], validator_groups: dict[str, list[str]] | None = None) -> list[str]:
|
||||
"""Pull concrete, single-action suggestions from the score breakdown.
|
||||
|
||||
Validator-detected layout failures (oversized icons, icon overlaps,
|
||||
edges through boxes) get the top of the list because they are
|
||||
visually obvious to the user even when the score is still high.
|
||||
"""
|
||||
weighted_gaps: list[tuple[float, str]] = []
|
||||
|
||||
# Validator-detected layout failures rank highest — they are the visible
|
||||
# bugs the user will spot immediately ("icon is huge", "arrow goes
|
||||
# through a box"), even when the structural fingerprint score is OK.
|
||||
if validator_groups:
|
||||
if validator_groups["icon_oversized"]:
|
||||
n = len(validator_groups["icon_oversized"])
|
||||
sample = validator_groups["icon_oversized"][0]
|
||||
weighted_gaps.append((
|
||||
1000.0, # always first
|
||||
f"Resize {n} oversized icon(s) to 32×32 (most common in SAP corpus) "
|
||||
f"or 48×48 for focal anchors. Use --w 32 --h 32 with extract_icon.py, "
|
||||
f"or edit `<mxGeometry width=\"...\" height=\"...\">` directly. "
|
||||
f"Example: {sample}"
|
||||
))
|
||||
if validator_groups["icon_overlap"]:
|
||||
n = len(validator_groups["icon_overlap"])
|
||||
sample = validator_groups["icon_overlap"][0]
|
||||
weighted_gaps.append((
|
||||
950.0,
|
||||
f"Move {n} icon(s) off the cards they overlap. Either tuck the icon "
|
||||
f"INSIDE its parent card (set parent attribute and small x/y inside the card), "
|
||||
f"or relocate it to an empty region of the canvas. Example: {sample}"
|
||||
))
|
||||
if validator_groups["edge_through"]:
|
||||
n = len(validator_groups["edge_through"])
|
||||
sample = validator_groups["edge_through"][0]
|
||||
weighted_gaps.append((
|
||||
900.0,
|
||||
f"Reroute {n} edge(s) so they don't cross unrelated cards. Two fixes "
|
||||
f"per edge: (a) add `edgeStyle=orthogonalEdgeStyle;` to the edge style + "
|
||||
f"`exitX=0/0.5/1;exitY=0/0.5/1;entryX=...;entryY=...;exitDx=0;exitDy=0;` "
|
||||
f"to dock to a specific edge of the source/target; OR (b) reposition the "
|
||||
f"source/target cells so the straight line has no obstacle. "
|
||||
f"Example: {sample}"
|
||||
))
|
||||
|
||||
weights = {
|
||||
"page_bg": 1.5, "canvas": 1.0, "zones": 1.5, "zone_depth": 1.0,
|
||||
"icons": 1.5, "pill_vocab": 1.5, "edge_palette": 1.0, "palette": 1.5,
|
||||
"label_tokens": 2.0, "fonts": 1.0, "shapes": 1.0, "grid_snap": 1.0,
|
||||
"pills": 0.5, "vertices": 0.5, "edges": 1.0, "label_count": 0.5,
|
||||
"abs_arc": 0.5, "label_bg": 0.5, "strokes": 0.5, "external_images": 0.5,
|
||||
}
|
||||
|
||||
if breakdown.get("page_bg", 1.0) < 1.0:
|
||||
weighted_gaps.append((
|
||||
weights.get("page_bg", 1) * (1 - breakdown["page_bg"]),
|
||||
f"Set canvas background to white. Remove "
|
||||
f"`pageBackgroundColor=\"{cand_fp.page_background or '?'}\"` from <mxGraphModel>."
|
||||
))
|
||||
if breakdown.get("canvas", 1.0) < 1.0:
|
||||
weighted_gaps.append((
|
||||
weights.get("canvas", 1) * (1 - breakdown["canvas"]),
|
||||
f"Resize canvas to {ref_fp.canvas_w}×{ref_fp.canvas_h} (currently "
|
||||
f"{cand_fp.canvas_w}×{cand_fp.canvas_h}). draw.io: File → Page Setup → Custom."
|
||||
))
|
||||
if breakdown.get("zones", 1.0) < 0.7:
|
||||
delta = abs(ref_fp.zones - cand_fp.zones)
|
||||
verb = "Add" if cand_fp.zones < ref_fp.zones else "Remove"
|
||||
weighted_gaps.append((
|
||||
weights.get("zones", 1) * (1 - breakdown["zones"]),
|
||||
f"{verb} {delta} zone container(s). Reference has {ref_fp.zones} zones; "
|
||||
f"you have {cand_fp.zones}. Use rounded rect with arcSize=16, strokeWidth=1.5, "
|
||||
"and a top-left bold inline label."
|
||||
))
|
||||
if breakdown.get("zone_depth", 1.0) < 1.0:
|
||||
weighted_gaps.append((
|
||||
weights.get("zone_depth", 1) * (1 - breakdown["zone_depth"]),
|
||||
f"Zone nesting depth differs (cand={cand_fp.zone_depth}, ref={ref_fp.zone_depth}). "
|
||||
"Common bug: putting Joule INSIDE the BTP zone when SAP places them side-by-side."
|
||||
))
|
||||
if breakdown.get("icons", 1.0) < 0.7:
|
||||
delta = abs(ref_fp.icons - cand_fp.icons)
|
||||
verb = "Add" if cand_fp.icons < ref_fp.icons else "Remove"
|
||||
weighted_gaps.append((
|
||||
weights.get("icons", 1) * (1 - breakdown["icons"]),
|
||||
f"{verb} {delta} BTP service icon(s). Use scripts/extract_icon.py "
|
||||
"\"<service-name>\" --x <X> --y <Y> --id <id> to get a ready mxCell."
|
||||
))
|
||||
if breakdown.get("pill_vocab", 1.0) < 1.0 and cand_fp.novelty_pill_count:
|
||||
weighted_gaps.append((
|
||||
weights.get("pill_vocab", 1) * (1 - breakdown["pill_vocab"]),
|
||||
f"Replace {cand_fp.novelty_pill_count} novelty pill verb(s). Allowed: "
|
||||
"TRUST, Authenticate, Authorization, A2A, MCP, ORD, HTTPS, OData/REST, "
|
||||
"SAML2/OIDC, SCIM, Identity Lifecycle. Forbidden: PROMPT, ROUTE, CONTEXT, "
|
||||
"DELEGATE, INVOKE, FETCH, EXECUTE."
|
||||
))
|
||||
if breakdown.get("edge_palette", 1.0) < 0.6:
|
||||
missing = sorted(set(ref_fp.edge_palette) - set(cand_fp.edge_palette))[:4]
|
||||
if missing:
|
||||
weighted_gaps.append((
|
||||
weights.get("edge_palette", 1) * (1 - breakdown["edge_palette"]),
|
||||
f"Add SAP-mandated edge stroke colors: {', '.join(missing)}. Mapping: "
|
||||
"trust=#CC00DC pink, auth=#188918 green, authorization=#5D36FF indigo, "
|
||||
"structural=#475E75 slate, MCP=#07838F teal."
|
||||
))
|
||||
if breakdown.get("palette", 1.0) < 0.6:
|
||||
weighted_gaps.append((
|
||||
weights.get("palette", 1) * (1 - breakdown["palette"]),
|
||||
"Palette overlap is low — many of your fill/stroke hexes aren't in the SAP set. "
|
||||
"Replace custom colors with the Horizon palette listed in references/palette-and-typography.md."
|
||||
))
|
||||
if breakdown.get("label_tokens", 1.0) < 0.5:
|
||||
weighted_gaps.append((
|
||||
weights.get("label_tokens", 1) * (1 - breakdown["label_tokens"]),
|
||||
"Label vocabulary drifted — most card/zone labels don't match the reference. "
|
||||
"Restore the SAP service names you removed, or rename your cards to use SAP product terminology."
|
||||
))
|
||||
if breakdown.get("grid_snap", 1.0) < 0.9:
|
||||
weighted_gaps.append((
|
||||
weights.get("grid_snap", 1) * (1 - breakdown["grid_snap"]),
|
||||
"Geometry off the 10-px grid. Run `python3 scripts/autofix.py --write <file>` "
|
||||
"— this is mechanical and won't change content."
|
||||
))
|
||||
|
||||
weighted_gaps.sort(key=lambda x: -x[0])
|
||||
out = [s for _, s in weighted_gaps[:6]]
|
||||
|
||||
# Surface remaining raw diffs the heuristics didn't classify
|
||||
for d in raw_diffs[:2]:
|
||||
if not any(d in o for o in out):
|
||||
out.append(d)
|
||||
|
||||
if not out:
|
||||
out.append(
|
||||
"Looks structurally close. Open the diff HTML and use the Swipe / Difference "
|
||||
"tabs to spot subtle visual drifts (label positions, icon sizes, spacing)."
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def read_history(cache: Path) -> list[dict]:
|
||||
p = cache / "history.json"
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def write_history(cache: Path, entry: dict, max_keep: int = 20) -> None:
|
||||
p = cache / "history.json"
|
||||
items = read_history(cache)
|
||||
items.append(entry)
|
||||
items = items[-max_keep:]
|
||||
p.write_text(json.dumps(items, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_template_recipe(target: Path) -> dict | None:
|
||||
"""Look up the chosen template's deep design profile from the registry.
|
||||
|
||||
Returns the profile dict (zones, icons, pills, edges, patterns, etc.) the
|
||||
LLM should match when relabeling. Returns None if the registry isn't built
|
||||
(run `profile_template.py --build-registry` first) or if `target` isn't
|
||||
in the bundled corpus (e.g. an externally-pinned reference).
|
||||
"""
|
||||
registry_path = THIS_DIR.parent / "assets" / "reference-examples" / "template-profiles.json"
|
||||
if not registry_path.exists():
|
||||
return None
|
||||
try:
|
||||
reg = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return (reg.get("templates") or {}).get(target.name)
|
||||
|
||||
|
||||
def fmt_delta(curr: float, prev: float | None) -> str:
|
||||
if prev is None:
|
||||
return "(first iteration)"
|
||||
delta = curr - prev
|
||||
arrow = "↑" if delta > 0 else ("↓" if delta < 0 else "→")
|
||||
return f"{arrow}{delta:+.1f} since last iteration"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("candidate", type=Path, help="the .drawio file you're iterating on")
|
||||
ap.add_argument("--target", type=Path, default=None,
|
||||
help="explicit SAP reference template to converge toward (default: auto-select)")
|
||||
ap.add_argument("--refs-dir", type=Path, default=None,
|
||||
help="directory of bundled SAP references (default: bundled assets)")
|
||||
ap.add_argument("--scale", type=float, default=1.0)
|
||||
ap.add_argument("--border", type=int, default=10)
|
||||
ap.add_argument("--min-score", type=float, default=90.0)
|
||||
ap.add_argument("--no-html", action="store_true",
|
||||
help="skip the side-by-side HTML diff (faster, still gives PNGs + text)")
|
||||
ap.add_argument("--json", action="store_true", help="emit machine-readable JSON only")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.candidate.exists():
|
||||
print(f"candidate {args.candidate}: not found", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
refs_dir = args.refs_dir or (THIS_DIR.parent / "assets" / "reference-examples")
|
||||
if not refs_dir.exists():
|
||||
print(f"reference dir {refs_dir}: not found", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
cli = _render.find_drawio_cli()
|
||||
if not cli:
|
||||
print(
|
||||
"draw.io CLI not found — install draw.io desktop or set $DRAWIO_CLI.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if args.target:
|
||||
target = args.target
|
||||
target_reason = "explicit --target"
|
||||
target_textual_score: float | None = None
|
||||
else:
|
||||
target, target_textual_score, target_reason = find_target(args.candidate, refs_dir)
|
||||
|
||||
cache = cache_dir_for(args.candidate)
|
||||
cand_png = cache / f"{args.candidate.stem}.candidate.png"
|
||||
ref_png = cache / f"{target.stem}.reference.png"
|
||||
|
||||
try:
|
||||
render_if_stale(cli, args.candidate, cand_png, args.scale, args.border)
|
||||
render_if_stale(cli, target, ref_png, args.scale, args.border)
|
||||
except RuntimeError as e:
|
||||
print(f"render failed: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ref_fp = _compare.fingerprint(target)
|
||||
cand_fp = _compare.fingerprint(args.candidate)
|
||||
result = _compare.compare(ref_fp, cand_fp)
|
||||
|
||||
history = read_history(cache)
|
||||
prev_score = history[-1]["score"] if history else None
|
||||
|
||||
diff_html = None
|
||||
if not args.no_html:
|
||||
diff_html = write_diff_html(
|
||||
cache, args.candidate, target, cand_png, ref_png,
|
||||
result.score, result.breakdown, result.diffs, []
|
||||
)
|
||||
|
||||
validator_groups = collect_validator_warnings(args.candidate)
|
||||
suggestions = actionable_suggestions(
|
||||
result.breakdown, ref_fp, cand_fp, result.diffs, validator_groups
|
||||
)
|
||||
|
||||
entry = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"candidate": str(args.candidate),
|
||||
"target": str(target),
|
||||
"score": result.score,
|
||||
"breakdown": result.breakdown,
|
||||
}
|
||||
write_history(cache, entry)
|
||||
|
||||
if args.json:
|
||||
out = {
|
||||
"candidate": str(args.candidate),
|
||||
"target": str(target),
|
||||
"target_reason": target_reason,
|
||||
"score": result.score,
|
||||
"previous_score": prev_score,
|
||||
"breakdown": result.breakdown,
|
||||
"diffs": result.diffs,
|
||||
"suggestions": suggestions,
|
||||
"candidate_png": str(cand_png),
|
||||
"reference_png": str(ref_png),
|
||||
"diff_html": str(diff_html) if diff_html else None,
|
||||
"passes": result.score >= args.min_score,
|
||||
}
|
||||
print(json.dumps(out, indent=2))
|
||||
return 0 if out["passes"] else 1
|
||||
|
||||
# Look up the chosen target's design recipe from the precomputed registry
|
||||
target_recipe = load_template_recipe(target)
|
||||
|
||||
# Human + LLM friendly text output
|
||||
print()
|
||||
print("─── SAP DIAGRAM ITERATION ───")
|
||||
print(f"candidate : {args.candidate}")
|
||||
print(f"target : {target.name} ({target_reason})")
|
||||
print(f"score : {result.score:.1f} / 100 {fmt_delta(result.score, prev_score)}")
|
||||
print(f"pass gate : {args.min_score:.1f} ({'PASS' if result.score >= args.min_score else 'BELOW — keep iterating'})")
|
||||
print()
|
||||
print("📷 Read these images with your vision tool to plan the next edit:")
|
||||
print(f" candidate : {cand_png}")
|
||||
print(f" reference : {ref_png}")
|
||||
if diff_html:
|
||||
print(f" side-by-side HTML : {diff_html}")
|
||||
print()
|
||||
|
||||
if target_recipe:
|
||||
print("🎯 SAP design recipe of your target template — preserve these patterns:")
|
||||
struct = target_recipe.get("structure_summary", {})
|
||||
if struct:
|
||||
print(
|
||||
f" structure : {struct.get('top_level_zones', 0)} top-level zones, "
|
||||
f"{struct.get('nested_zones', 0)} nested, "
|
||||
f"{struct.get('cards', 0)} cards, "
|
||||
f"{struct.get('icons', 0)} icons, "
|
||||
f"{struct.get('pills', 0)} pills, "
|
||||
f"{struct.get('edges', 0)} edges"
|
||||
)
|
||||
if target_recipe.get("icon_sizes"):
|
||||
sizes = ", ".join(f"{n}×{s}" for s, n in list(target_recipe["icon_sizes"].items())[:4])
|
||||
print(f" icon sizes: {sizes} — match these, do NOT exceed 48×48 unless ref does")
|
||||
if target_recipe.get("pill_vocab"):
|
||||
vocab = ", ".join(f"{p!r}" for p in target_recipe["pill_vocab"][:8])
|
||||
print(f" pill vocab: {vocab}")
|
||||
eq = target_recipe.get("edge_quality", {})
|
||||
if eq.get("total"):
|
||||
print(
|
||||
f" edges : {eq['total']} total, "
|
||||
f"{eq.get('with_anchors', 0)} use entryX/exitX anchors, "
|
||||
f"{eq.get('orthogonal', 0)} orthogonalEdgeStyle "
|
||||
f"(your edges should follow the same proportions to avoid arrows-through-cards)"
|
||||
)
|
||||
if target_recipe.get("detected_patterns"):
|
||||
print(f" patterns : {', '.join(target_recipe['detected_patterns'][:6])}")
|
||||
zones = target_recipe.get("zones", [])
|
||||
top_zones = [z for z in zones if z.get("parent_id") in (None, "1")]
|
||||
if top_zones:
|
||||
zone_summary = "; ".join(
|
||||
f"{z.get('label', '?').strip() or '(unlabeled)':<30s} [{z.get('color_role', '?')}]"
|
||||
for z in top_zones[:6]
|
||||
)
|
||||
print(f" top zones : {zone_summary}")
|
||||
print()
|
||||
|
||||
# Lowest-scoring dimensions (top 5)
|
||||
print("⚠ Lowest-scoring dimensions (fix worst first):")
|
||||
sorted_dims = sorted(result.breakdown.items(), key=lambda kv: kv[1])[:6]
|
||||
for dim, val in sorted_dims:
|
||||
bar = "█" * int(val * 10) + "░" * (10 - int(val * 10))
|
||||
print(f" {dim:14s} {val*100:5.1f}% {bar}")
|
||||
print()
|
||||
|
||||
print("✏ Next concrete edit (do ONE, then re-run iterate.py):")
|
||||
for i, s in enumerate(suggestions, 1):
|
||||
# word-wrap at ~80 cols for readability
|
||||
prefix = f" {i}. "
|
||||
cont = " "
|
||||
words = s.split()
|
||||
line = prefix
|
||||
for w in words:
|
||||
if len(line) + len(w) + 1 > 88:
|
||||
print(line)
|
||||
line = cont + w
|
||||
else:
|
||||
line += (" " if line not in (prefix, cont) else "") + w
|
||||
print(line)
|
||||
print()
|
||||
|
||||
if prev_score is not None:
|
||||
delta = result.score - prev_score
|
||||
if delta < -0.5:
|
||||
print(f"⏪ Last iteration regressed ({delta:+.1f}). Inspect the candidate vs the previous "
|
||||
"version — consider rolling back the last edit if it wasn't intentional.")
|
||||
elif delta < 0.5:
|
||||
print("≈ Score barely moved. Pick a higher-impact suggestion above (the ones at the top "
|
||||
"have the biggest weighted score gap).")
|
||||
else:
|
||||
print(f"✓ Score improved {delta:+.1f}. Keep going.")
|
||||
print()
|
||||
|
||||
return 0 if result.score >= args.min_score else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user