Import sap-architecture skill

This commit is contained in:
2026-07-24 20:47:16 +00:00
commit f3699e6f43
121 changed files with 127110 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Auto-fix mechanical issues in a .drawio file.
Fixes (in-place, writes if --write given):
* Snap every x/y/width/height in <mxGeometry> to the 10-px grid (round half up)
* Quantize floating-point widths (e.g. 239.9999...) to integers
* Uppercase all hex color values in styles (outside data: URIs)
* Add `absoluteArcSize=1` anywhere `arcSize=<n>` is set without it
* Replace `strokeWidth=1.2` and similar odd values with the nearest allowed weight
* Normalise the font family to `Helvetica` (warn only — style strings preserved)
Usage:
autofix.py <file.drawio> # dry-run, prints summary
autofix.py --write <file.drawio> # edits in place (makes a .bak copy)
Run validate.py afterwards — any remaining issues are authorial (bent edges,
overflow, palette deviations).
"""
from __future__ import annotations
import argparse
import re
import shutil
import sys
from pathlib import Path
GRID = 10
ALLOWED_STROKE_VALUES = (1.0, 1.5, 2.0, 3.0, 4.0)
def snap(v: float) -> int:
return int(round(v / GRID) * GRID)
def fix_geometry(text: str, stats: dict[str, int]) -> str:
def repl(m: re.Match[str]) -> str:
attr, num = m.group(1), float(m.group(2))
snapped = snap(num)
if abs(num - snapped) > 0.001:
stats["geometry"] += 1
return f'{attr}="{snapped}"'
return re.sub(r'\b(x|y|width|height)="(-?\d+(?:\.\d+)?)"', repl, text)
def fix_hex_case(text: str, stats: dict[str, int]) -> str:
out_chunks: list[str] = []
i = 0
# naive split around data: URIs so we don't touch hex inside SVG payloads
while i < len(text):
m = re.search(r"data:image/[^&\";]+", text[i:])
if not m:
out_chunks.append(_fix_hex_chunk(text[i:], stats))
break
start, end = m.span()
out_chunks.append(_fix_hex_chunk(text[i : i + start], stats))
out_chunks.append(text[i + start : i + end])
i += end
return "".join(out_chunks)
def _fix_hex_chunk(chunk: str, stats: dict[str, int]) -> str:
def repl(m: re.Match[str]) -> str:
hex_val = m.group(0)
up = hex_val.upper()
if hex_val != up:
stats["hex_case"] += 1
return up
return re.sub(r"#[0-9a-fA-F]{6}\b", repl, chunk)
def fix_arc_size(text: str, stats: dict[str, int]) -> str:
# For each style="..." attribute value, if arcSize= appears without absoluteArcSize=1, add it.
def repl(m: re.Match[str]) -> str:
style_val = m.group(1)
if "arcSize=" in style_val and "absoluteArcSize=" not in style_val:
stats["arc_size"] += 1
trailing = "" if style_val.endswith(";") else ";"
return f'style="{style_val}{trailing}absoluteArcSize=1;"'
return m.group(0)
return re.sub(r'style="([^"]*)"', repl, text)
def fix_stroke_width(text: str, stats: dict[str, int]) -> str:
def repl(m: re.Match[str]) -> str:
val = float(m.group(1))
nearest = min(ALLOWED_STROKE_VALUES, key=lambda x: abs(x - val))
if abs(nearest - val) < 0.01:
return m.group(0)
stats["stroke_width"] += 1
formatted = f"{nearest:g}"
return f"strokeWidth={formatted}"
return re.sub(r"strokeWidth=([0-9.]+)", repl, text)
def fix_font_family(text: str, stats: dict[str, int]) -> str:
def repl(m: re.Match[str]) -> str:
val = m.group(1)
if val.lower() == "helvetica":
return m.group(0)
stats["font_family"] += 1
return "fontFamily=Helvetica"
return re.sub(r"fontFamily=([^;\"]+)", repl, text)
_COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
def fix_strip_comments(text: str, stats: dict[str, int]) -> str:
n = len(_COMMENT_RE.findall(text))
if n:
stats["xml_comments"] += n
text = _COMMENT_RE.sub("", text)
return text
def apply_all(text: str) -> tuple[str, dict[str, int]]:
stats = {
"geometry": 0,
"hex_case": 0,
"arc_size": 0,
"stroke_width": 0,
"font_family": 0,
"xml_comments": 0,
}
text = fix_strip_comments(text, stats)
text = fix_geometry(text, stats)
text = fix_hex_case(text, stats)
text = fix_arc_size(text, stats)
text = fix_stroke_width(text, stats)
text = fix_font_family(text, stats)
return text, stats
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("file")
ap.add_argument("--write", action="store_true", help="write the fix in place (backup .bak)")
args = ap.parse_args()
p = Path(args.file)
if not p.exists():
print(f"{p}: not found", file=sys.stderr)
return 1
original = p.read_text(encoding="utf-8")
fixed, stats = apply_all(original)
total = sum(stats.values())
summary = ", ".join(f"{k}={v}" for k, v in stats.items() if v)
if total == 0:
print(f"{p}: no fixes needed")
return 0
if args.write:
shutil.copyfile(p, p.with_suffix(p.suffix + ".bak"))
p.write_text(fixed, encoding="utf-8")
print(f"{p}: wrote ({total} fixes — {summary}); backup at {p.name}.bak")
else:
print(f"{p}: would fix ({total} changes — {summary}); re-run with --write")
return 0
if __name__ == "__main__":
sys.exit(main())
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Build a searchable index for all bundled SAP draw.io libraries.
The service-only icon index is kept for backwards compatibility with
extract_icon.py. This broader index covers the full SAP starter-kit surface
that is useful to an LLM: BTP service icons, generic icons, connectors, area
shapes, number bubbles, product names, text elements, and interface labels.
"""
from __future__ import annotations
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from xml.sax.saxutils import unescape
HERE = Path(__file__).resolve().parent
ASSETS = HERE.parent / "assets"
LIB_DIR = ASSETS / "libraries"
OUT = ASSETS / "asset-index.json"
LIBRARY_KINDS = {
"btp-service-icons-all-size-M.xml": "btp-service-icon",
"sap-generic-icons-size-M-200302.xml": "generic-icon",
"connectors.xml": "connector",
"area_shapes.xml": "area-shape",
"default_shapes.xml": "default-shape",
"essentials.xml": "essential-shape",
"numbers.xml": "number-marker",
"sap_brand_names.xml": "sap-brand-name",
"text_elements.xml": "text-element",
"annotations_and_interfaces.xml": "annotation-interface",
}
def clean_text(value: str) -> str:
value = unescape(value)
value = value.replace("&amp;#10;", " ").replace("&#10;", " ").replace("\\n", " ")
value = re.sub(r"<br\s*/?>", " ", value, flags=re.I)
value = re.sub(r"<[^>]+>", " ", value)
value = re.sub(r"&nbsp;|\xa0", " ", value)
value = re.sub(r"\s+", " ", value).strip()
return value
def humanize(value: str | None) -> str:
if not value:
return ""
value = re.sub(r"^\d+-", "", value)
value = re.sub(r"_sd$", "", value)
value = re.sub(r"[-_]+", " ", value)
value = re.sub(r"\bsize [sml]\b", "", value, flags=re.I)
value = re.sub(r"\s+", " ", value).strip()
return value
def slugify(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
def load_mxlibrary(path: Path) -> list[dict[str, Any]]:
raw = path.read_text(encoding="utf-8")
raw = re.sub(r"<!--.*?-->", "", raw, flags=re.S).strip()
if not raw.startswith("<mxlibrary>") or not raw.endswith("</mxlibrary>"):
raise ValueError(f"{path} is not a draw.io mxlibrary")
body = raw[len("<mxlibrary>") : -len("</mxlibrary>")].strip()
return json.loads(body)
def first_cell_text(xml: str) -> str:
try:
root = ET.fromstring(xml)
except ET.ParseError:
return ""
for cell in root.iter("mxCell"):
value = clean_text(cell.get("value") or "")
if value:
return value
return ""
def first_cell_style(xml: str) -> str:
try:
root = ET.fromstring(xml)
except ET.ParseError:
return ""
for cell in root.iter("mxCell"):
style = cell.get("style") or ""
if cell.get("id") not in {"0", "1"} and style:
return style
return ""
def infer_untitled_display(kind: str, library: str, entry_index: int, xml: str) -> str:
style = first_cell_style(xml).lower()
hints: list[str] = []
if "#0070f2" in style or "#ebf8ff" in style:
hints.append("SAP BTP")
if "#475e75" in style or "#f5f6f7" in style:
hints.append("non-SAP")
if "dashed=1" in style:
hints.append("dashed")
if "ellipse" in style:
hints.append("ellipse")
if "group" in style:
hints.append("group")
if "fillcolor=#ffffff" in style:
hints.append("white fill")
base = humanize(library.replace(".xml", ""))
suffix = " ".join(hints) if hints else f"entry {entry_index + 1:02d}"
return f"{base} {suffix}".strip()
def aliases_for(display: str, title: str, kind: str, library: str) -> list[str]:
aliases = {slugify(display), slugify(title), slugify(humanize(title)), slugify(kind), slugify(library)}
words = slugify(display)
replacements = {
"sap-business-technology-platform": "sap-btp",
"business-technology-platform": "btp",
"authorization-and-trust-management": "xsuaa",
"cloud-integration": "cpi",
"cloud-connector": "cc",
"cloud-foundry-runtime": "cf",
"identity-authentication": "ias",
"identity-provisioning": "ips",
"sap-destination-service": "destination",
"sap-hana-cloud": "hana",
}
for old, new in replacements.items():
if old in words:
aliases.add(words.replace(old, new))
return sorted(a for a in aliases if a and a != slugify(display))
def entry_display(kind: str, library: str, entry_index: int, entry: dict[str, Any]) -> str:
title = humanize(entry.get("title"))
if "xml" in entry:
xml = unescape(entry["xml"])
return first_cell_text(xml) or title or infer_untitled_display(kind, library, entry_index, xml)
return title or f"{humanize(library)} entry {entry_index + 1:02d}"
def build() -> dict[str, Any]:
assets: dict[str, dict[str, Any]] = {}
libraries: dict[str, dict[str, Any]] = {}
for library, kind in sorted(LIBRARY_KINDS.items()):
path = LIB_DIR / library
if not path.exists():
continue
entries = load_mxlibrary(path)
libraries[library] = {"kind": kind, "entries": len(entries)}
used_slugs: dict[str, int] = {}
for entry_index, entry in enumerate(entries):
display = entry_display(kind, library, entry_index, entry)
slug = slugify(display) or f"{kind}-{entry_index + 1:03d}"
if slug in used_slugs:
used_slugs[slug] += 1
slug = f"{slug}-{used_slugs[slug]}"
else:
used_slugs[slug] = 1
key = f"{kind}:{slug}"
title = entry.get("title") or ""
assets[key] = {
"kind": kind,
"display": display,
"title": title,
"aliases": aliases_for(display, title, kind, library),
"library": library,
"entry": entry_index,
"width": entry.get("w"),
"height": entry.get("h"),
"aspect": entry.get("aspect"),
"source": "data" if "data" in entry else "xml",
}
return {
"metadata": {
"source": "SAP/btp-solution-diagrams assets/shape-libraries-and-editable-presets/draw.io",
"count": len(assets),
"libraries": libraries,
},
"assets": dict(sorted(assets.items())),
}
def main() -> int:
missing = sorted(name for name in LIBRARY_KINDS if not (LIB_DIR / name).exists())
if missing:
print("missing libraries:", ", ".join(missing), file=sys.stderr)
return 1
index = build()
OUT.write_text(json.dumps(index, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
print(f"wrote {OUT}{index['metadata']['count']} assets across {len(index['metadata']['libraries'])} libraries")
return 0
if __name__ == "__main__":
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Parse assets/libraries/btp-service-icons-all-size-M.xml and emit icon-index.json.
Run once (or whenever the upstream icon library is refreshed).
Output: assets/icon-index.json with
{
"<slug>": {
"label": "<exact library label as-is, with whitespace>",
"aliases": ["<normalized alias>", ...],
"style": "<ready-to-paste mxCell style attribute value>"
}
}
No image payload is inlined (the library XML already holds them); the extract
script (extract_icon.py) returns the full image-data URI on demand.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from xml.sax.saxutils import unescape
HERE = Path(__file__).resolve().parent
LIB = HERE.parent / "assets" / "libraries" / "btp-service-icons-all-size-M.xml"
OUT = HERE.parent / "assets" / "icon-index.json"
def clean(label: str) -> str:
label = label.replace("&amp;#10;", " ").replace("\\n", " ")
label = re.sub(r"\s+", " ", label).strip()
return label
def display_from_title(title: str | None) -> str:
if not title:
return ""
title = re.sub(r"^\d+-", "", title)
title = re.sub(r"_sd$", "", title)
title = title.replace("-", " ")
title = re.sub(r"\s+", " ", title).strip()
acronyms = {"sap": "SAP", "btp": "BTP", "hana": "HANA", "abap": "ABAP"}
return " ".join(acronyms.get(part, part.capitalize()) for part in title.split())
def slugify(name: str) -> str:
s = name.lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def build() -> dict[str, dict]:
raw = LIB.read_text(encoding="utf-8")
# strip the <mxlibrary>...</mxlibrary> shell and the stray comments
raw = re.sub(r"<!--.*?-->", "", raw, flags=re.S)
raw = raw.strip()
assert raw.startswith("<mxlibrary>") and raw.endswith("</mxlibrary>"), raw[:80]
body = raw[len("<mxlibrary>") : -len("</mxlibrary>")].strip()
entries = json.loads(body)
index: dict[str, dict] = {}
for entry in entries:
xml_encoded = entry["xml"]
xml = unescape(xml_encoded)
# find the <mxCell ... value="..." style="..." ...>
cell = re.search(r'<mxCell[^>]*value="([^"]*)"[^>]*style="([^"]*)"', xml)
if not cell:
continue
raw_label = cell.group(1)
style = cell.group(2)
label = clean(raw_label) or display_from_title(entry.get("title"))
slug = slugify(label)
if not slug:
continue
aliases = {slug}
# common abbreviations / renamings people use when describing diagrams
noise = ["sap-", "service-for-sap-btp", "-service", "-on-sap-btp"]
short = slug
for n in noise:
short = short.replace(n, "-")
short = re.sub(r"-+", "-", short).strip("-")
if short and short != slug:
aliases.add(short)
# word-only alias (drops "-service" tails)
aliases.add(re.sub(r"-service$", "", slug))
index[slug] = {
"label": raw_label,
"display": label,
"aliases": sorted(aliases - {slug}),
"style": style,
}
return index
def main() -> int:
if not LIB.exists():
print(f"missing {LIB}", file=sys.stderr)
return 1
index = build()
OUT.write_text(json.dumps(index, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
print(f"wrote {OUT}{len(index)} icons")
return 0
if __name__ == "__main__":
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Smoke-check SAP library and palette coverage.
This is intentionally fast and local. It verifies that the LLM-facing indexes
cover the bundled SAP draw.io libraries and that the validator accepts every
official SAP preset color from drawio-config-all-in-one.json.
"""
from __future__ import annotations
import json
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from build_asset_index import LIBRARY_KINDS
from extract_asset import emit_asset
from validate import SAP_PALETTE
HERE = Path(__file__).resolve().parent
ASSETS = HERE.parent / "assets"
LIB_DIR = ASSETS / "libraries"
ICON_INDEX = ASSETS / "icon-index.json"
ASSET_INDEX = ASSETS / "asset-index.json"
OFFICIAL_PRESET_COLORS = {
"#0070F2",
"#EBF8FF",
"#475E75",
"#F5F6F7",
"#1D2D3E",
"#556B82",
"#188918",
"#F5FAE5",
"#C35500",
"#FFF8D6",
"#D20A0A",
"#FFEAF4",
"#07838F",
"#DAFDF5",
"#5D36FF",
"#793802",
"#F1ECFF",
"#CC00DC",
"#FFF0FA",
}
class Args:
x = 0
y = 0
w = None
h = None
id = "smoke"
parent = "1"
label = None
def fail(message: str) -> int:
print(f"FAIL: {message}", file=sys.stderr)
return 1
def main() -> int:
missing = [name for name in LIBRARY_KINDS if not (LIB_DIR / name).exists()]
if missing:
return fail(f"missing libraries: {', '.join(missing)}")
icon_index = json.loads(ICON_INDEX.read_text(encoding="utf-8"))
asset_index = json.loads(ASSET_INDEX.read_text(encoding="utf-8"))
assets = asset_index["assets"]
service_assets = [a for a in assets.values() if a["kind"] == "btp-service-icon"]
if len(icon_index) != len(service_assets):
return fail(f"icon-index has {len(icon_index)} entries but asset-index has {len(service_assets)} service icons")
if "sap-build" not in icon_index:
return fail("SAP Build is missing from icon-index")
if not any(key == "btp-service-icon:sap-build" for key in assets):
return fail("SAP Build is missing from asset-index")
expected_count = sum(meta["entries"] for meta in asset_index["metadata"]["libraries"].values())
actual_count = asset_index["metadata"]["count"]
if actual_count != expected_count or actual_count != len(assets):
return fail(f"asset count mismatch: metadata={actual_count}, library sum={expected_count}, assets={len(assets)}")
missing_palette = OFFICIAL_PRESET_COLORS - {color.upper() for color in SAP_PALETTE}
if missing_palette:
return fail(f"validator missing official SAP colors: {sorted(missing_palette)}")
smoke_keys = [
"btp-service-icon:sap-build",
"btp-service-icon:sap-authorization-and-trust-management-service",
"generic-icon:devices-non-sap",
"connector:direct-one-directional",
"area-shape:area-shapes-dashed",
"number-marker:1",
"sap-brand-name:sap-btp",
"annotation-interface:interface",
]
for key in smoke_keys:
asset = assets.get(key)
if not asset:
return fail(f"smoke asset missing: {key}")
xml = emit_asset(asset, Args())
ET.fromstring(f"<root>{xml}</root>")
print(f"ok: {len(icon_index)} BTP service icons")
print(f"ok: {actual_count} indexed SAP draw.io assets across {len(asset_index['metadata']['libraries'])} libraries")
print(f"ok: {len(OFFICIAL_PRESET_COLORS)} official SAP preset colors covered")
return 0
if __name__ == "__main__":
sys.exit(main())
+681
View File
@@ -0,0 +1,681 @@
#!/usr/bin/env python3
"""Compare two .drawio files and produce a similarity / divergence report.
The goal isn't pixel-perfect equality — it's to surface *structural* and *style*
divergences so an iterative loop can drive a generated diagram toward an SAP
reference. Two diagrams that draw the same scenario end up with very similar
fingerprints across these dimensions:
Structural
* canvas size (W × H) — should match the selected SAP template
* total cell count, vertex count, edge count
* zone count (cells with arcSize=16, strokeWidth=1.5, fontStyle=1, top-left
label)
* service-icon count (cells with shape=image and SAP icon SVG data URI)
* pill count (small cells with arcSize=50)
Style
* palette (set of hex colors, Jaccard similarity)
* fonts (set of fontFamily values)
* stroke widths (set)
* presence of `absoluteArcSize=1`, `labelBackgroundColor=default`
* grid-snap rate (% of geometries on the 10-px grid)
Usage:
compare.py reference.drawio candidate.drawio # human report
compare.py --json reference.drawio candidate.drawio # JSON report
compare.py --score reference.drawio candidate.drawio # one-line score 0..100
Score is a weighted blend of the dimensions above; 100 = identical fingerprint
(not necessarily identical content), 0 = nothing in common.
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass, field
from pathlib import Path
try:
from validate import SAP_PALETTE
except Exception: # pragma: no cover - compare.py can run standalone
SAP_PALETTE = {
"#0070F2", "#EBF8FF", "#475E75", "#F5F6F7", "#1D2D3E", "#556B82",
"#188918", "#F5FAE5", "#C35500", "#FFF8D6", "#D20A0A", "#FFEAF4",
"#07838F", "#DAFDF5", "#5D36FF", "#F1ECFF", "#CC00DC", "#FFF0FA",
"#FFFFFF", "#FFF", "#000000", "#000", "#FCFCFC",
}
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")
DATA_URI_RE = re.compile(r"data:image/[^&\";]+")
INLINE_SVG_ICON_RE = re.compile(r"shape=image[^\"]*image=data:image/svg")
STENCIL_ICON_RE = re.compile(r"shape=mxgraph\.sap\.icon")
ICON_RE = re.compile(r"shape=image[^\"]*image=data:image/svg|shape=mxgraph\.sap\.icon")
EXTERNAL_IMAGE_RE = re.compile(r"shape=image[^\"]*image=https?://|image=https?://")
SHAPE_RE = re.compile(r"(?:^|;)shape=([^;\"]+)")
ARC16_RE = re.compile(r"arcSize=16\b")
ARC50_RE = re.compile(r"arcSize=50\b")
ABS_ARC_RE = re.compile(r"absoluteArcSize=1\b")
LABEL_BG_RE = re.compile(r"labelBackgroundColor=default\b")
ZONE_HINT_RE = re.compile(r"strokeWidth=1\.5[^\"]*fontStyle=1|fontStyle=1[^\"]*strokeWidth=1\.5")
PILL_HINT_RE = re.compile(r"arcSize=50[^\"]*strokeWidth=1\b|strokeWidth=1\b[^\"]*arcSize=50")
FONT_RE = re.compile(r"fontFamily=([^;\"]+)")
STROKE_RE = re.compile(r"strokeWidth=([0-9.]+)")
LINE_RE = re.compile(r"^line", re.I)
PAGE_BG_RE = re.compile(r'(?:background|pageBackgroundColor)="([^"]+)"')
# Canonical SAP flow-pill vocabulary, kept in sync with validate.py.
CANONICAL_PILL_VOCAB = {
"trust", "authenticate", "authentication", "authorization",
"identity", "identity lifecycle", "customer-managed identity lifecycle",
"user", "usergroup", "group", "role", "role collection", "role collections",
"policy", "scim", "saml2/oidc", "oidc", "saml", "openid",
"https", "https/active", "https/standby", "rest", "rest/spi",
"rest/token", "rest / odata", "odata/rest", "odata/rest/soap",
"destination", "source", "target", "harmonized api",
"data federation", "data sync", "task data",
"a2a", "mcp", "ord",
"business data cloud", "business role", "cdm",
"role replica",
"commit", "build & test", "release", "deploy", "connectivity", "private link",
"sql", "security logs", "alerts, findings & enriched events",
"correlated incidents", "status & closure updates", "notification", "open ticket",
"data", "metadata",
}
STOPWORDS = {
"a", "an", "and", "app", "apps", "architecture", "as", "at", "be", "by",
"cloud", "create", "diagram", "for", "from", "in", "into", "is", "l0",
"l1", "l2", "of", "on", "or", "page", "ref", "reference", "sap", "show",
"solution", "style", "the", "to", "use", "using", "via", "with",
}
TOKEN_CANONICAL = {
"adminstrator": "administrator",
"plaforms": "platforms",
"provisoning": "provisioning",
}
SAP_REFERENCE_GRID_BASELINE = 0.23
# --- Fingerprint ---------------------------------------------------------------
@dataclass
class Fingerprint:
path: str
canvas_w: int = 0
canvas_h: int = 0
cells_total: int = 0
vertices: int = 0
edges: int = 0
zones: int = 0
icons: int = 0
icons_inline: int = 0 # bundled inline-SVG icons (preferred)
icons_stencil: int = 0 # mxgraph.sap.icon stencil legacy
external_images: int = 0
pills: int = 0
grid_snap_rate: float = 0.0
has_absolute_arc: bool = False
has_label_bg: bool = False
palette: set[str] = field(default_factory=set)
edge_palette: set[str] = field(default_factory=set) # strokeColors used on edges
fonts: set[str] = field(default_factory=set)
stroke_widths: set[float] = field(default_factory=set)
shapes: set[str] = field(default_factory=set)
label_count: int = 0
label_tokens: set[str] = field(default_factory=set)
pill_vocab: set[str] = field(default_factory=set)
canonical_pill_count: int = 0
novelty_pill_count: int = 0
page_background: str = ""
zone_depth: int = 0 # max nested zone depth observed
sap_logo_count: int = 0
def split_words(text: str) -> list[str]:
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
text = re.sub(r"([a-z])([A-Z])", r"\1 \2", text)
text = text.replace("_", " ").replace("-", " ").replace("/", " ")
return [t.lower() for t in re.findall(r"[A-Za-z0-9]+", text)]
def clean_label(value: str) -> str:
value = html.unescape(value)
value = re.sub(r"<br\s*/?>", " ", value, flags=re.I)
value = re.sub(r"<[^>]+>", " ", value)
value = re.sub(r"&nbsp;", " ", value)
return re.sub(r"\s+", " ", value).strip()
def tokens(text: str) -> set[str]:
out: set[str] = set()
words = split_words(text)
joined = "".join(words)
for word in words:
word = TOKEN_CANONICAL.get(word, word)
if len(word) >= 2 and word not in STOPWORDS:
out.add(word)
for compact in ("xsuaa", "privatelink", "workzone", "eventmesh", "multiaz", "multiregion", "businessdatacloud"):
if compact in joined:
out.add(compact)
if "businessdatacloud" in out:
out.add("bdc")
if "cloudconnector" in joined:
out.add("cloudconnector")
if "principalpropagation" in joined:
out.add("principalpropagation")
if "s4hana" in joined or "4hana" in out:
out.add("s4hana")
return out
def parse_style_dict(style: str) -> dict[str, str]:
out: dict[str, str] = {}
if not style:
return out
for part in style.split(";"):
part = part.strip()
if not part or "=" not in part:
continue
k, v = part.split("=", 1)
out[k.strip()] = v.strip()
return out
def fingerprint(path: Path) -> Fingerprint:
fp = Fingerprint(path=str(path))
text = path.read_text(encoding="utf-8")
try:
root = ET.parse(path).getroot()
except ET.ParseError:
palette_text = DATA_URI_RE.sub("", text)
fp.palette = {h.upper() for h in HEX_RE.findall(palette_text)}
fp.fonts = set(FONT_RE.findall(palette_text))
fp.stroke_widths = {float(s) for s in STROKE_RE.findall(palette_text)}
fp.has_absolute_arc = bool(ABS_ARC_RE.search(text))
fp.has_label_bg = bool(LABEL_BG_RE.search(text))
bg_match = PAGE_BG_RE.search(palette_text)
if bg_match:
fp.page_background = bg_match.group(1).strip().lower()
return fp
graph = root.find(".//mxGraphModel")
scope = graph if graph is not None else root
scope_text = ET.tostring(scope, encoding="unicode")
palette_text = DATA_URI_RE.sub("", scope_text)
fp.palette = {h.upper() for h in HEX_RE.findall(palette_text)}
fp.fonts = set(FONT_RE.findall(palette_text))
fp.stroke_widths = {float(s) for s in STROKE_RE.findall(palette_text)}
fp.has_absolute_arc = bool(ABS_ARC_RE.search(scope_text))
fp.has_label_bg = bool(LABEL_BG_RE.search(scope_text))
bg_match = PAGE_BG_RE.search(palette_text)
if bg_match:
fp.page_background = bg_match.group(1).strip().lower()
if graph is not None:
fp.canvas_w = int(graph.get("pageWidth") or graph.get("dx") or 0)
fp.canvas_h = int(graph.get("pageHeight") or graph.get("dy") or 0)
if not fp.page_background:
bg = (graph.get("background") or graph.get("pageBackgroundColor") or "").strip().lower()
if bg:
fp.page_background = bg
cells = scope.findall(".//mxCell")
fp.cells_total = len(cells)
coords: list[float] = []
labels: set[str] = set()
for elem in scope.iter():
for attr in ("name", "label", "value"):
raw = elem.get(attr)
if not raw:
continue
label = clean_label(raw)
if label:
labels.add(label)
fp.label_count = len(labels)
for label in labels:
fp.label_tokens |= tokens(label)
# Map cell id → cell so we can compute parent-zone nesting depth.
cells_by_id: dict[str, ET.Element] = {}
for c in cells:
cid = c.get("id")
if cid:
cells_by_id[cid] = c
parent_by_elem = {id(child): parent for parent in scope.iter() for child in list(parent)}
def is_zone_cell(c: ET.Element) -> bool:
style_text = c.get("style") or ""
if not ARC16_RE.search(style_text):
return False
if "strokeWidth=1.5" not in style_text:
return False
# SAP zone styling encodes bold via inline HTML in `value`, not fontStyle.
# So we accept zone cells with arcSize=16 + strokeWidth=1.5 even without
# fontStyle=1 — a critical fix for accurate zone counting.
return True
zone_ids: set[str] = set()
for c in cells:
if c.get("vertex") == "1":
fp.vertices += 1
style = c.get("style") or ""
sd = parse_style_dict(style)
inline_icon = bool(INLINE_SVG_ICON_RE.search(style))
stencil_icon = bool(STENCIL_ICON_RE.search(style))
if inline_icon:
fp.icons += 1
fp.icons_inline += 1
elif stencil_icon:
fp.icons += 1
fp.icons_stencil += 1
if EXTERNAL_IMAGE_RE.search(style):
fp.external_images += 1
image = sd.get("image", "")
if image and "sap_logo" in image.lower():
fp.sap_logo_count += 1
for shape in SHAPE_RE.findall(style):
if shape != "image":
fp.shapes.add(shape)
if ARC50_RE.search(style):
fp.pills += 1
# capture pill label vocabulary
raw_label = c.get("value") or ""
if not raw_label:
parent = parent_by_elem.get(id(c))
if parent is not None and parent.tag == "UserObject":
raw_label = parent.get("value") or parent.get("label") or ""
pill_label = clean_label(raw_label).strip().lower()
if pill_label:
fp.pill_vocab.add(pill_label)
if pill_label in CANONICAL_PILL_VOCAB:
fp.canonical_pill_count += 1
else:
fp.novelty_pill_count += 1
elif is_zone_cell(c):
fp.zones += 1
cid = c.get("id")
if cid:
zone_ids.add(cid)
geo = c.find("mxGeometry")
if geo is not None:
for attr in ("x", "y", "width", "height"):
v = geo.get(attr)
if v is not None:
try:
coords.append(float(v))
except ValueError:
pass
elif c.get("edge") == "1":
fp.edges += 1
style = c.get("style") or ""
sd = parse_style_dict(style)
stroke = sd.get("strokeColor", "").upper()
if stroke and stroke.startswith("#"):
fp.edge_palette.add(stroke)
# Zone nesting depth: count how many zone cells appear in the parent chain.
def zone_depth_for(c: ET.Element) -> int:
depth = 0
parent_id = c.get("parent")
seen = set()
while parent_id and parent_id not in seen:
seen.add(parent_id)
parent_cell = cells_by_id.get(parent_id)
if parent_cell is None:
break
if parent_cell.get("id") in zone_ids:
depth += 1
parent_id = parent_cell.get("parent")
return depth
if zone_ids:
max_depth = 0
for c in cells:
if c.get("vertex") != "1":
continue
d = zone_depth_for(c)
if d > max_depth:
max_depth = d
fp.zone_depth = max_depth
if coords:
snapped = sum(1 for v in coords if abs(v - round(v)) < 1e-6 and round(v) % 10 == 0)
fp.grid_snap_rate = snapped / len(coords)
return fp
# --- Comparison ----------------------------------------------------------------
def jaccard(a: set, b: set) -> float:
if not a and not b:
return 1.0
return len(a & b) / max(1, len(a | b))
@dataclass
class CompareResult:
score: float = 0.0
breakdown: dict = field(default_factory=dict)
diffs: list[str] = field(default_factory=list)
@dataclass
class SapLikenessResult:
score: float = 0.0
breakdown: dict[str, float] = field(default_factory=dict)
issues: list[str] = field(default_factory=list)
def sap_likeness(fp: Fingerprint, *, validator_errors: int = 0) -> SapLikenessResult:
"""Reference-free SAP Architecture Center style score."""
result = SapLikenessResult()
parts: dict[str, float] = {}
accepted_bgs = {"", "none", "default", "#ffffff", "#fff"}
parts["page_bg"] = 1.0 if fp.page_background.lower() in accepted_bgs else 0.0
if not parts["page_bg"]:
result.issues.append(f"non-white page background {fp.page_background!r}")
parts["validator_errors"] = 1.0 if validator_errors == 0 else 0.0
if validator_errors:
result.issues.append(f"{validator_errors} validator error(s)")
parts["zones"] = min(1.0, fp.zones / 1.0)
if fp.zones == 0:
result.issues.append("no SAP-style zones detected")
parts["icons"] = min(1.0, fp.icons / 3.0) if fp.vertices >= 4 else min(1.0, fp.icons / 1.0)
if fp.icons == 0:
result.issues.append("no bundled/icon-library assets detected")
parts["pills"] = min(1.0, fp.pills / 3.0) if fp.edges >= 2 else 1.0
if fp.edges >= 2 and fp.pills == 0:
result.issues.append("no SAP-style flow pills detected")
if fp.pills:
parts["pill_vocab"] = max(0.0, 1.0 - (fp.novelty_pill_count / max(1, fp.pills)))
else:
parts["pill_vocab"] = 0.8
if parts["pill_vocab"] < 1.0:
result.issues.append(f"{fp.novelty_pill_count} non-canonical pill label(s)")
sap_palette = {color.upper() for color in SAP_PALETTE}
visible_palette = {color.upper() for color in fp.palette}
if visible_palette:
parts["palette"] = len(visible_palette & sap_palette) / len(visible_palette)
else:
parts["palette"] = 1.0
if parts["palette"] < 1.0:
result.issues.append(f"off-palette colors: {sorted(visible_palette - sap_palette)[:6]}")
if fp.edge_palette:
parts["edge_palette"] = len(fp.edge_palette & sap_palette) / len(fp.edge_palette)
else:
parts["edge_palette"] = 1.0
fonts = {font.lower() for font in fp.fonts}
parts["fonts"] = 1.0 if not fonts or fonts <= {"helvetica", "arial"} else 0.0
if not parts["fonts"]:
result.issues.append(f"non-SAP font families: {sorted(fp.fonts)}")
allowed_strokes = {1.0, 1.5, 2.0, 3.0, 4.0}
if fp.stroke_widths:
parts["strokes"] = len(fp.stroke_widths & allowed_strokes) / len(fp.stroke_widths)
else:
parts["strokes"] = 1.0
if parts["strokes"] < 1.0:
result.issues.append(f"non-standard stroke widths: {sorted(fp.stroke_widths - allowed_strokes)}")
parts["abs_arc"] = 1.0 if fp.has_absolute_arc else 0.6
parts["label_bg"] = 1.0 if fp.has_label_bg or fp.edges == 0 else 0.8
parts["grid_snap"] = min(1.0, fp.grid_snap_rate / SAP_REFERENCE_GRID_BASELINE)
if fp.grid_snap_rate < SAP_REFERENCE_GRID_BASELINE:
result.issues.append(f"grid-snap rate {fp.grid_snap_rate * 100:.1f}%")
parts["external_images"] = max(0.0, 1.0 - min(fp.external_images, 3) * 0.1)
if fp.external_images > 1:
result.issues.append(f"{fp.external_images} external image(s)")
weights = {
"page_bg": 2.0,
"validator_errors": 2.0,
"zones": 1.25,
"icons": 1.0,
"pills": 0.75,
"pill_vocab": 1.25,
"palette": 1.5,
"edge_palette": 0.75,
"fonts": 1.0,
"strokes": 0.75,
"abs_arc": 0.5,
"label_bg": 0.5,
"grid_snap": 1.0,
"external_images": 1.0,
}
total = sum(weights[k] for k in parts)
result.score = round(sum(parts[k] * weights[k] for k in parts) / total * 100, 1)
result.breakdown = parts
return result
def compare(ref: Fingerprint, cand: Fingerprint) -> CompareResult:
r = CompareResult()
parts: dict[str, float] = {}
parts["canvas"] = 1.0 if (ref.canvas_w == cand.canvas_w and ref.canvas_h == cand.canvas_h) else 0.0
if not parts["canvas"]:
r.diffs.append(f"canvas mismatch — ref {ref.canvas_w}x{ref.canvas_h} vs cand {cand.canvas_w}x{cand.canvas_h}")
# Page background fidelity. SAP diagrams use white/transparent canvas;
# any explicit non-white background is a major red flag.
accepted_bgs = {"", "none", "default", "#ffffff", "#fff"}
cand_bg = cand.page_background.lower()
ref_bg = ref.page_background.lower()
if cand_bg in accepted_bgs:
parts["page_bg"] = 1.0
elif cand_bg == ref_bg:
parts["page_bg"] = 1.0
else:
parts["page_bg"] = 0.0
r.diffs.append(
f"non-white page background {cand.page_background!r} — SAP uses white/transparent canvas"
)
def ratio(a: float, b: float) -> float:
if a == 0 and b == 0:
return 1.0
if a == 0 or b == 0:
return 0.0
return min(a, b) / max(a, b)
# zones: zone detection now uses arcSize=16 + strokeWidth=1.5 (no fontStyle
# requirement) — matches SAP's actual encoding where bold is HTML-inline.
if ref.zones > 0 or cand.zones > 0:
parts["zones"] = ratio(ref.zones, cand.zones)
# zone nesting depth — matters for templates like Joule-inside-vs-beside-BTP
if ref.zone_depth > 0 or cand.zone_depth > 0:
parts["zone_depth"] = ratio(ref.zone_depth, cand.zone_depth)
if ref.zone_depth != cand.zone_depth:
r.diffs.append(
f"zone nesting depth differs — ref {ref.zone_depth} vs cand {cand.zone_depth}"
)
# icons: prefer inline-SVG over legacy mxgraph.sap.icon stencils. SAP's own
# corpus uses both, so we don't penalize stencils outright; we just count
# inline + stencil together to match SAP's behavior.
parts["icons"] = ratio(ref.icons, cand.icons)
parts["external_images"] = 1.0 if cand.external_images <= ref.external_images else ratio(ref.external_images, cand.external_images)
parts["edges"] = ratio(ref.edges, cand.edges)
parts["vertices"] = ratio(ref.vertices, cand.vertices)
parts["pills"] = ratio(ref.pills, cand.pills) if (ref.pills or cand.pills) else 1.0
# Pill vocabulary fidelity. Reward use of SAP-canonical pill labels;
# penalize candidates whose pills are mostly novelty verbs.
if ref.pills > 0 or cand.pills > 0:
ref_canon_rate = ref.canonical_pill_count / max(1, ref.pills)
cand_canon_rate = cand.canonical_pill_count / max(1, cand.pills)
if ref.pills == 0:
parts["pill_vocab"] = 1.0 if cand_canon_rate >= 0.6 else cand_canon_rate
else:
# Match the reference's own canon rate. Some official SAP diagrams
# intentionally use scenario-specific flow labels such as
# "Security Logs" or "Open Ticket"; comparing a reference with
# itself must still score 100.
target = ref_canon_rate
parts["pill_vocab"] = 1.0 if cand_canon_rate >= target else (cand_canon_rate / max(0.01, target))
if cand.novelty_pill_count > 0 and cand.novelty_pill_count > ref.novelty_pill_count:
r.diffs.append(
f"novelty pill labels: {cand.novelty_pill_count} (cand) vs {ref.novelty_pill_count} (ref) "
"— prefer canonical SAP verbs (TRUST/Authenticate/A2A/MCP/ORD/...)"
)
# Edge palette: which colors are actually used on edges? This catches
# green↔magenta semantic swaps that the global palette set hides.
if ref.edge_palette or cand.edge_palette:
parts["edge_palette"] = jaccard(ref.edge_palette, cand.edge_palette)
edge_diff = ref.edge_palette - cand.edge_palette
if edge_diff:
r.diffs.append(f"edge stroke colors missing from candidate: {sorted(edge_diff)[:6]}")
parts["palette"] = jaccard(ref.palette, cand.palette)
only_in_cand = cand.palette - ref.palette
if only_in_cand:
r.diffs.append(f"colors in candidate not in reference: {sorted(only_in_cand)[:8]}")
# fonts: candidate ⊆ ref counts as full credit. SAP's own files mix
# Arial+Helvetica; if our candidate uses only one of them, that's fine.
if cand.fonts and ref.fonts and cand.fonts <= ref.fonts:
parts["fonts"] = 1.0
else:
parts["fonts"] = jaccard(ref.fonts, cand.fonts)
if cand.fonts and not (cand.fonts <= ref.fonts):
r.diffs.append(f"fonts: ref={sorted(ref.fonts)} cand={sorted(cand.fonts)}")
parts["strokes"] = jaccard(ref.stroke_widths, cand.stroke_widths)
parts["shapes"] = jaccard(ref.shapes, cand.shapes)
parts["label_count"] = ratio(ref.label_count, cand.label_count)
parts["label_tokens"] = jaccard(ref.label_tokens, cand.label_tokens)
missing_label_tokens = ref.label_tokens - cand.label_tokens
extra_label_tokens = cand.label_tokens - ref.label_tokens
if parts["label_tokens"] < 0.8:
r.diffs.append(
"label token drift — "
f"missing={sorted(missing_label_tokens)[:8]} extra={sorted(extra_label_tokens)[:8]}"
)
extra_shapes = cand.shapes - ref.shapes
if extra_shapes:
r.diffs.append(f"shape styles in candidate not in reference: {sorted(extra_shapes)[:8]}")
if cand.external_images > ref.external_images:
r.diffs.append(f"external image count increased — ref {ref.external_images} vs cand {cand.external_images}")
parts["abs_arc"] = 1.0 if ref.has_absolute_arc == cand.has_absolute_arc else 0.5
parts["label_bg"] = 1.0 if ref.has_label_bg == cand.has_label_bg else 0.5
# grid_snap: candidate at-or-above reference scores 1.0; else proportional.
# We don't penalise the candidate for being MORE snapped than SAP's own files,
# we just want it to be at least as clean. Target absolute rate is ≥ 0.95
# which we surface as a separate diff.
if ref.grid_snap_rate >= 0.95:
parts["grid_snap"] = 1.0 if cand.grid_snap_rate >= ref.grid_snap_rate * 0.95 else cand.grid_snap_rate
else:
# SAP reference itself is sloppy — give candidate full credit if it matches or exceeds it
parts["grid_snap"] = 1.0 if cand.grid_snap_rate >= ref.grid_snap_rate else cand.grid_snap_rate / max(0.01, ref.grid_snap_rate)
if cand.grid_snap_rate < 0.95:
r.diffs.append(f"grid-snap rate {cand.grid_snap_rate*100:.1f}% (recommend 95%+; reference is {ref.grid_snap_rate*100:.1f}%)")
weights = {
"canvas": 1.0,
"page_bg": 1.5, # NEW: dark/branded canvas now penalized
"zones": 1.5,
"zone_depth": 1.0, # NEW: nesting hierarchy match (Joule-in-BTP bug)
"icons": 1.5,
"external_images": 0.5,
"edges": 1.0,
"vertices": 0.5,
"pills": 0.5,
"pill_vocab": 1.5, # NEW: canonical SAP pill verbs vs novelty
"palette": 1.5,
"edge_palette": 1.0, # NEW: connector colors actually used on edges
"fonts": 1.0,
"strokes": 0.5,
"shapes": 1.0,
"label_count": 0.5,
"label_tokens": 2.0,
"abs_arc": 0.5,
"label_bg": 0.5,
"grid_snap": 1.0,
}
total_weight = sum(weights[k] for k in parts)
score = sum(parts[k] * weights[k] for k in parts) / total_weight * 100
r.score = round(score, 1)
r.breakdown = parts
return r
# --- CLI -----------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("reference", type=Path)
ap.add_argument("candidate", type=Path)
ap.add_argument("--json", action="store_true")
ap.add_argument("--score", action="store_true")
args = ap.parse_args()
ref = fingerprint(args.reference)
cand = fingerprint(args.candidate)
result = compare(ref, cand)
quality = sap_likeness(cand)
if args.score:
print(f"{result.score:.1f}")
return 0
if args.json:
out = {
"score": result.score,
"sap_likeness": asdict(quality),
"breakdown": result.breakdown,
"diffs": result.diffs,
"reference": asdict(ref),
"candidate": asdict(cand),
}
# sets aren't JSON-serializable; coerce
for fp_dict in (out["reference"], out["candidate"]):
for k in ("palette", "edge_palette", "fonts", "stroke_widths", "shapes", "label_tokens", "pill_vocab"):
fp_dict[k] = sorted(fp_dict[k])
print(json.dumps(out, indent=2))
return 0
print(f"reference : {args.reference}")
print(f"candidate : {args.candidate}")
print(f"score : {result.score:.1f}/100")
print(f"sap-like : {quality.score:.1f}/100")
print("breakdown :")
for k, v in result.breakdown.items():
bar = "" * int(v * 20)
print(f" {k:10s} {v*100:5.1f}% {bar}")
if result.diffs:
print("\nnotable diffs:")
for d in result.diffs:
print(f" - {d}")
print("\nreference fingerprint:")
for k, v in asdict(ref).items():
if isinstance(v, set):
v = sorted(v)
print(f" {k}: {v}")
print("\ncandidate fingerprint:")
for k, v in asdict(cand).items():
if isinstance(v, set):
v = sorted(v)
print(f" {k}: {v}")
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Extract a ready-to-paste mxCell snippet from any bundled SAP draw.io asset.
Examples:
extract_asset.py --list --kind connector
extract_asset.py "direct one-directional" --kind connector --id flow1 --x 100 --y 200
extract_asset.py "database non sap" --kind generic-icon --id db1 --x 300 --y 160
"""
from __future__ import annotations
import argparse
import copy
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from xml.sax.saxutils import escape, unescape
HERE = Path(__file__).resolve().parent
ASSETS = HERE.parent / "assets"
INDEX = ASSETS / "asset-index.json"
LIB_DIR = ASSETS / "libraries"
def load_index() -> dict[str, Any]:
if not INDEX.exists():
print(f"asset index not found at {INDEX}; run build_asset_index.py first", file=sys.stderr)
sys.exit(1)
return json.loads(INDEX.read_text(encoding="utf-8"))
def load_library_entry(entry: dict[str, Any]) -> dict[str, Any]:
path = LIB_DIR / entry["library"]
raw = path.read_text(encoding="utf-8")
raw = re.sub(r"<!--.*?-->", "", raw, flags=re.S).strip()
body = raw[len("<mxlibrary>") : -len("</mxlibrary>")].strip()
return json.loads(body)[entry["entry"]]
def normalize(text: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
def slugify(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
def tokens(text: str) -> set[str]:
return {token for token in normalize(text).split() if token}
def find_asset(index: dict[str, Any], query: str, kind: str | None) -> tuple[str, dict[str, Any]] | None:
assets = index["assets"]
query_raw = query.strip()
query_slug = slugify(query)
query_tokens = tokens(query)
filtered = [
(key, asset)
for key, asset in assets.items()
if kind is None or asset["kind"] == kind
]
for key, asset in filtered:
key_short = key.split(":", 1)[-1]
if (
query_raw == key
or query_raw == key_short
or query_slug == slugify(key)
or query_slug == slugify(key_short)
):
return key, asset
if query_slug in {slugify(alias) for alias in asset.get("aliases", [])}:
return key, asset
candidates: list[tuple[int, str, dict[str, Any]]] = []
for key, asset in filtered:
pool = tokens(key) | tokens(asset["display"]) | tokens(asset.get("title", ""))
for alias in asset.get("aliases", []):
pool |= tokens(alias)
if query_tokens and query_tokens <= pool:
candidates.append((len(pool), key, asset))
if len(candidates) == 1:
_, key, asset = candidates[0]
return key, asset
if len(candidates) > 1:
candidates.sort(key=lambda item: (item[0], item[1]))
top = candidates[0]
if len(candidates) == 1 or top[0] < candidates[1][0]:
return top[1], top[2]
print(f"ambiguous '{query}'{len(candidates)} matches:", file=sys.stderr)
for _, key, asset in candidates[:12]:
print(f" {key}{asset['display']}", file=sys.stderr)
sys.exit(2)
substring = [
(key, asset)
for key, asset in filtered
if normalize(query) in normalize(asset["display"]) or query_slug in key
]
if len(substring) == 1:
return substring[0]
if len(substring) > 1:
print(f"ambiguous '{query}'{len(substring)} matches:", file=sys.stderr)
for key, asset in substring[:12]:
print(f" {key}{asset['display']}", file=sys.stderr)
sys.exit(2)
return None
def snap(value: float) -> int:
return round(value / 10) * 10
def mxcell_for_data(asset: dict[str, Any], library_entry: dict[str, Any], args: argparse.Namespace) -> str:
width = snap(args.w if args.w is not None else int(asset.get("width") or 40))
height = snap(args.h if args.h is not None else int(asset.get("height") or 40))
label = args.label if args.label is not None else asset["display"]
label_xml = escape(label, {'"': "&quot;"})
style = (
"shape=image;verticalLabelPosition=bottom;verticalAlign=top;aspect=fixed;"
f"imageAspect=0;image={library_entry['data']};"
)
return (
f'<mxCell id="{args.id}" value="{label_xml}" style="{style}" '
f'vertex="1" parent="{args.parent}">'
f'<mxGeometry x="{snap(args.x)}" y="{snap(args.y)}" width="{width}" height="{height}" as="geometry"/>'
"</mxCell>"
)
def set_edge_points(cell: ET.Element, x: int, y: int, width: int, height: int) -> None:
geom = cell.find("mxGeometry")
if geom is None:
return
source = geom.find("mxPoint[@as='sourcePoint']")
target = geom.find("mxPoint[@as='targetPoint']")
if source is not None:
source.set("x", str(x))
source.set("y", str(y))
if target is not None:
target.set("x", str(x + width))
target.set("y", str(y + height))
def mx_cells_for_xml(asset: dict[str, Any], library_entry: dict[str, Any], args: argparse.Namespace) -> str:
root = ET.fromstring(unescape(library_entry["xml"]))
cells = [copy.deepcopy(c) for c in root.iter("mxCell") if c.get("id") not in {"0", "1"}]
if not cells:
raise ValueError(f"{asset['display']} contains no extractable mxCell")
id_map: dict[str, str] = {}
for index, cell in enumerate(cells):
old_id = cell.get("id")
if old_id:
id_map[old_id] = args.id if index == 0 else f"{args.id}-{index + 1}"
original_top_ids = {cell.get("id") for cell in cells if cell.get("parent") == "1"}
x = snap(args.x)
y = snap(args.y)
width = snap(args.w) if args.w is not None else snap(int(asset.get("width") or 120))
height = snap(args.h) if args.h is not None else 0
for cell in cells:
old_id = cell.get("id")
old_parent = cell.get("parent")
if old_id in id_map:
cell.set("id", id_map[old_id])
if old_parent == "1":
cell.set("parent", args.parent)
elif old_parent in id_map:
cell.set("parent", id_map[old_parent])
for ref in ("source", "target"):
if cell.get(ref) in id_map:
cell.set(ref, id_map[cell.get(ref)])
if args.label is not None and old_id in original_top_ids:
cell.set("value", escape(args.label, {'"': "&quot;"}))
geom = cell.find("mxGeometry")
if geom is not None and old_id in original_top_ids and cell.get("vertex") == "1":
geom.set("x", str(x))
geom.set("y", str(y))
if args.w is not None:
geom.set("width", str(width))
if args.h is not None:
geom.set("height", str(snap(args.h)))
if cell.get("edge") == "1" and old_id in original_top_ids:
set_edge_points(cell, x, y, width, height)
return "\n".join(ET.tostring(cell, encoding="unicode") for cell in cells)
def emit_asset(asset: dict[str, Any], args: argparse.Namespace) -> str:
library_entry = load_library_entry(asset)
if "data" in library_entry:
return mxcell_for_data(asset, library_entry, args)
if "xml" in library_entry:
return mx_cells_for_xml(asset, library_entry, args)
raise ValueError(f"unsupported library entry for {asset['display']}")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("query", nargs="?")
ap.add_argument("--list", action="store_true")
ap.add_argument("--kind", help="Filter by asset kind, e.g. btp-service-icon, generic-icon, connector")
ap.add_argument("--x", type=int, default=0)
ap.add_argument("--y", type=int, default=0)
ap.add_argument("--w", type=int)
ap.add_argument("--h", type=int)
ap.add_argument("--id", default="asset1")
ap.add_argument("--parent", default="1")
ap.add_argument("--label")
args = ap.parse_args()
index = load_index()
assets = index["assets"]
if args.list:
for key, asset in assets.items():
if args.kind and asset["kind"] != args.kind:
continue
print(f"{key:70s} {asset['display']}")
return 0
if not args.query:
ap.print_usage(sys.stderr)
return 2
match = find_asset(index, args.query, args.kind)
if not match:
print(f"no asset matches '{args.query}'", file=sys.stderr)
return 1
key, asset = match
print(emit_asset(asset, args))
print(f"# matched: {key}{asset['display']}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Extract a ready-to-paste <mxCell> for a named SAP BTP service icon.
Usage:
extract_icon.py <service-name> [--x X --y Y --w W --h H --id ID --parent P --label "text"]
extract_icon.py --list
Search is fuzzy (case-insensitive substring + slug alias).
Emits the <mxCell ...><mxGeometry .../></mxCell> to stdout.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from xml.sax.saxutils import escape
HERE = Path(__file__).resolve().parent
INDEX = HERE.parent / "assets" / "icon-index.json"
ASSET_INDEX = HERE.parent / "assets" / "asset-index.json"
try:
from extract_asset import emit_asset as emit_general_asset
from extract_asset import find_asset as find_general_asset
except ImportError: # pragma: no cover - fallback for copied standalone script use
emit_general_asset = None
find_general_asset = None
def load_index() -> dict:
if not INDEX.exists():
print(f"icon index not found at {INDEX}; run build_icon_index.py first", file=sys.stderr)
sys.exit(1)
return json.loads(INDEX.read_text(encoding="utf-8"))
def load_asset_index() -> dict | None:
if not ASSET_INDEX.exists() or emit_general_asset is None or find_general_asset is None:
return None
return json.loads(ASSET_INDEX.read_text(encoding="utf-8"))
def _normalize(text: str) -> str:
"""Strip HTML entities (&#10;, &amp;), lowercase, collapse non-alphanumeric to single spaces."""
t = text.lower()
# decode common HTML newline / ampersand entities
t = re.sub(r"&#?\w+;", " ", t)
# kill the -10- slug artifact from earlier encoding
t = re.sub(r"(^|-)10(-|$)", r"\1 \2", t)
return re.sub(r"[^a-z0-9]+", " ", t).strip()
def _tokens(text: str) -> set[str]:
return {tok for tok in _normalize(text).split() if tok}
def is_backend_system_query(query: str) -> bool:
"""Return True for backend product names that are not BTP service icons."""
normalized = _normalize(query)
compact = re.sub(r"[^a-z0-9]+", "", query.lower())
if normalized in {"sap s 4hana", "sap s 4hana cloud", "s 4hana", "s 4hana cloud"}:
return True
if compact in {"s4hana", "saps4hana", "s4hanacloud", "saps4hanacloud"}:
return True
return False
def backend_system_guidance(query: str) -> str:
return (
f"'{query}' is a backend system/product label, not a BTP service icon. "
"Use extract_asset.py \"sap-s-4hana\" --kind sap-brand-name for the label "
"or extract_asset.py \"on-premise-sap\" --kind generic-icon for a generic backend icon."
)
# Hand-curated aliases for common SAP service abbreviations / nicknames.
# These map a user-typed query → a slug substring that uniquely identifies the icon.
COMMON_ALIASES: dict[str, str] = {
"xsuaa": "sap-authorization-10-and-10-trust-management-service",
"uaa": "sap-authorization-10-and-10-trust-management-service",
"auth trust": "sap-authorization-10-and-10-trust-management-service",
"ias": "identity-10-authentication",
"ips": "identity-provisioning",
"btp cockpit": "sap-btp-cockpit",
"hana": "sap-hana-cloud",
"hana cloud": "sap-hana-cloud",
"cf": "cloud-10-foundry-runtime",
"cloud foundry": "cloud-10-foundry-runtime",
"cap": "cloud-application-programming",
"destination": "sap-destination-10-service",
"cc": "cloud-10-connector",
"connector": "cloud-10-connector",
"abap env": "abap-environment",
"abap environment": "abap-environment",
"build apps": "sap-build-apps",
"build process": "sap-build-process-automation",
"build code": "sap-build-code",
"build work zone": "sap-build-work-zone-10-standard-edition",
"sap build work zone": "sap-build-work-zone-10-standard-edition",
"work zone": "sap-build-work-zone-10-standard-edition",
"joule": "joule-studio",
"task center": "sap-task-center",
"cpi": "cloud-10-integration",
"integration suite": "integration-suite",
}
def find(index: dict, query: str) -> tuple[str, dict] | None:
if is_backend_system_query(query):
return None
q = query.lower().strip()
qslug = re.sub(r"[^a-z0-9]+", "-", q).strip("-")
# curated alias table
if q in COMMON_ALIASES:
slug = COMMON_ALIASES[q]
if slug in index:
return slug, index[slug]
# exact slug
if qslug in index:
return qslug, index[qslug]
# alias match
for slug, entry in index.items():
if qslug in entry["aliases"] or qslug == entry["display"].lower():
return slug, entry
qtokens = _tokens(q)
if not qtokens:
return None
# token-subset match: every query token appears in (display | slug | aliases)
token_candidates: list[tuple[str, dict]] = []
for slug, entry in index.items():
pool = _tokens(slug) | _tokens(entry["display"]) | {a for alias in entry["aliases"] for a in _tokens(alias)}
if qtokens <= pool:
token_candidates.append((slug, entry))
if len(token_candidates) == 1:
return token_candidates[0]
if len(token_candidates) > 1:
# prefer the candidate whose normalized display is shortest (tightest match)
token_candidates.sort(key=lambda se: len(_normalize(se[1]["display"])))
# if the top candidate is strictly shorter than the runner-up, take it
top = _normalize(token_candidates[0][1]["display"])
second = _normalize(token_candidates[1][1]["display"])
if len(top) < len(second):
return token_candidates[0]
print(f"ambiguous '{query}'{len(token_candidates)} matches:", file=sys.stderr)
for s, e in token_candidates[:10]:
print(f" {s}{e['display']}", file=sys.stderr)
sys.exit(2)
# substring fallback
candidates = [(s, e) for s, e in index.items() if q in e["display"].lower() or qslug in s]
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
print(f"ambiguous '{query}'{len(candidates)} matches:", file=sys.stderr)
for s, e in candidates[:10]:
print(f" {s}{e['display']}", file=sys.stderr)
sys.exit(2)
return None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("query", nargs="?")
ap.add_argument("--list", action="store_true")
ap.add_argument("--x", type=int, default=0)
ap.add_argument("--y", type=int, default=0)
# Defaults match the SAP corpus convention: 32x32 is the dominant icon
# size (used 224x across the 71 bundled templates), then 48x48 (157x).
# The previous default 64x80 caused icons to overlap card text — the
# most common visible bug. Override with --w 48 --h 48 only when the
# icon is the focal anchor of a zone (e.g. a brand mark beside a
# top-left zone label).
ap.add_argument("--w", type=int, default=32)
ap.add_argument("--h", type=int, default=32)
ap.add_argument("--id", default="icon1")
ap.add_argument("--parent", default="1")
ap.add_argument("--label", default=None, help="Override icon label text")
args = ap.parse_args()
asset_index = load_asset_index()
index = load_index()
if args.list:
if asset_index:
assets = asset_index["assets"]
for key in sorted(assets):
entry = assets[key]
if entry["kind"] == "btp-service-icon":
slug = key.split(":", 1)[1]
print(f"{slug:60s} {entry['display']}")
else:
for slug in sorted(index):
print(f"{slug:60s} {index[slug]['display']}")
return 0
if not args.query:
ap.print_usage(sys.stderr)
return 2
if is_backend_system_query(args.query):
print(backend_system_guidance(args.query), file=sys.stderr)
return 1
prefer_icon_index = args.query.lower().strip() in COMMON_ALIASES
if asset_index and find_general_asset and emit_general_asset and not prefer_icon_index:
try:
asset_match = find_general_asset(asset_index, args.query, "btp-service-icon")
except SystemExit:
asset_match = None
if asset_match:
slug, asset = asset_match
print(emit_general_asset(asset, args))
print(f"# matched: {slug}{asset['display']}", file=sys.stderr)
return 0
match = find(index, args.query)
if not match:
print(f"no icon matches '{args.query}'", file=sys.stderr)
return 1
slug, entry = match
style = entry["style"]
label = args.label if args.label is not None else entry["label"]
label_xml = escape(label, {'"': "&quot;"})
# Snap to 10-px grid (quiet fix)
x = round(args.x / 10) * 10
y = round(args.y / 10) * 10
w = round(args.w / 10) * 10
h = round(args.h / 10) * 10
cell = (
f'<mxCell id="{args.id}" value="{label_xml}" style="{style}" '
f'vertex="1" parent="{args.parent}">'
f'<mxGeometry x="{x}" y="{y}" width="{w}" height="{h}" as="geometry"/>'
f"</mxCell>"
)
print(cell)
print(f"# matched: {slug}{entry['display']}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Search the bundled SAP template registry for templates that match a design pattern.
This is the "if SAP did something similar, find it for me" tool. Use cases
the LLM should reach for it:
- "I need to draw 4 zones with a vertical network divider — what SAP
template has that already?"
- "Which templates use exactly TRUST + Authenticate + A2A + MCP pills?"
- "Show me templates with ≥ 6 BTP service icons inside a Cloud Solutions
band, so I can match the spacing/sizing they use."
- "Which templates have Joule as a separate purple zone (not nested in BTP)?"
The script reads the precomputed `assets/reference-examples/template-profiles.json`
(built by `profile_template.py --build-registry`) and ranks templates by how
well their structural profile matches the query.
Usage:
find_pattern.py "vertical network divider"
find_pattern.py "joule purple zone"
find_pattern.py --pill TRUST --pill A2A --pill MCP
find_pattern.py --zones 4 --icons-min 8
find_pattern.py --pattern tri-zone-joule-btp-third-party
find_pattern.py --top 5 --json "identity flow at bottom"
Output: ranked templates with the matching evidence per template.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
THIS_DIR = Path(__file__).resolve().parent
REGISTRY_DEFAULT = THIS_DIR.parent / "assets" / "reference-examples" / "template-profiles.json"
@dataclass
class Match:
name: str
score: float
reasons: list[str] = field(default_factory=list)
profile: dict = field(default_factory=dict)
def load_registry(path: Path) -> dict:
if not path.exists():
print(
f"registry not found: {path}\n"
"Run: python3 .../scripts/profile_template.py --build-registry",
file=sys.stderr,
)
sys.exit(2)
return json.loads(path.read_text(encoding="utf-8"))
def textual_score(profile: dict, terms: set[str]) -> tuple[float, list[str]]:
"""Score how often the search terms appear in the profile's textual fields."""
haystack_parts = [
profile.get("title", ""),
profile.get("description", ""),
" ".join(profile.get("aliases") or []),
" ".join(profile.get("tags") or []),
" ".join(profile.get("detected_patterns") or []),
" ".join(p.get("label", "") for p in (profile.get("zones") or [])),
" ".join(p.get("label", "") for p in (profile.get("cards") or [])),
" ".join(profile.get("pill_vocab") or []),
]
haystack = " ".join(str(s) for s in haystack_parts).lower()
hits = []
score = 0.0
for t in terms:
n = haystack.count(t)
if n > 0:
hits.append(f"{t}×{n}")
score += n
return score, hits
def pattern_match(profile: dict, want_patterns: list[str]) -> tuple[float, list[str]]:
have = set(profile.get("detected_patterns") or [])
matched = [p for p in want_patterns if p in have]
return (len(matched) * 8.0, matched)
def pill_match(profile: dict, want_pills: list[str]) -> tuple[float, list[str]]:
have = set(p.lower() for p in (profile.get("pill_vocab") or []))
matched = [p for p in want_pills if p.lower() in have]
return (len(matched) * 5.0, matched)
def structure_match(profile: dict, want: dict) -> tuple[float, list[str]]:
"""Partial-credit scoring for desired counts (zones, cards, icons, pills, edges)."""
structure = profile.get("structure_summary", {})
score = 0.0
reasons = []
pairs = {
"zones": "top_level_zones",
"nested_zones": "nested_zones",
"cards": "cards",
"icons": "icons",
"pills": "pills",
"edges": "edges",
}
for key, prof_key in pairs.items():
if want.get(key) is not None:
target = int(want[key])
actual = int(structure.get(prof_key, 0))
# Award full points when exact, partial when within 20%
if actual == target:
score += 6.0
reasons.append(f"{key}={actual}")
elif abs(actual - target) <= max(2, target * 0.2):
score += 3.0
reasons.append(f"{key}={actual}~={target}")
if want.get(f"{key}_min") is not None:
target = int(want[f"{key}_min"])
actual = int(structure.get(prof_key, 0))
if actual >= target:
score += 2.0
reasons.append(f"{key}>={actual}")
return score, reasons
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("query", nargs="*", help="free-text search across titles, tags, patterns, labels")
ap.add_argument("--registry", type=Path, default=REGISTRY_DEFAULT)
ap.add_argument("--top", type=int, default=5)
ap.add_argument("--json", action="store_true")
ap.add_argument("--pattern", action="append", default=[],
help="require a specific detected_patterns tag (repeatable)")
ap.add_argument("--pill", action="append", default=[],
help="require a specific pill verb in vocab (repeatable)")
ap.add_argument("--zones", type=int, help="prefer templates with this many top-level zones")
ap.add_argument("--zones-min", type=int)
ap.add_argument("--cards", type=int)
ap.add_argument("--cards-min", type=int)
ap.add_argument("--icons", type=int)
ap.add_argument("--icons-min", type=int)
ap.add_argument("--pills", type=int)
ap.add_argument("--pills-min", type=int)
ap.add_argument("--edges", type=int)
ap.add_argument("--edges-min", type=int)
ap.add_argument("--family", help="restrict to a specific family tag (e.g. ra0029, btp, ext-mdi)")
ap.add_argument("--list-patterns", action="store_true",
help="print every detected_patterns tag observed in the registry")
ap.add_argument("--list-pills", action="store_true",
help="print every pill verb observed across the registry")
args = ap.parse_args()
reg = load_registry(args.registry)
profiles = reg.get("templates", {})
if args.list_patterns:
all_pats: dict[str, int] = {}
for prof in profiles.values():
for p in (prof.get("detected_patterns") or []):
all_pats[p] = all_pats.get(p, 0) + 1
for pat, n in sorted(all_pats.items(), key=lambda kv: -kv[1]):
print(f" {n:>3}× {pat}")
return 0
if args.list_pills:
all_pills: dict[str, int] = {}
for prof in profiles.values():
for p in (prof.get("pill_vocab") or []):
all_pills[p] = all_pills.get(p, 0) + 1
for pill, n in sorted(all_pills.items(), key=lambda kv: -kv[1]):
print(f" {n:>3}× {pill!r}")
return 0
free_text = " ".join(args.query).lower().strip()
terms = set(re.findall(r"[a-z0-9]+", free_text)) if free_text else set()
want_structure = {
"zones": args.zones, "zones_min": args.zones_min,
"cards": args.cards, "cards_min": args.cards_min,
"icons": args.icons, "icons_min": args.icons_min,
"pills": args.pills, "pills_min": args.pills_min,
"edges": args.edges, "edges_min": args.edges_min,
}
matches: list[Match] = []
for name, profile in profiles.items():
if args.family and profile.get("family") != args.family:
continue
score = 0.0
reasons: list[str] = []
if terms:
s, hits = textual_score(profile, terms)
if s > 0:
score += s
reasons.append("text: " + ", ".join(hits[:8]))
if args.pattern:
s, hits = pattern_match(profile, args.pattern)
if s > 0:
score += s
reasons.append("pattern: " + ", ".join(hits))
else:
# Required patterns not found — skip this template
continue
if args.pill:
s, hits = pill_match(profile, args.pill)
if s > 0:
score += s
reasons.append("pills: " + ", ".join(hits))
s, hits = structure_match(profile, want_structure)
if s > 0:
score += s
reasons.append("structure: " + ", ".join(hits))
# Light prior: prefer primary templates when scores are tied
if profile.get("primary"):
score += 0.5
reasons.append("(primary)")
if score > 0 or not (terms or args.pattern or args.pill or any(want_structure.values())):
matches.append(Match(name=name, score=score, reasons=reasons, profile=profile))
matches.sort(key=lambda m: (-m.score, m.name))
matches = matches[: args.top]
if args.json:
out = [{
"name": m.name,
"score": round(m.score, 1),
"reasons": m.reasons,
"title": m.profile.get("title", ""),
"description": m.profile.get("description", ""),
"structure": m.profile.get("structure_summary", {}),
"detected_patterns": m.profile.get("detected_patterns", []),
"pill_vocab": m.profile.get("pill_vocab", []),
} for m in matches]
print(json.dumps(out, indent=2))
return 0
if not matches:
print("no templates matched the criteria")
return 0
for i, m in enumerate(matches, 1):
print(f"{i}. {m.score:5.1f} {m.name}")
if m.profile.get("title"):
print(f" title : {m.profile['title']}")
if m.profile.get("structure_summary"):
print(f" structure: {m.profile['structure_summary']}")
if m.profile.get("detected_patterns"):
print(f" patterns : {', '.join(m.profile['detected_patterns'][:8])}")
if m.profile.get("pill_vocab"):
print(f" pills : {', '.join(repr(p) for p in m.profile['pill_vocab'][:6])}")
for r in m.reasons:
print(f"{r}")
return 0
if __name__ == "__main__":
sys.exit(main())
+589
View File
@@ -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())
+648
View File
@@ -0,0 +1,648 @@
#!/usr/bin/env python3
"""Extract a deep structural design profile from one .drawio file.
The fingerprint in compare.py is a similarity signature — good for scoring
but useless for *teaching* an LLM what makes a SAP template work. This
script produces a much richer per-template profile: zone inventory, card
inventory, icon size+library breakdown, pill vocabulary, edge
anchor/orthogonality stats, color usage, and detected layout patterns
(vertical network divider, identity-anchor-bottom-center, cloud-solutions
horizontal band, etc.).
When the LLM has chosen a SAP template via scaffold_diagram.py, it should
read this profile to understand the "design recipe" it's editing. The
recipe answers:
- How many zones, of what colors, in what arrangement?
- How many cards inside each zone?
- What sizes are the icons (so don't override default to 80×80)?
- What pill verbs does this scenario use?
- What edge styles are typical here (orthogonal vs straight, anchored vs naked)?
- What named layout patterns does this template exhibit?
Usage:
profile_template.py <file>.drawio # human-readable profile
profile_template.py <file>.drawio --json # machine-readable
profile_template.py --build-registry [--out FILE] # scan all bundled SAP refs and write
a single profiles.json registry
The registry is what `iterate.py` and `find_pattern.py` consult — it's
small (~250 KB for 71 templates) and shipped with the plugin.
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import xml.etree.ElementTree as ET
from collections import Counter
from dataclasses import asdict, dataclass, field
from pathlib import Path
THIS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(THIS_DIR))
import select_reference as _sel # noqa: E402 for metadata lookup
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")
DATA_URI_RE = re.compile(r"data:image/[^&\";]+")
SHAPE_RE = re.compile(r"(?:^|;)shape=([^;\"]+)")
# ---- Color → human name mapping (for the "zone colors" line in profiles)
COLOR_NAMES = {
"#0070F2": "SAP blue (border)",
"#EBF8FF": "SAP blue (fill)",
"#475E75": "non-SAP slate (border)",
"#F5F6F7": "non-SAP slate (fill)",
"#188918": "positive green (border)",
"#F5FAE5": "positive green (fill)",
"#C35500": "critical orange (border)",
"#FFF8D6": "critical orange (fill)",
"#D20A0A": "negative red (border)",
"#FFEAF4": "negative red (fill)",
"#5D36FF": "indigo accent (border)",
"#F1ECFF": "indigo accent (fill, Joule purple)",
"#CC00DC": "pink accent (trust)",
"#FFF0FA": "pink accent (fill)",
"#07838F": "teal accent (border, MCP)",
"#DAFDF5": "teal accent (fill)",
"#1D2D3E": "title text",
"#556B82": "body text",
"#1A2733": "near-black navy",
"#5B738B": "lighter slate",
}
@dataclass
class ZoneInfo:
cell_id: str
label: str
fill: str
stroke: str
x: int
y: int
w: int
h: int
parent_id: str | None
color_role: str # "sap-blue" | "non-sap-slate" | "indigo" | "teal" | "pink" | "other"
@dataclass
class CardInfo:
cell_id: str
label: str
fill: str
stroke: str
x: int
y: int
w: int
h: int
parent_zone: str | None
@dataclass
class IconInfo:
cell_id: str
library_name: str # SAP service slug, "mxgraph.sap.icon", or "image"
label: str
w: int
h: int
parent_id: str | None
@dataclass
class PillInfo:
cell_id: str
label: str
fill: str
stroke: str
@dataclass
class EdgeInfo:
cell_id: str
source: str
target: str
stroke_color: str
has_anchors: bool
is_orthogonal: bool
is_dashed: bool
@dataclass
class TemplateProfile:
file: str
name: str
family: str
level: str
primary: bool
title: str
aliases: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
domain: str = ""
canvas_w: int = 0
canvas_h: int = 0
background: str = ""
zones: list[ZoneInfo] = field(default_factory=list)
cards: list[CardInfo] = field(default_factory=list)
icons: list[IconInfo] = field(default_factory=list)
pills: list[PillInfo] = field(default_factory=list)
edges: list[EdgeInfo] = field(default_factory=list)
icon_sizes: dict[str, int] = field(default_factory=dict) # "32x32" → count
pill_vocab: list[str] = field(default_factory=list)
color_distribution: dict[str, int] = field(default_factory=dict) # hex → count
font_sizes: dict[str, int] = field(default_factory=dict)
fonts: list[str] = field(default_factory=list)
edge_color_distribution: dict[str, int] = field(default_factory=dict)
structure_summary: dict[str, int] = field(default_factory=dict)
edge_quality: dict[str, int] = field(default_factory=dict)
detected_patterns: list[str] = field(default_factory=list)
description: str = ""
def to_dict(self) -> dict:
d = asdict(self)
return d
# ---------- Helpers -----------------------------------------------------------
def _strip_label(raw: str) -> str:
s = html.unescape(raw or "")
s = re.sub(r"<br\s*/?>", " ", s, flags=re.I)
s = re.sub(r"<[^>]+>", "", s)
s = re.sub(r"&nbsp;", " ", s)
return re.sub(r"\s+", " ", s).strip()
def _parse_style(style: str | None) -> dict[str, str]:
out: dict[str, str] = {}
if not style:
return out
for part in style.split(";"):
part = part.strip()
if not part or "=" not in part:
continue
k, v = part.split("=", 1)
out[k.strip()] = v.strip()
return out
def _color_role(stroke: str) -> str:
s = stroke.upper()
if s == "#0070F2":
return "sap-blue"
if s == "#475E75":
return "non-sap-slate"
if s == "#5D36FF":
return "indigo (Joule purple)"
if s == "#07838F":
return "teal accent"
if s == "#CC00DC":
return "pink accent (trust)"
if s == "#188918":
return "positive green"
if s == "#C35500":
return "critical orange"
if s == "#D20A0A":
return "negative red"
return f"other ({stroke})"
def _icon_library_name(style: str) -> str:
"""Extract a usable library / service name from an icon's style string."""
if "mxgraph.sap.icon" in style:
m = re.search(r"SAPIcon=([A-Za-z0-9_]+)", style)
if m:
return f"mxgraph.sap.icon/{m.group(1)}"
return "mxgraph.sap.icon"
# Inline-SVG icons sometimes carry an SAP-set hint in image-data URI; we can't easily
# decode the content, but we can flag image vs not.
if "shape=image" in style:
return "inline-svg"
return "unknown"
# ---------- Pattern detectors ------------------------------------------------
def _detect_patterns(profile: TemplateProfile) -> list[str]:
"""Heuristic detectors for common SAP layout patterns.
These run on the populated profile and produce short, grep-able pattern
tags the LLM can match against.
"""
patterns: list[str] = []
# Tri-zone Joule/BTP/3rd-party (RA0029 family signature)
has_joule_color = any("indigo" in z.color_role for z in profile.zones)
has_btp_color = any("sap-blue" in z.color_role for z in profile.zones if z.parent_id == "1")
has_slate = any("non-sap-slate" in z.color_role for z in profile.zones if z.parent_id == "1")
if has_joule_color and has_btp_color and has_slate:
patterns.append("tri-zone-joule-btp-third-party")
# Network divider — vertical bar, slate, very tall, very narrow
for c in profile.cards:
if c.h > 200 and c.w < 20 and "475E75" in (c.stroke + c.fill).upper():
patterns.append("vertical-network-divider")
break
# Cloud Solutions horizontal band — wide BTP-blue zone at bottom containing 4+ cards
btp_blue_zones = [z for z in profile.zones if z.color_role == "sap-blue"]
for z in btp_blue_zones:
if z.w > 400 and z.h < 250 and z.y > profile.canvas_h * 0.55:
children_count = sum(1 for c in profile.cards if c.parent_zone == z.cell_id)
if children_count >= 3:
patterns.append("cloud-solutions-bottom-band")
break
# Identity anchored at bottom center (IAS icon near bottom-center)
canvas_cx = profile.canvas_w / 2
for icon in profile.icons:
if "Identity" in (icon.library_name or "") or "identity" in icon.label.lower() or "ias" in icon.label.lower():
if abs((icon.parent_id and 0 or 0) + 0) < 0: # placeholder; check coordinates instead
pass
# Find geometry for this icon
# (icon doesn't carry geometry directly; would need separate lookup;
# approximation: if any icon's label contains "identity"/"ias" we tag it)
patterns.append("has-identity-services-icon")
break
# Many BTP service icons (>= 8) — full-blown landscape
if len(profile.icons) >= 8:
patterns.append("dense-icon-landscape")
elif len(profile.icons) <= 3:
patterns.append("sparse-icon-overview")
# Multiple-pill flow (>= 5 pills) → labeled-flow diagram
if len(profile.pills) >= 5:
patterns.append("labeled-flow-multi-pill")
# Dashed edges signal optional/async flows
if any(e.is_dashed for e in profile.edges):
patterns.append("uses-dashed-edges")
# Properly anchored edges — quality signal for the LLM to imitate
if profile.edges:
anchored_pct = sum(1 for e in profile.edges if e.has_anchors) / len(profile.edges)
if anchored_pct >= 0.7:
patterns.append("well-anchored-edges")
elif anchored_pct < 0.3:
patterns.append("naked-edges-style")
# Mostly orthogonal — SAP convention
if profile.edges:
ortho_pct = sum(1 for e in profile.edges if e.is_orthogonal) / len(profile.edges)
if ortho_pct >= 0.8:
patterns.append("orthogonal-edges-dominant")
# Subaccount nested inside SAP BTP zone (RA0029 + IAM templates pattern)
for z in profile.zones:
if z.label.lower() == "subaccount" and z.parent_id and z.parent_id != "1":
patterns.append("subaccount-nested-in-btp")
break
# Multi-zone pattern — reference families that use 4+ top-level zones
top_level_zones = [z for z in profile.zones if z.parent_id == "1" or z.parent_id is None]
if len(top_level_zones) >= 4:
patterns.append("multi-zone-layout-4plus")
elif len(top_level_zones) == 3:
patterns.append("tri-zone-layout")
elif len(top_level_zones) == 2:
patterns.append("dual-zone-layout")
return sorted(set(patterns))
# ---------- Description synthesizer ------------------------------------------
def _synthesize_description(profile: TemplateProfile) -> str:
"""One-paragraph plain-English summary of the template's design recipe."""
parts = []
parts.append(profile.title or profile.name)
if profile.canvas_w and profile.canvas_h:
parts.append(f"on a {profile.canvas_w}×{profile.canvas_h} canvas")
top_zones = [z for z in profile.zones if z.parent_id in (None, "1")]
zone_summary = ", ".join(
f"{z.label or '(unnamed)'} [{z.color_role}]"
for z in top_zones[:6]
)
if zone_summary:
parts.append(f"with top-level zones: {zone_summary}")
if profile.icons:
size_summary = ", ".join(f"{n}× {s}" for s, n in sorted(profile.icon_sizes.items(), key=lambda kv: -kv[1])[:3])
parts.append(f"and {len(profile.icons)} icons ({size_summary})")
if profile.pills:
vocab_summary = ", ".join(f"{lbl!r}" for lbl in list(dict.fromkeys(profile.pill_vocab))[:6])
parts.append(f"plus {len(profile.pills)} flow pills using verbs {vocab_summary}")
if profile.detected_patterns:
parts.append(f"Detected patterns: {', '.join(profile.detected_patterns[:5])}")
return ". ".join(parts) + "."
# ---------- Main extraction --------------------------------------------------
def profile_one(path: Path) -> TemplateProfile:
metadata = _sel.template_metadata(path)
profile = TemplateProfile(
file=str(path),
name=path.name,
family=str(metadata.get("family", "")),
level=str(metadata.get("level", "")),
primary=bool(metadata.get("primary", False)),
title=str(metadata.get("title", "")),
aliases=list(metadata.get("aliases", [])) if isinstance(metadata.get("aliases"), list) else [],
tags=list(metadata.get("tags", [])) if isinstance(metadata.get("tags"), list) else [],
domain=str(metadata.get("domain", "")),
)
text = path.read_text(encoding="utf-8", errors="ignore")
palette_text = DATA_URI_RE.sub("", text)
profile.color_distribution = dict(Counter(h.upper() for h in HEX_RE.findall(palette_text)).most_common(20))
profile.fonts = sorted(set(re.findall(r"fontFamily=([^;\"]+)", palette_text)))
try:
root = ET.parse(path).getroot()
except ET.ParseError:
return profile
graph = root.find(".//mxGraphModel")
if graph is not None:
profile.canvas_w = int(graph.get("pageWidth") or "0")
profile.canvas_h = int(graph.get("pageHeight") or "0")
bg = graph.get("background") or graph.get("pageBackgroundColor") or ""
profile.background = bg.strip().lower()
parent_by_elem = {id(child): parent for parent in root.iter() for child in list(parent)}
# --- Pass 1: collect cell metadata
icon_geometries: dict[str, tuple[int, int, int, int]] = {}
cells = []
cell_lookup: dict[str, ET.Element] = {}
for c in root.iter("mxCell"):
cid = c.get("id")
if not cid:
parent_uo = parent_by_elem.get(id(c))
if parent_uo is not None and parent_uo.tag == "UserObject":
cid = parent_uo.get("id")
if not cid:
continue
cells.append((cid, c))
cell_lookup[cid] = c
# --- Pre-pass: count children per cell so we can distinguish zone (container)
# from card (leaf). Both can use rounded-rect style with strokeWidth=1.5; the
# only visual difference is whether other cells are parented inside.
children_count: dict[str, int] = {}
for _, c in cells:
parent = c.get("parent")
if parent:
children_count[parent] = children_count.get(parent, 0) + 1
# --- Pass 2: classify each cell
pill_labels: list[str] = []
font_size_counter: Counter[str] = Counter()
for cid, c in cells:
style = c.get("style") or ""
sd = _parse_style(style)
is_vertex = c.get("vertex") == "1"
is_edge = c.get("edge") == "1"
# Geometry
geo = c.find("mxGeometry")
if geo is None:
x = y = w = h = 0
else:
try:
x = int(float(geo.get("x", "0")))
y = int(float(geo.get("y", "0")))
w = int(float(geo.get("width", "0")))
h = int(float(geo.get("height", "0")))
except ValueError:
x = y = w = h = 0
raw_value = c.get("value") or ""
if not raw_value:
parent_uo = parent_by_elem.get(id(c))
if parent_uo is not None and parent_uo.tag == "UserObject":
raw_value = parent_uo.get("value") or parent_uo.get("label") or ""
label = _strip_label(raw_value)
# Font sizes from inline HTML — heuristic
for fs in re.findall(r"font-size\s*:\s*(\d+)", raw_value):
font_size_counter[fs] += 1
if sd.get("fontSize"):
font_size_counter[sd["fontSize"]] += 1
if is_edge:
stroke = sd.get("strokeColor", "").upper()
has_anchors = any(k in sd for k in ("entryX", "exitX", "entryY", "exitY"))
is_ortho = sd.get("edgeStyle") == "orthogonalEdgeStyle"
is_dashed = sd.get("dashed") == "1"
profile.edges.append(EdgeInfo(
cell_id=cid,
source=c.get("source", ""),
target=c.get("target", ""),
stroke_color=stroke,
has_anchors=has_anchors,
is_orthogonal=is_ortho,
is_dashed=is_dashed,
))
continue
if not is_vertex or w <= 0 or h <= 0:
continue
# Icon? — image with SVG or PNG, or mxgraph.sap.icon stencil
is_icon = False
image = sd.get("image", "")
if sd.get("shape") == "image" and image.startswith(("data:image/svg", "data:image/png")):
is_icon = True
elif "mxgraph.sap.icon" in style:
is_icon = True
if is_icon:
profile.icons.append(IconInfo(
cell_id=cid,
library_name=_icon_library_name(style),
label=label[:60],
w=w, h=h,
parent_id=c.get("parent"),
))
icon_geometries[cid] = (x, y, w, h)
continue
# Pill? — arcSize=50, small
try:
arc = int(float(sd.get("arcSize", "0")))
except ValueError:
arc = 0
if arc >= 40 and w <= 220 and h <= 60:
pill_labels.append(label.lower())
profile.pills.append(PillInfo(
cell_id=cid,
label=label,
fill=sd.get("fillColor", ""),
stroke=sd.get("strokeColor", ""),
))
continue
# Zone? — rounded container with strokeWidth=1.5. SAP uses the same
# rounded-rect style for zones and cards. We distinguish by either:
# (a) the cell has children parented inside it — definitively a zone
# (b) the cell is large enough to plausibly hold content
# (min dim >= 100 AND max dim >= 200 — cards are smaller)
n_children = children_count.get(cid, 0)
is_rounded_rect = (
12 <= arc <= 30
and sd.get("strokeWidth", "").rstrip(";") == "1.5"
)
is_zone = is_rounded_rect and (
n_children >= 1
or (min(w, h) >= 100 and max(w, h) >= 200)
)
if is_zone:
stroke = sd.get("strokeColor", "")
fill = sd.get("fillColor", "")
profile.zones.append(ZoneInfo(
cell_id=cid,
label=label[:80],
fill=fill,
stroke=stroke,
x=x, y=y, w=w, h=h,
parent_id=c.get("parent"),
color_role=_color_role(stroke),
))
continue
# Otherwise classify as a card if it has a fill and a label
if sd.get("fillColor", "").lower() not in ("", "none"):
# Determine which zone (if any) this card sits inside
parent_zone_id: str | None = None
for z in profile.zones:
if z.cell_id == c.get("parent"):
parent_zone_id = z.cell_id
break
profile.cards.append(CardInfo(
cell_id=cid,
label=label[:80],
fill=sd.get("fillColor", ""),
stroke=sd.get("strokeColor", ""),
x=x, y=y, w=w, h=h,
parent_zone=parent_zone_id,
))
profile.icon_sizes = dict(Counter(f"{i.w}x{i.h}" for i in profile.icons).most_common(10))
profile.pill_vocab = list(dict.fromkeys(pill_labels))
profile.font_sizes = dict(font_size_counter.most_common(8))
# Edge color distribution
profile.edge_color_distribution = dict(
Counter(e.stroke_color for e in profile.edges if e.stroke_color).most_common(8)
)
profile.edge_quality = {
"total": len(profile.edges),
"with_anchors": sum(1 for e in profile.edges if e.has_anchors),
"orthogonal": sum(1 for e in profile.edges if e.is_orthogonal),
"dashed": sum(1 for e in profile.edges if e.is_dashed),
}
profile.structure_summary = {
"top_level_zones": sum(1 for z in profile.zones if z.parent_id in (None, "1")),
"nested_zones": sum(1 for z in profile.zones if z.parent_id not in (None, "1")),
"cards": len(profile.cards),
"icons": len(profile.icons),
"pills": len(profile.pills),
"edges": len(profile.edges),
}
profile.detected_patterns = _detect_patterns(profile)
profile.description = _synthesize_description(profile)
return profile
def build_registry(refs_dir: Path, out_path: Path) -> int:
refs = sorted(refs_dir.rglob("*.drawio"))
profiles = {}
for p in refs:
try:
profiles[p.name] = profile_one(p).to_dict()
except Exception as e:
print(f"warning: failed to profile {p.name}: {e}", file=sys.stderr)
continue
payload = {
"version": 1,
"purpose": "Pre-computed deep design profiles for every bundled SAP reference template. Consulted by iterate.py and find_pattern.py so the LLM can study the design recipe of its chosen scaffold.",
"templates": profiles,
}
out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return len(profiles)
def render_human(profile: TemplateProfile) -> str:
lines: list[str] = []
lines.append(f"=== {profile.name} ===")
lines.append(f"Title : {profile.title or '(no metadata title)'}")
lines.append(f"Family : {profile.family} | Level: {profile.level} | Domain: {profile.domain} | Primary: {profile.primary}")
lines.append(f"Canvas : {profile.canvas_w}×{profile.canvas_h} bg={profile.background or '(white/none)'}")
lines.append(f"Structure: {profile.structure_summary}")
if profile.zones:
lines.append("Zones :")
for z in profile.zones:
depth = " " if z.parent_id in (None, "1") else ""
lines.append(f" {depth}{z.label or '(unnamed)':40s} {z.color_role:30s} @ ({z.x},{z.y}) {z.w}×{z.h}")
if profile.icons:
lines.append(f"Icons : {len(profile.icons)} total — sizes: {profile.icon_sizes}")
if profile.pills:
vocab_preview = ", ".join(f"{lbl!r}" for lbl in profile.pill_vocab[:8])
lines.append(f"Pills : {len(profile.pills)} — vocab: {vocab_preview}")
if profile.edges:
eq = profile.edge_quality
lines.append(f"Edges : {eq['total']} total — {eq['with_anchors']} anchored, {eq['orthogonal']} orthogonal, {eq['dashed']} dashed")
if profile.edge_color_distribution:
lines.append(f" colors: {profile.edge_color_distribution}")
if profile.detected_patterns:
lines.append("Patterns :")
for pat in profile.detected_patterns:
lines.append(f"{pat}")
lines.append(f"Recipe : {profile.description}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("file", nargs="?", type=Path, help="single .drawio to profile")
ap.add_argument("--build-registry", action="store_true",
help="scan all bundled SAP refs and write template-profiles.json")
ap.add_argument("--refs-dir", type=Path, default=None,
help="reference dir (default: bundled assets)")
ap.add_argument("--out", type=Path, default=None,
help="output path for --build-registry (default: assets/reference-examples/template-profiles.json)")
ap.add_argument("--json", action="store_true", help="emit JSON for one file")
args = ap.parse_args()
if args.build_registry:
refs_dir = args.refs_dir or (THIS_DIR.parent / "assets" / "reference-examples")
out_path = args.out or (refs_dir / "template-profiles.json")
n = build_registry(refs_dir, out_path)
print(f"profiled {n} templates → {out_path}")
return 0
if not args.file:
print("either provide a .drawio path or pass --build-registry", file=sys.stderr)
return 2
profile = profile_one(args.file)
if args.json:
print(json.dumps(profile.to_dict(), indent=2))
else:
print(render_human(profile))
return 0
if __name__ == "__main__":
sys.exit(main())
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Apply deterministic label replacements to a scaffolded .drawio file.
Mapping formats:
{"Old visible label": "New label"}
{"ids": {"cell-id": "New label"}, "labels": {"Old visible label": "New label"}}
Visible-label matching ignores HTML wrappers and treats <br> as whitespace.
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
LABEL_ATTRS = ("value", "label", "name")
@dataclass(frozen=True)
class Replacement:
element_id: str
attr: str
old: str
new: str
matched_by: str
def clean_label(value: str) -> str:
value = html.unescape(value)
value = re.sub(r"<br\s*/?>", " ", value, flags=re.I)
value = re.sub(r"<[^>]+>", " ", value)
value = value.replace("&nbsp;", " ")
return re.sub(r"\s+", " ", value).strip()
def as_drawio_label(value: str) -> str:
return value.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
def replace_inner_simple_html(raw: str, new_value: str) -> str:
replacement = as_drawio_label(new_value)
match = re.fullmatch(
r"(?P<prefix><(?P<tag>b|i|u|font|span)\b[^>]*>)(?P<body>.*)(?P<suffix></(?P=tag)>)",
raw,
flags=re.I | re.S,
)
if match:
return f"{match.group('prefix')}{replacement}{match.group('suffix')}"
return replacement
def load_mapping(path: Path) -> tuple[dict[str, str], dict[str, str]]:
data: Any = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("mapping JSON must be an object")
if "ids" in data or "labels" in data:
ids = data.get("ids", {})
labels = data.get("labels", {})
if not isinstance(ids, dict) or not isinstance(labels, dict):
raise ValueError("'ids' and 'labels' must be objects when present")
else:
ids = {}
labels = data
id_map = {str(key): str(value) for key, value in ids.items()}
label_map = {clean_label(str(key)): str(value) for key, value in labels.items()}
return id_map, label_map
def relabel_tree(root: ET.Element, id_map: dict[str, str], label_map: dict[str, str]) -> list[Replacement]:
replacements: list[Replacement] = []
for elem in root.iter():
elem_id = elem.get("id") or ""
attr = next((name for name in LABEL_ATTRS if elem.get(name) is not None), None)
if attr is None:
continue
raw = elem.get(attr) or ""
matched_by = ""
new_value: str | None = None
if elem_id in id_map:
new_value = id_map[elem_id]
matched_by = f"id:{elem_id}"
else:
visible = clean_label(raw)
if visible in label_map:
new_value = label_map[visible]
matched_by = f"label:{visible}"
if new_value is None:
continue
updated = replace_inner_simple_html(raw, new_value)
if updated == raw:
continue
elem.set(attr, updated)
replacements.append(
Replacement(
element_id=elem_id,
attr=attr,
old=clean_label(raw),
new=clean_label(updated),
matched_by=matched_by,
)
)
return replacements
def relabel_file(source: Path, mapping: Path, destination: Path | None = None) -> list[Replacement]:
id_map, label_map = load_mapping(mapping)
tree = ET.parse(source)
replacements = relabel_tree(tree.getroot(), id_map, label_map)
ET.indent(tree, space=" ")
target = destination or source
tree.write(target, encoding="unicode", xml_declaration=False)
return replacements
def print_summary(replacements: Iterable[Replacement]) -> None:
items = list(replacements)
print(f"relabel: replaced {len(items)} label(s)", file=sys.stderr)
for item in items:
print(f" {item.matched_by}: {item.old!r} -> {item.new!r}", file=sys.stderr)
def main(argv: Iterable[str] | None = None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("drawio", type=Path)
ap.add_argument("mapping", type=Path, help="JSON replacement map")
ap.add_argument("-o", "--out", type=Path, help="write to a new file")
ap.add_argument("--write", action="store_true", help="modify the .drawio file in place")
args = ap.parse_args(list(argv) if argv is not None else None)
if args.out and args.write:
print("use either --out or --write, not both", file=sys.stderr)
return 2
if not args.drawio.exists():
print(f"{args.drawio}: file not found", file=sys.stderr)
return 2
if not args.mapping.exists():
print(f"{args.mapping}: mapping not found", file=sys.stderr)
return 2
destination = args.drawio if args.write else args.out
if destination is None:
print("pass --write or --out", file=sys.stderr)
return 2
try:
replacements = relabel_file(args.drawio, args.mapping, destination)
except (ET.ParseError, OSError, ValueError, json.JSONDecodeError) as exc:
print(f"relabel failed: {exc}", file=sys.stderr)
return 1
print_summary(replacements)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Render a `.drawio` file to PNG/SVG/PDF using the draw.io desktop CLI.
The structural fingerprint score in `compare.py` is necessary but not
sufficient — two diagrams can have similar fingerprints yet look very
different. Rendering both to PNG enables side-by-side visual review and
makes manual iteration in draw.io desktop fast and deliberate.
Why this script matters:
* The eval-corpus loop has plateaued at ~22/63 leave-one-out passes.
* The remaining gap is structural / geometric — it cannot be closed
by more LLM retries against an XML fingerprint.
* The realistic last-mile workflow is: scaffold → manual edit → render
→ side-by-side compare against the SAP reference → iterate.
Usage:
render.py my-diagram.drawio # PNG, same dir
render.py my-diagram.drawio -o /tmp/out.png
render.py --format svg --scale 1.5 my-diagram.drawio
render.py --transparent --border 20 my-diagram.drawio
render.py --batch <dir> --format png # render every .drawio in dir
Exit code:
0 — render succeeded
1 — render failed
2 — usage / draw.io CLI not found
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
DEFAULT_DRAWIO_PATHS = [
"/Applications/draw.io.app/Contents/MacOS/draw.io", # macOS
"/usr/bin/drawio", # Linux package
"/usr/local/bin/drawio",
"/snap/bin/drawio",
"/mnt/c/Program Files/draw.io/draw.io.exe", # WSL2
"C:\\Program Files\\draw.io\\draw.io.exe", # Windows
]
def find_drawio_cli() -> str | None:
"""Locate the draw.io desktop binary.
Honors $DRAWIO_CLI first (override), then $PATH, then the canonical
install paths on each platform. Returns None if nothing is found.
"""
env = os.environ.get("DRAWIO_CLI")
if env and Path(env).exists():
return env
which = shutil.which("drawio") or shutil.which("draw.io")
if which:
return which
for candidate in DEFAULT_DRAWIO_PATHS:
if Path(candidate).exists():
return candidate
return None
def render_one(
drawio_cli: str,
src: Path,
dest: Path,
fmt: str,
scale: float,
border: int,
transparent: bool,
quiet: bool,
) -> int:
"""Render a single .drawio file. Returns the draw.io CLI exit code.
draw.io CLI infers format from extension when -f is omitted, but we
pass -f explicitly to keep the output deterministic.
"""
args = [
drawio_cli,
"-x",
"-f", fmt,
"-o", str(dest),
"-s", str(scale),
"-b", str(border),
]
if transparent and fmt == "png":
args.append("-t")
args.append(str(src))
try:
proc = subprocess.run(
args,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=120,
)
except subprocess.TimeoutExpired:
if not quiet:
print(f"{src}: render timed out after 120s", file=sys.stderr)
return 1
if proc.returncode != 0 and not quiet:
sys.stderr.write(proc.stdout.decode(errors="replace"))
sys.stderr.write(proc.stderr.decode(errors="replace"))
return proc.returncode
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("source", type=Path, nargs="?")
ap.add_argument(
"-o", "--output",
type=Path,
help="output path (default: alongside source with the chosen extension)",
)
ap.add_argument("--format", default="png", choices=("png", "svg", "pdf", "jpg"))
ap.add_argument("--scale", type=float, default=1.0, help="render scale, 1.0 = native")
ap.add_argument("--border", type=int, default=10, help="border in px around the diagram")
ap.add_argument("--transparent", action="store_true", help="transparent background (PNG only)")
ap.add_argument("--batch", type=Path, help="render every .drawio file in this directory")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args()
cli = find_drawio_cli()
if not cli:
print(
"draw.io CLI not found. Install draw.io desktop and re-run, or set "
"$DRAWIO_CLI to the binary path.\n"
" macOS: /Applications/draw.io.app/Contents/MacOS/draw.io\n"
" Linux: apt/snap/yum install drawio (or download .deb/.rpm)\n"
" Windows: choco install drawio (or installer from drawio.com)",
file=sys.stderr,
)
return 2
targets: list[Path] = []
if args.batch:
if not args.batch.is_dir():
print(f"--batch: {args.batch} is not a directory", file=sys.stderr)
return 2
targets = sorted(args.batch.rglob("*.drawio"))
if not targets:
print(f"--batch: no .drawio files in {args.batch}", file=sys.stderr)
return 1
elif args.source:
if not args.source.exists():
print(f"{args.source}: not found", file=sys.stderr)
return 1
targets = [args.source]
else:
ap.print_usage(sys.stderr)
return 2
failures = 0
for src in targets:
if args.batch:
dest = src.with_suffix(f".{args.format}")
else:
dest = args.output or src.with_suffix(f".{args.format}")
dest.parent.mkdir(parents=True, exist_ok=True)
rc = render_one(
cli, src, dest,
fmt=args.format,
scale=args.scale,
border=args.border,
transparent=args.transparent,
quiet=args.quiet,
)
if rc != 0:
failures += 1
continue
if not args.quiet:
print(f"{src}{dest}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+959
View File
@@ -0,0 +1,959 @@
#!/usr/bin/env python3
"""Render a candidate + reference `.drawio` to PNG and produce a side-by-side
HTML review page with overlay-swipe, hover-zoom, and tabs for the different
comparison modes.
Why this matters:
The structural fingerprint score (`compare.py`) is necessary but
insufficient for the last 20% of diagram polish. Two diagrams with
similar fingerprints can look very different. The honest workflow is:
scaffold → manual edit → render → side-by-side compare → iterate.
This script collapses "render + compare + review" into one command so
the human iteration loop is fast.
The HTML supports four review modes:
1. Side-by-side: classic two-pane comparison.
2. Overlay slider: drag a slider to fade between reference and
candidate. Catches subtle position/size drifts.
3. Swipe (curtain): vertical dividing line you drag — left half
reference, right half candidate. Fastest way to spot zone-level
differences.
4. Difference: stacked images with subtraction blend mode (browser-
native), no Pillow dependency.
Usage:
render_compare.py reference.drawio candidate.drawio
→ writes review.html plus reference.png + candidate.png next to it
render_compare.py reference.drawio candidate.drawio \
--out-dir .cache/review/agentic-ai/ --open
render_compare.py reference.drawio candidate.drawio \
--scale 1.5 --transparent
Exit code:
0 — review HTML written
1 — render or compare failed
2 — usage / draw.io CLI not found
"""
from __future__ import annotations
import argparse
import html
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 render as _render # noqa: E402 drawio CLI wrapper
import compare as _compare # noqa: E402 fingerprint comparison
HTML_TEMPLATE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SAP Diagram Review __TITLE__</title>
<style>
:root {
color-scheme: light;
--bg: #f5f6f7;
--card: #ffffff;
--border: #d5dadd;
--primary: #0070f2;
--good: #188918;
--warn: #c35500;
--bad: #d20a0a;
--text: #1d2d3e;
--muted: #556b82;
--shadow: 0 2px 8px rgba(29, 45, 62, 0.06);
}
* { box-sizing: border-box; }
body {
font-family: Helvetica, Arial, sans-serif;
margin: 0;
background: var(--bg);
color: var(--text);
line-height: 1.45;
}
header {
padding: 16px 24px;
background: var(--card);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
header h1 {
margin: 0;
font-size: 17px;
font-weight: 600;
flex: 1;
min-width: 280px;
}
.badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--bg);
color: var(--muted);
border-radius: 4px;
margin-left: 8px;
}
.score-pill {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 18px;
background: var(--score-bg, var(--bg));
border: 1px solid var(--score-border, var(--border));
border-radius: 999px;
}
.score-pill .num {
font-size: 28px;
font-weight: 700;
color: var(--score-color, var(--text));
font-variant-numeric: tabular-nums;
}
.score-pill .lbl {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.actions {
display: flex;
gap: 8px;
}
.actions a, .actions button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font: inherit;
font-size: 13px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
border-radius: 6px;
text-decoration: none;
cursor: pointer;
transition: background 80ms;
}
.actions a:hover, .actions button:hover {
background: var(--bg);
}
.actions a.primary, .actions button.primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.actions a.primary:hover, .actions button.primary:hover {
filter: brightness(1.06);
}
nav.modes {
padding: 0 24px;
background: var(--card);
border-bottom: 1px solid var(--border);
display: flex;
gap: 4px;
}
nav.modes button {
background: transparent;
border: none;
padding: 12px 16px;
font-size: 13px;
font-weight: 500;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-family: inherit;
}
nav.modes button.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
nav.modes button:hover { color: var(--text); }
.stage {
padding: 16px 24px;
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
/* Mode: side-by-side */
.mode { display: none; }
.mode.active { display: block; }
.sbs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 1024px) {
.sbs { grid-template-columns: 1fr; }
}
.pane h2 {
margin: 0;
padding: 10px 14px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid var(--border);
background: #fafbfc;
display: flex;
justify-content: space-between;
align-items: center;
}
.pane h2 .who {
display: flex;
align-items: center;
gap: 8px;
}
.pane h2 .who::before {
content: "";
width: 8px; height: 8px; border-radius: 50%;
}
.pane h2.ref .who::before { background: var(--good); }
.pane h2.cand .who::before { background: var(--primary); }
.pane figure {
margin: 0;
padding: 12px;
background: #fff;
text-align: center;
}
.pane figure img {
max-width: 100%;
max-height: 78vh;
border: 1px solid var(--border);
cursor: zoom-in;
}
/* Mode: overlay slider */
.overlay-wrap {
padding: 14px;
text-align: center;
background: #fff;
}
.overlay-stage {
position: relative;
display: inline-block;
max-width: 100%;
}
.overlay-stage img {
display: block;
max-width: 100%;
max-height: 78vh;
border: 1px solid var(--border);
}
.overlay-stage img.cand {
position: absolute;
top: 0; left: 0;
pointer-events: none;
}
.overlay-controls {
padding: 14px 24px 18px;
display: flex;
align-items: center;
gap: 14px;
justify-content: center;
background: #fafbfc;
border-top: 1px solid var(--border);
}
.overlay-controls label {
font-size: 13px;
color: var(--muted);
}
.overlay-controls input[type=range] {
width: 280px;
}
.opacity-readout {
width: 56px;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text);
font-weight: 600;
}
/* Mode: swipe / curtain */
.swipe-wrap {
padding: 14px;
background: #fff;
text-align: center;
}
.swipe-stage {
position: relative;
display: inline-block;
max-width: 100%;
cursor: ew-resize;
user-select: none;
}
.swipe-stage img {
display: block;
max-width: 100%;
max-height: 78vh;
border: 1px solid var(--border);
}
.swipe-stage .clip {
position: absolute;
top: 0; left: 0;
bottom: 0;
overflow: hidden;
width: 50%;
border-right: 2px solid var(--primary);
pointer-events: none;
}
.swipe-stage .clip img {
max-width: none;
width: auto;
}
.swipe-handle {
position: absolute;
top: 50%; transform: translateY(-50%);
width: 32px; height: 32px;
background: var(--primary);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
cursor: ew-resize;
box-shadow: 0 2px 8px rgba(0, 112, 242, 0.4);
pointer-events: auto;
}
.swipe-labels {
display: flex;
justify-content: space-between;
padding: 8px 24px;
font-size: 11px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
background: #fafbfc;
border-top: 1px solid var(--border);
}
/* Mode: difference (CSS blend) */
.diff-wrap {
padding: 14px;
background: #1d2d3e;
text-align: center;
}
.diff-stage {
position: relative;
display: inline-block;
}
.diff-stage img {
max-width: 100%;
max-height: 78vh;
display: block;
}
.diff-stage .top {
position: absolute;
top: 0; left: 0;
mix-blend-mode: difference;
}
.diff-note {
padding: 12px 24px;
background: #1d2d3e;
color: #d5dadd;
font-size: 12px;
text-align: center;
border-top: 1px solid #354a5f;
}
/* Score breakdown */
.breakdown {
padding: 18px 24px 8px;
}
.breakdown h3 {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.breakdown table {
border-collapse: collapse;
width: 100%;
max-width: 920px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.breakdown th, .breakdown td {
padding: 9px 14px;
border-bottom: 1px solid var(--border);
font-size: 13px;
text-align: left;
}
.breakdown th {
background: #fafbfc;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 11px;
}
.breakdown td.score {
text-align: right;
font-variant-numeric: tabular-nums;
width: 80px;
font-weight: 600;
}
.breakdown td.bar-cell {
width: 280px;
}
.bar {
height: 7px;
background: #eaecee;
border-radius: 3px;
overflow: hidden;
}
.bar > span {
display: block;
height: 100%;
transition: width 200ms;
}
.bar > span.good { background: var(--good); }
.bar > span.fine { background: var(--primary); }
.bar > span.warn { background: var(--warn); }
.bar > span.bad { background: var(--bad); }
/* Suggestions and diffs */
.insights {
padding: 18px 24px 28px;
max-width: 1180px;
}
.insights h3 {
margin: 18px 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.insights ul {
margin: 0;
padding: 0;
list-style: none;
}
.insights li {
padding: 12px 14px;
margin-bottom: 8px;
background: var(--card);
border: 1px solid var(--border);
border-left: 3px solid var(--primary);
border-radius: 6px;
font-size: 13px;
}
.insights li.diff {
border-left-color: var(--warn);
}
.insights code {
background: #f5f6f7;
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
font-family: Menlo, Consolas, monospace;
}
.insights .empty {
color: var(--muted);
font-style: italic;
}
/* Lightbox */
.lightbox {
position: fixed;
inset: 0;
background: rgba(29, 45, 62, 0.85);
display: none;
align-items: center;
justify-content: center;
cursor: zoom-out;
z-index: 50;
}
.lightbox.active { display: flex; }
.lightbox img {
max-width: 95vw;
max-height: 95vh;
}
/* Meta footer */
.meta {
padding: 14px 24px 30px;
font-size: 12px;
color: var(--muted);
}
.meta p {
margin: 4px 0;
}
.meta code {
background: var(--card);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
}
</style>
</head>
<body>
<header>
<h1>SAP Diagram Review · __TITLE__ <span class="badge">__BADGE_LABEL__</span></h1>
<div class="score-pill" style="--score-color: __SCORE_COLOR__; --score-bg: __SCORE_BG__; --score-border: __SCORE_BORDER__">
<span class="num">__SCORE__</span>
<span class="lbl">structural fidelity / 100</span>
</div>
<div class="actions">
<a class="primary" href="__CAND_DRAWIO_URI__" title="Open the candidate in draw.io desktop">Open candidate in draw.io</a>
<a href="__REF_DRAWIO_URI__" title="Open the reference template in draw.io desktop">Open reference</a>
</div>
</header>
<nav class="modes" id="mode-nav">
<button data-mode="sbs" class="active">Side-by-side</button>
<button data-mode="overlay">Overlay slider</button>
<button data-mode="swipe">Swipe</button>
<button data-mode="diff">Difference</button>
</nav>
<div class="stage">
<!-- Side-by-side -->
<div class="mode active" id="m-sbs">
<div class="sbs">
<section class="panel pane">
<h2 class="ref"><span class="who">Reference (target)</span><span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:11px">__REF_NAME__</span></h2>
<figure><img src="__REF_IMG__" alt="reference" data-zoom></figure>
</section>
<section class="panel pane">
<h2 class="cand"><span class="who">Candidate (your diagram)</span><span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:11px">__CAND_NAME__</span></h2>
<figure><img src="__CAND_IMG__" alt="candidate" data-zoom></figure>
</section>
</div>
</div>
<!-- Overlay slider -->
<div class="mode" id="m-overlay">
<section class="panel">
<div class="overlay-wrap">
<div class="overlay-stage">
<img src="__REF_IMG__" alt="reference">
<img src="__CAND_IMG__" alt="candidate" class="cand" id="overlay-cand" style="opacity: 0.5">
</div>
</div>
<div class="overlay-controls">
<label>Reference</label>
<input type="range" min="0" max="100" value="50" id="overlay-slider">
<label>Candidate</label>
<span class="opacity-readout" id="overlay-readout">50%</span>
</div>
</section>
</div>
<!-- Swipe / curtain -->
<div class="mode" id="m-swipe">
<section class="panel">
<div class="swipe-wrap">
<div class="swipe-stage" id="swipe-stage">
<img src="__CAND_IMG__" alt="candidate" id="swipe-bg">
<div class="clip" id="swipe-clip">
<img src="__REF_IMG__" alt="reference" id="swipe-fg">
</div>
<div class="swipe-handle" id="swipe-handle">↔</div>
</div>
</div>
<div class="swipe-labels">
<span>Reference (left of line)</span>
<span>Candidate (right of line)</span>
</div>
</section>
</div>
<!-- Difference (CSS blend) -->
<div class="mode" id="m-diff">
<section class="panel">
<div class="diff-wrap">
<div class="diff-stage">
<img src="__REF_IMG__" alt="reference">
<img src="__CAND_IMG__" alt="candidate" class="top">
</div>
</div>
<p class="diff-note">Black areas = identical pixels. Bright areas = differences. Uses the browser's <code>mix-blend-mode: difference</code>.</p>
</section>
</div>
</div>
<section class="breakdown">
<h3>Score breakdown — what to fix in priority order</h3>
<table>
<thead><tr><th>Dimension</th><th class="score">Score</th><th class="bar-cell">Bar</th></tr></thead>
<tbody>
__BREAKDOWN_ROWS__
</tbody>
</table>
</section>
<section class="insights">
<h3>Actionable suggestions</h3>
<ul>
__ACTIONABLE_BLOCK__
</ul>
<h3>Notable structural diffs</h3>
__DIFFS_BLOCK__
</section>
<section class="meta">
<p><strong>Reference</strong> · <code>__REF_PATH__</code></p>
<p><strong>Candidate</strong> · <code>__CAND_PATH__</code></p>
<p>Generated <code>__TIMESTAMP__</code>. After editing the candidate in draw.io desktop, re-run <code>render_compare.py</code> to refresh this review.</p>
</section>
<div class="lightbox" id="lightbox"><img id="lightbox-img" alt="zoomed"></div>
<script>
// Mode switcher
document.getElementById('mode-nav').addEventListener('click', e => {
if (e.target.tagName !== 'BUTTON') return;
const target = e.target.dataset.mode;
document.querySelectorAll('#mode-nav button').forEach(b => b.classList.toggle('active', b.dataset.mode === target));
document.querySelectorAll('.mode').forEach(m => m.classList.toggle('active', m.id === 'm-' + target));
});
// Overlay slider
const overlaySlider = document.getElementById('overlay-slider');
const overlayCand = document.getElementById('overlay-cand');
const overlayReadout = document.getElementById('overlay-readout');
overlaySlider.addEventListener('input', () => {
const v = overlaySlider.value;
overlayCand.style.opacity = v / 100;
overlayReadout.textContent = v + '%';
});
// Swipe / curtain — keep reference image fully sized in the clip; resize on window/image load
const swipeStage = document.getElementById('swipe-stage');
const swipeClip = document.getElementById('swipe-clip');
const swipeFg = document.getElementById('swipe-fg');
const swipeBg = document.getElementById('swipe-bg');
const swipeHandle = document.getElementById('swipe-handle');
function syncSwipeFgSize() {
const r = swipeBg.getBoundingClientRect();
swipeFg.style.width = r.width + 'px';
swipeFg.style.height = r.height + 'px';
}
swipeBg.addEventListener('load', syncSwipeFgSize);
window.addEventListener('resize', syncSwipeFgSize);
let dragging = false;
function setSwipe(clientX) {
const r = swipeStage.getBoundingClientRect();
let pct = ((clientX - r.left) / r.width) * 100;
pct = Math.max(0, Math.min(100, pct));
swipeClip.style.width = pct + '%';
swipeHandle.style.left = pct + '%';
}
swipeStage.addEventListener('mousedown', e => { dragging = true; setSwipe(e.clientX); e.preventDefault(); });
window.addEventListener('mousemove', e => { if (dragging) setSwipe(e.clientX); });
window.addEventListener('mouseup', () => { dragging = false; });
swipeStage.addEventListener('touchstart', e => { dragging = true; setSwipe(e.touches[0].clientX); }, { passive: true });
window.addEventListener('touchmove', e => { if (dragging) setSwipe(e.touches[0].clientX); }, { passive: true });
window.addEventListener('touchend', () => { dragging = false; });
// Initialize handle position
window.addEventListener('load', () => {
syncSwipeFgSize();
const r = swipeStage.getBoundingClientRect();
swipeHandle.style.left = '50%';
});
// Lightbox click-to-zoom on side-by-side images
const lightbox = document.getElementById('lightbox');
const lightboxImg = document.getElementById('lightbox-img');
document.querySelectorAll('img[data-zoom]').forEach(img => {
img.addEventListener('click', () => {
lightboxImg.src = img.src;
lightbox.classList.add('active');
});
});
lightbox.addEventListener('click', () => lightbox.classList.remove('active'));
// Keyboard shortcuts: 1/2/3/4 to switch modes, Esc closes lightbox
document.addEventListener('keydown', e => {
if (e.key === 'Escape') lightbox.classList.remove('active');
const idx = ['1', '2', '3', '4'].indexOf(e.key);
if (idx >= 0) {
const modes = ['sbs', 'overlay', 'swipe', 'diff'];
document.querySelector(`#mode-nav button[data-mode=${modes[idx]}]`).click();
}
});
</script>
</body>
</html>
"""
def score_color(score: float) -> tuple[str, str, str]:
"""Return (color, bg, border) tuple for the score pill."""
if score >= 95:
return ("#188918", "#F5FAE5", "#C6E5C8")
if score >= 85:
return ("#0070F2", "#EBF8FF", "#B8DCFC")
if score >= 70:
return ("#C35500", "#FFF8D6", "#F2D8A6")
return ("#D20A0A", "#FFEAF4", "#F5C5C0")
def bar_class(pct: float) -> str:
if pct >= 95:
return "good"
if pct >= 80:
return "fine"
if pct >= 60:
return "warn"
return "bad"
def actionable_suggestions(breakdown: dict, ref_fp, cand_fp, diffs: list[str]) -> list[str]:
"""Map low-scoring fingerprint dimensions to concrete edit suggestions.
Each rule is "if dimension X is below threshold, suggest a specific
edit." This is the bridge from raw scoring to human-actionable advice.
"""
out: list[str] = []
if breakdown.get("page_bg", 1.0) < 1.0:
out.append(
"Set the canvas to white or transparent — remove "
f"<code>pageBackgroundColor=\"{cand_fp.page_background or '?'}\"</code>"
" from <code>&lt;mxGraphModel&gt;</code>. SAP diagrams are always on white."
)
if breakdown.get("canvas", 1.0) < 1.0:
out.append(
f"Resize canvas to <code>{ref_fp.canvas_w}×{ref_fp.canvas_h}</code> "
f"(currently <code>{cand_fp.canvas_w}×{cand_fp.canvas_h}</code>). "
"In draw.io: <em>Diagram → Page Setup → Custom</em>."
)
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"
out.append(
f"Zone count differs (ref={ref_fp.zones}, cand={cand_fp.zones}). "
f"{verb.capitalize()} {delta} rounded-corner area container(s) "
"(<code>arcSize=16</code>, <code>strokeWidth=1.5</code>)."
)
if breakdown.get("zone_depth", 1.0) < 1.0:
out.append(
f"Zone nesting depth differs (ref={ref_fp.zone_depth}, cand={cand_fp.zone_depth}). "
"Watch for cases where a focus zone (Joule, Identity Services) is nested inside BTP "
"when the SAP reference puts 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"
out.append(
f"Icon count differs (ref={ref_fp.icons}, cand={cand_fp.icons}). "
f"{verb.capitalize()} {delta} BTP service icon(s) using "
"<code>scripts/extract_icon.py \"&lt;service&gt;\" --x &lt;X&gt; --y &lt;Y&gt;</code>."
)
if breakdown.get("pill_vocab", 1.0) < 1.0:
if cand_fp.novelty_pill_count:
out.append(
f"Replace {cand_fp.novelty_pill_count} novelty pill verb(s). "
"SAP-canonical pill labels: <code>TRUST</code>, <code>Authenticate</code>, "
"<code>Authorization</code>, <code>A2A</code>, <code>MCP</code>, "
"<code>ORD</code>, <code>HTTPS</code>, <code>OData/REST</code>, "
"<code>SAML2/OIDC</code>, <code>SCIM</code>. Avoid "
"PROMPT/ROUTE/CONTEXT/DELEGATE/INVOKE/FETCH."
)
if breakdown.get("edge_palette", 1.0) < 0.6:
missing = sorted(set(ref_fp.edge_palette) - set(cand_fp.edge_palette))
if missing:
swatch = " ".join(
f'<span style="display:inline-block;width:14px;height:14px;background:{c};'
f'border-radius:3px;border:1px solid var(--border);vertical-align:middle"></span>'
f' <code>{c}</code>'
for c in missing[:6]
)
out.append(
f"Add SAP-mandated connector colors on edges: {swatch}. "
"trust=<code>#CC00DC</code> pink · auth=<code>#188918</code> green · "
"authorization=<code>#5D36FF</code> indigo · structural=<code>#475E75</code> slate."
)
if breakdown.get("label_tokens", 1.0) < 0.6:
out.append(
"Label-token Jaccard is low — visible text drifted from the reference. "
"Restore or rename service-card labels to match the SAP scenario vocabulary."
)
if breakdown.get("grid_snap", 1.0) < 0.9:
out.append(
"Geometry off the 10-px grid. Run "
"<code>autofix.py --write &lt;file&gt;</code> to snap automatically."
)
# Surface raw diffs we couldn't classify into specific advice
for d in diffs[:3]:
if not any(d in o for o in out):
out.append(html.escape(d))
if not out:
out.append("Looks structurally close. Use the <strong>Swipe</strong> or "
"<strong>Difference</strong> tabs above to spot subtle visual drifts.")
return out
def to_file_uri(path: Path) -> str:
"""Convert an absolute path to a file:// URI for browser links."""
p = path.resolve()
# macOS/Linux: prepend /// (file:///abs/path)
return "file://" + str(p)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("reference", type=Path)
ap.add_argument("candidate", type=Path)
ap.add_argument("--out-dir", type=Path, default=None,
help="output dir for the HTML and PNGs (default: alongside candidate)")
ap.add_argument("--scale", type=float, default=1.0)
ap.add_argument("--border", type=int, default=10)
ap.add_argument("--transparent", action="store_true")
ap.add_argument("--open", action="store_true", help="open the HTML in default browser")
args = ap.parse_args()
if not args.reference.exists():
print(f"reference {args.reference}: not found", file=sys.stderr)
return 1
if not args.candidate.exists():
print(f"candidate {args.candidate}: not found", file=sys.stderr)
return 1
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
out_dir = args.out_dir or args.candidate.parent
out_dir.mkdir(parents=True, exist_ok=True)
ref_png = out_dir / f"{args.reference.stem}.reference.png"
cand_png = out_dir / f"{args.candidate.stem}.candidate.png"
rc = _render.render_one(cli, args.reference, ref_png, "png", args.scale, args.border, args.transparent, quiet=False)
if rc != 0:
print(f"reference render failed (rc={rc})", file=sys.stderr)
return 1
rc = _render.render_one(cli, args.candidate, cand_png, "png", args.scale, args.border, args.transparent, quiet=False)
if rc != 0:
print(f"candidate render failed (rc={rc})", file=sys.stderr)
return 1
ref_fp = _compare.fingerprint(args.reference)
cand_fp = _compare.fingerprint(args.candidate)
result = _compare.compare(ref_fp, cand_fp)
breakdown_rows: list[str] = []
for k, v in sorted(result.breakdown.items(), key=lambda kv: kv[1]):
pct = v * 100
cls = bar_class(pct)
breakdown_rows.append(
f'<tr><td>{html.escape(k)}</td>'
f'<td class="score">{pct:.1f}</td>'
f'<td class="bar-cell"><div class="bar"><span class="{cls}" style="width: {pct:.1f}%"></span></div></td></tr>'
)
if result.diffs:
diffs_html = "<ul>"
for d in result.diffs:
diffs_html += f'<li class="diff">{html.escape(d)}</li>'
diffs_html += "</ul>"
else:
diffs_html = '<ul><li class="empty">No notable structural differences detected.</li></ul>'
actionable = actionable_suggestions(result.breakdown, ref_fp, cand_fp, result.diffs)
actionable_html = "\n".join(f" <li>{a}</li>" for a in actionable)
color, bg, border = score_color(result.score)
badge_label = "PASS ≥ 90" if result.score >= 90 else (
"Near-miss" if result.score >= 80 else (
"Below 80 — needs structural work" if result.score >= 60 else "Far from target"
)
)
placeholders = {
"TITLE": html.escape(args.candidate.stem),
"BADGE_LABEL": html.escape(badge_label),
"SCORE": f"{result.score:.1f}",
"SCORE_COLOR": color,
"SCORE_BG": bg,
"SCORE_BORDER": border,
"REF_NAME": html.escape(args.reference.name),
"CAND_NAME": html.escape(args.candidate.name),
"REF_IMG": html.escape(ref_png.name),
"CAND_IMG": html.escape(cand_png.name),
"REF_DRAWIO_URI": html.escape(to_file_uri(args.reference)),
"CAND_DRAWIO_URI": html.escape(to_file_uri(args.candidate)),
"REF_PATH": html.escape(str(args.reference)),
"CAND_PATH": html.escape(str(args.candidate)),
"TIMESTAMP": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
"BREAKDOWN_ROWS": "\n".join(breakdown_rows),
"DIFFS_BLOCK": diffs_html,
"ACTIONABLE_BLOCK": actionable_html,
}
html_out = HTML_TEMPLATE
for key, val in placeholders.items():
html_out = html_out.replace(f"__{key}__", val)
review_html = out_dir / "review.html"
review_html.write_text(html_out, encoding="utf-8")
summary = {
"reference": str(args.reference),
"candidate": str(args.candidate),
"score": result.score,
"breakdown": result.breakdown,
"diffs": result.diffs,
"ref_image": str(ref_png),
"cand_image": str(cand_png),
"review_html": str(review_html),
}
(out_dir / "review.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"score : {result.score:.1f}/100 ({badge_label})")
print(f"reference : {ref_png}")
print(f"candidate : {cand_png}")
print(f"review : {review_html}")
print(f" open with: open {review_html}")
if args.open:
try:
subprocess.run(["open", str(review_html)], check=False)
except FileNotFoundError:
pass
return 0
if __name__ == "__main__":
sys.exit(main())
+737
View File
@@ -0,0 +1,737 @@
#!/usr/bin/env python3
"""Render a SAP-style architecture diagram from a small semantic archetype.
This is the escape hatch for prompts where nearest-template copying is known to
hit a geometry ceiling. It deliberately supports only a few SAP BTP archetypes;
unknown prompts should still go through scaffold_diagram.py.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
THIS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(THIS_DIR))
try:
import extract_icon # type: ignore[import-not-found]
except Exception: # pragma: no cover - renderer still works without icons
extract_icon = None
try:
import extract_asset # type: ignore[import-not-found]
except Exception: # pragma: no cover - renderer still works without generic assets
extract_asset = None
BLUE = "#0070F2"
TEXT = "#1D2D3E"
MUTED = "#475E75"
AUTH_GREEN = "#188918"
INDIGO = "#5D36FF"
INDIGO_FILL = "#F1ECFF"
ZONE_FILL = "#EBF8FF"
NEUTRAL_FILL = "#F5F6F7"
WHITE = "#FFFFFF"
PILL_FILL = "#FCFCFC"
PAGE_W = 1169
PAGE_H = 827
@dataclass(frozen=True)
class Box:
id: str
label: str
x: int
y: int
w: int
h: int
icon: str | None = None
fill: str = WHITE
stroke: str = BLUE
@dataclass(frozen=True)
class Edge:
id: str
source: str
target: str
label: str = ""
stroke: str = MUTED
@dataclass(frozen=True)
class Pill:
id: str
label: str
x: int
y: int
w: int = 120
h: int = 24
@dataclass(frozen=True)
class DiagramPlan:
archetype: str
title: str
subtitle: str
width: int
height: int
boxes: list[Box]
edges: list[Edge]
pills: list[Pill]
zones: list[Box]
def norm(text: str) -> str:
return re.sub(r"\s+", " ", text.strip())
def words(text: str) -> set[str]:
return set(re.findall(r"[a-z0-9]+", text.lower()))
def snap(value: int | float) -> int:
return int(round(float(value) / 10.0) * 10)
def infer_archetype(description: str) -> str:
tokens = words(description)
joined = " ".join(tokens)
lower = description.lower()
if tokens & {"siem", "soar", "threat", "etd"} or "security operations" in description.lower():
return "security-operations"
if tokens & {"devops", "cicd", "pipeline", "transport"}:
return "devops"
cloud_connector = "cloud connector" in lower or "cloudconnector" in tokens or ({"cloud", "connector"} <= tokens)
on_premise = bool(tokens & {"onprem", "onpremise"}) or "on-premise" in lower or "on premise" in lower
backend_access = bool(tokens & {"s4", "s4hana", "destination", "connectivity", "vscode", "arc", "arc1"})
if cloud_connector or (on_premise and backend_access):
return "on-prem-connectivity"
if tokens & {"privatelink", "private"}:
return "private-connectivity"
if "event mesh" in lower or tokens & {"eventmesh", "kafka"}:
return "integration-flow"
data_integration = bool(tokens & {"databricks", "snowflake", "lakehouse", "warehouse", "datasphere"})
if data_integration or ("data integration" in lower and "hyperscaler" in lower):
return "data-integration"
if tokens & {"cap", "fiori", "hana", "html5", "approuter"}:
return "btp-application"
if tokens & {"a2a", "b2b", "b2g", "api", "integration", "eventmesh"}:
return "integration-flow"
if tokens & {"joule", "agentic", "agents", "mcp", "llm"} or "ai agent" in joined:
return "ai-agent"
return "generic-btp"
def title_from(description: str, archetype: str) -> str:
clean = norm(description)
if len(clean) <= 86:
return clean
defaults = {
"security-operations": "Security Operations with SAP ETD and SOAR",
"devops": "DevOps on SAP BTP",
"on-prem-connectivity": "On-Premise Connectivity on SAP BTP",
"private-connectivity": "Private Connectivity on SAP BTP",
"btp-application": "SAP BTP Application Architecture",
"data-integration": "Data Integration on SAP BTP",
"integration-flow": "Integration Flow on SAP BTP",
"ai-agent": "AI Agents on SAP BTP",
}
return defaults.get(archetype, "SAP BTP Architecture")
def security_operations(description: str) -> DiagramPlan:
title = title_from(description, "security-operations")
boxes = [
Box("s4", "SAP S/4HANA\nOn-Premise Solutions", 40, 145, 250, 72, "on-premise-sap"),
Box("rise", "RISE", 40, 235, 250, 72, None),
Box("cloud", "SAP Cloud\nSolutions", 40, 325, 250, 72, "cloud foundry"),
Box("btp", "SAP BTP", 40, 415, 250, 72, None),
Box("resources", "Cloud Enterprise Resources\n(Network Logs, EDR, IAM, etc)", 560, 40, 300, 70, None),
Box("etd", "SAP Domain Detection\n\nSAP Enterprise Threat Detection\n(Cloud Edition)", 440, 280, 240, 170, "audit log"),
Box("analytics", "Central Security\nAnalytics\n\nFortiSIEM", 800, 250, 220, 170, None, NEUTRAL_FILL, MUTED),
Box("response", "Security Orchestration\n& Response\n\nFortiSOAR", 800, 520, 220, 170, None, NEUTRAL_FILL, MUTED),
Box("notification", "Notification System", 720, 725, 170, 50, None, WHITE, MUTED),
Box("itsm", "ITSM System", 930, 725, 150, 50, None, WHITE, MUTED),
]
edges = [
Edge("e1", "s4", "etd"),
Edge("e2", "rise", "etd"),
Edge("e3", "cloud", "etd"),
Edge("e4", "btp", "etd"),
Edge("e5", "resources", "analytics"),
Edge("e6", "etd", "analytics"),
Edge("e7", "analytics", "response"),
Edge("e8", "response", "notification"),
Edge("e9", "response", "itsm"),
]
pills = [
Pill("p1", "Security Logs", 325, 335, 110, 24),
Pill("p2", "Alerts, findings &\nenriched events", 680, 335, 135, 36),
Pill("p3", "Correlated\nIncidents", 830, 455, 100, 38),
Pill("p4", "Status & closure\nupdates", 930, 455, 110, 38),
Pill("p5", "Notification", 740, 695, 125, 24),
Pill("p6", "Open Ticket", 940, 695, 125, 24),
]
return DiagramPlan(
"security-operations",
title,
"L2 event-to-response flow for SAP and enterprise security signals",
1100,
850,
boxes,
edges,
pills,
[],
)
def devops(description: str) -> DiagramPlan:
title = title_from(description, "devops")
zones = [
Box("z-source", "Source and Build", 60, 130, 325, 515, None, ZONE_FILL, BLUE),
Box("z-transport", "Transport and Release", 420, 130, 300, 515, None, ZONE_FILL, BLUE),
Box("z-runtime", "SAP BTP Runtime", 755, 130, 340, 515, None, ZONE_FILL, BLUE),
]
boxes = [
Box("dev", "Developer", 115, 210, 165, 62, None),
Box("git", "Git Repository", 115, 330, 165, 62, None),
Box("cicd", "SAP Continuous\nIntegration and Delivery", 80, 455, 250, 82, "continuous integration and delivery"),
Box("ctms", "SAP Cloud Transport\nManagement", 465, 300, 210, 82, "cloud transport management"),
Box("alm", "SAP Cloud ALM", 465, 465, 210, 72, None),
Box("cf", "Cloud Foundry\nRuntime", 815, 215, 200, 72, "cloud foundry"),
Box("kyma", "Kyma Runtime", 815, 350, 200, 72, None),
Box("abap", "ABAP Environment", 815, 485, 200, 72, "abap environment"),
]
edges = [
Edge("e1", "dev", "git"),
Edge("e2", "git", "cicd"),
Edge("e3", "cicd", "ctms"),
Edge("e4", "ctms", "cf"),
Edge("e5", "ctms", "kyma"),
Edge("e6", "ctms", "abap"),
Edge("e7", "alm", "ctms"),
]
pills = [
Pill("p1", "Commit", 155, 292, 82, 22),
Pill("p2", "Build & Test", 145, 418, 105, 22),
Pill("p3", "Release", 350, 420, 82, 22),
Pill("p4", "Deploy", 720, 245, 82, 22),
Pill("p5", "Deploy", 720, 380, 82, 22),
Pill("p6", "Deploy", 720, 515, 82, 22),
]
return DiagramPlan("devops", title, "L2 pipeline from source to SAP BTP runtimes", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def private_connectivity(description: str) -> DiagramPlan:
title = title_from(description, "private-connectivity")
zones = [
Box("z-btp", "SAP BTP", 170, 100, 700, 570, None, ZONE_FILL, BLUE),
Box("z-network", "Private Network / Hyperscaler", 905, 170, 225, 360, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("client", "Application Clients", 30, 325, 140, 62, None),
Box("app", "Extension Application\n(CAP / HTML5)", 245, 280, 225, 92, "cloud application programming"),
Box("dest", "SAP Destination\nservice", 540, 280, 155, 72, "destination"),
Box("identity", "SAP Cloud Identity\nServices", 335, 565, 250, 72, None),
Box("plink", "SAP Private Link\nservice", 710, 280, 150, 72, None),
Box("provider", "Provider Service\nor SAP workload", 945, 280, 160, 92, None),
]
edges = [
Edge("e1", "client", "app"),
Edge("e2", "app", "dest"),
Edge("e3", "dest", "plink"),
Edge("e4", "plink", "provider"),
Edge("e5", "identity", "app", stroke=AUTH_GREEN),
]
pills = [
Pill("p1", "HTTPS", 178, 345, 70, 22),
Pill("p2", "REST/OData", 452, 250, 95, 22),
Pill("p3", "Private Link", 855, 245, 105, 22),
Pill("p4", "Authenticate", 455, 505, 115, 22),
]
return DiagramPlan("private-connectivity", title, "L2 private connectivity pattern for SAP BTP", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def on_prem_connectivity(description: str) -> DiagramPlan:
title = title_from(description, "on-prem-connectivity")
zones = [
Box("z-dev", "Developer Workstation", 40, 220, 215, 240, None, NEUTRAL_FILL, MUTED),
Box("z-btp", "SAP BTP - Cloud Foundry", 300, 120, 520, 500, None, ZONE_FILL, BLUE),
Box("z-onprem", "Customer On-Premise Network", 875, 190, 250, 340, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("vscode", "Visual Studio\nCode", 75, 300, 150, 70, None),
Box("arc1", "ARC-1 Application\nCloud Foundry Runtime", 365, 235, 210, 90, "cloud foundry"),
Box("dest", "SAP Destination\nservice", 615, 235, 165, 72, "destination"),
Box("conn", "SAP Connectivity\nservice", 615, 385, 165, 72, "connectivity"),
Box("identity", "SAP Cloud Identity\nServices / XSUAA", 405, 535, 255, 72, "xsuaa"),
Box("cloudconn", "SAP Cloud\nConnector", 930, 235, 160, 72, "cloud connector"),
Box("s4", "SAP S/4HANA\nOn-Premise", 930, 400, 160, 72, "on-premise-sap"),
]
edges = [
Edge("e1", "vscode", "arc1"),
Edge("e2", "arc1", "dest"),
Edge("e3", "dest", "conn"),
Edge("e4", "conn", "cloudconn"),
Edge("e5", "cloudconn", "s4"),
Edge("e6", "identity", "arc1", stroke=AUTH_GREEN),
]
pills = [
Pill("p1", "HTTPS", 250, 325, 70, 22),
Pill("p2", "Destination", 545, 205, 100, 22),
Pill("p3", "Connectivity", 645, 335, 105, 22),
Pill("p4", "OData/REST", 955, 355, 105, 22),
Pill("p5", "Authenticate", 505, 495, 115, 22),
]
return DiagramPlan(
"on-prem-connectivity",
title,
"L2 developer-to-on-premise flow through SAP BTP Cloud Foundry",
PAGE_W,
PAGE_H,
boxes,
edges,
pills,
zones,
)
def btp_application(description: str) -> DiagramPlan:
title = title_from(description, "btp-application")
zones = [
Box("z-user", "Users and Channels", 55, 220, 240, 250, None, NEUTRAL_FILL, MUTED),
Box("z-btp", "SAP BTP", 350, 120, 720, 500, None, ZONE_FILL, BLUE),
]
boxes = [
Box("user", "Business User", 95, 290, 160, 62, None),
Box("fiori", "SAP Fiori /\nApp Router", 430, 205, 185, 72, None),
Box("cap", "CAP Service", 430, 350, 185, 72, "cloud application programming"),
Box("hana", "SAP HANA Cloud", 750, 350, 190, 72, "hana cloud"),
Box("identity", "SAP Cloud Identity\nServices / XSUAA", 500, 535, 255, 72, "xsuaa"),
]
edges = [
Edge("e1", "user", "fiori"),
Edge("e2", "fiori", "cap"),
Edge("e3", "cap", "hana"),
Edge("e4", "identity", "cap", stroke=AUTH_GREEN),
]
pills = [
Pill("p1", "HTTPS", 300, 235, 70, 22),
Pill("p2", "OData/REST", 475, 305, 105, 22),
Pill("p3", "SQL", 635, 375, 70, 22),
Pill("p4", "Authenticate", 560, 495, 115, 22),
]
return DiagramPlan("btp-application", title, "L2 application pattern with SAP BTP runtime and data service", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def data_integration(description: str) -> DiagramPlan:
title = title_from(description, "data-integration")
zones = [
Box("z-sources", "Enterprise Data Sources", 55, 170, 250, 410, None, NEUTRAL_FILL, MUTED),
Box("z-btp", "SAP BTP - Data Integration", 360, 120, 430, 510, None, ZONE_FILL, BLUE),
Box("z-platform", "Hyperscaler Data Platform", 850, 170, 260, 410, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("s4", "SAP S/4HANA", 95, 255, 170, 62, "on-premise-sap"),
Box("hana", "SAP HANA Cloud", 95, 395, 170, 62, "hana cloud"),
Box("datasphere", "SAP Datasphere", 470, 225, 210, 72, "sap datasphere"),
Box("integration", "Data Integration\nFlow", 470, 385, 210, 72, "integration suite"),
Box("databricks", "Databricks /\nLakehouse", 900, 300, 170, 82, None),
]
edges = [
Edge("e1", "s4", "datasphere"),
Edge("e2", "hana", "integration"),
Edge("e3", "datasphere", "databricks"),
Edge("e4", "integration", "databricks"),
]
pills = [
Pill("p1", "Data Federation", 305, 260, 120, 22),
Pill("p2", "Data Sync", 305, 410, 95, 22),
Pill("p3", "HTTPS", 785, 285, 70, 22),
Pill("p4", "Metadata", 785, 410, 85, 22),
]
return DiagramPlan("data-integration", title, "L2 data integration pattern between SAP and hyperscaler platforms", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def integration_flow(description: str) -> DiagramPlan:
title = title_from(description, "integration-flow")
zones = [
Box("z-senders", "Sender Systems", 55, 150, 255, 470, None, NEUTRAL_FILL, MUTED),
Box("z-btp", "SAP BTP - Integration Suite", 360, 110, 430, 550, None, ZONE_FILL, BLUE),
Box("z-receivers", "Receiver Systems", 850, 150, 255, 470, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("sender1", "SAP S/4HANA", 95, 250, 170, 62, "on-premise-sap"),
Box("sender2", "Third-party\nApplication", 95, 390, 170, 72, None),
Box("cpi", "Cloud Integration", 470, 230, 210, 72, "integration suite"),
Box("api", "API Management", 470, 370, 210, 72, None),
Box("event", "Event Mesh", 470, 510, 210, 72, "event mesh"),
Box("receiver1", "SAP Cloud\nSolution", 895, 260, 170, 72, None),
Box("receiver2", "Partner / B2B\nSystem", 895, 430, 170, 72, None),
]
edges = [
Edge("e1", "sender1", "cpi"),
Edge("e2", "sender2", "api"),
Edge("e3", "cpi", "receiver1"),
Edge("e4", "api", "receiver2"),
Edge("e5", "event", "receiver1"),
]
pills = [
Pill("p1", "OData/REST", 310, 260, 105, 22),
Pill("p2", "API", 315, 400, 70, 22),
Pill("p3", "Events", 720, 530, 82, 22),
]
return DiagramPlan("integration-flow", title, "L2 integration pattern with SAP Integration Suite", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def ai_agent(description: str) -> DiagramPlan:
title = title_from(description, "ai-agent")
zones = [
Box("z-user", "Users and Channels", 45, 155, 220, 430, None, NEUTRAL_FILL, MUTED),
Box("z-joule", "SAP Joule", 315, 120, 455, 210, None, INDIGO_FILL, INDIGO),
Box("z-btp", "SAP BTP", 315, 370, 455, 290, None, ZONE_FILL, BLUE),
Box("z-sap", "SAP Cloud and Enterprise Systems", 835, 155, 280, 430, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("user", "Business User", 80, 265, 150, 62, None),
Box("joule", "SAP Joule", 455, 220, 180, 72, "joule"),
Box("agent", "AI Agent /\nOrchestrator", 385, 420, 180, 82, None),
Box("mcp", "MCP / Tool\nGateway", 575, 420, 160, 82, None),
Box("identity", "SAP Cloud Identity\nServices", 400, 555, 250, 72, None),
Box("s4", "SAP S/4HANA", 880, 240, 190, 62, None),
Box("sf", "SAP SuccessFactors", 880, 355, 190, 62, None),
Box("ext", "Third-party APIs", 880, 470, 190, 62, None),
]
edges = [
Edge("e1", "user", "joule"),
Edge("e2", "joule", "agent"),
Edge("e3", "agent", "mcp"),
Edge("e4", "mcp", "s4"),
Edge("e5", "mcp", "sf"),
Edge("e6", "mcp", "ext"),
Edge("e7", "identity", "agent", stroke=AUTH_GREEN),
]
pills = [
Pill("p1", "HTTPS", 270, 245, 80, 22),
Pill("p2", "MCP", 545, 390, 70, 22),
Pill("p3", "REST", 760, 492, 70, 22),
Pill("p4", "Authenticate", 545, 525, 115, 22),
]
return DiagramPlan("ai-agent", title, "L2 AI agent pattern with SAP BTP tools and identity", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def generic_btp(description: str) -> DiagramPlan:
return btp_application(description)
PLANNERS = {
"security-operations": security_operations,
"devops": devops,
"on-prem-connectivity": on_prem_connectivity,
"private-connectivity": private_connectivity,
"btp-application": btp_application,
"data-integration": data_integration,
"integration-flow": integration_flow,
"ai-agent": ai_agent,
"generic-btp": generic_btp,
}
def icon_style(query: str | None) -> str | None:
if not query:
return None
if extract_icon is not None:
try:
index = extract_icon.load_index()
match = extract_icon.find(index, query)
except SystemExit:
match = None
except Exception:
match = None
if match:
return match[1].get("style")
if extract_asset is None:
return None
try:
asset_index = extract_asset.load_index()
match = extract_asset.find_asset(asset_index, query, None)
if not match:
return None
_, asset = match
if asset.get("kind") not in {"generic-icon", "sap-brand-name"}:
return None
library_entry = extract_asset.load_library_entry(asset)
except SystemExit:
return None
except Exception:
return None
data = library_entry.get("data")
if not data:
return None
return (
"shape=image;verticalLabelPosition=bottom;verticalAlign=top;aspect=fixed;"
f"imageAspect=0;image={data};"
)
def cell(parent: ET.Element, cell_id: str, value: str, style: str, x: int, y: int, w: int, h: int, *, parent_id: str = "1") -> ET.Element:
elem = ET.SubElement(
parent,
"mxCell",
{
"id": cell_id,
"value": value.replace("\n", "<br>"),
"style": style,
"vertex": "1",
"parent": parent_id,
},
)
ET.SubElement(elem, "mxGeometry", {"x": str(snap(x)), "y": str(snap(y)), "width": str(snap(w)), "height": str(snap(h)), "as": "geometry"})
return elem
def edge(parent: ET.Element, edge_id: str, value: str, source: Box, target: Box, stroke: str = MUTED) -> ET.Element:
source_cx = source.x + source.w / 2
source_cy = source.y + source.h / 2
target_cx = target.x + target.w / 2
target_cy = target.y + target.h / 2
dx = target_cx - source_cx
dy = target_cy - source_cy
if abs(dx) >= abs(dy):
if dx >= 0:
exit_x, exit_y, entry_x, entry_y = 1, 0.5, 0, 0.5
else:
exit_x, exit_y, entry_x, entry_y = 0, 0.5, 1, 0.5
else:
if dy >= 0:
exit_x, exit_y, entry_x, entry_y = 0.5, 1, 0.5, 0
else:
exit_x, exit_y, entry_x, entry_y = 0.5, 0, 0.5, 1
style = (
"edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;"
f"strokeColor={stroke};strokeWidth=1.5;labelBackgroundColor=default;"
"fontFamily=Helvetica;fontSize=11;fontColor=#1D2D3E;"
f"exitX={exit_x};exitY={exit_y};exitDx=0;exitDy=0;"
f"entryX={entry_x};entryY={entry_y};entryDx=0;entryDy=0;"
)
elem = ET.SubElement(
parent,
"mxCell",
{
"id": edge_id,
"value": value,
"style": style,
"edge": "1",
"parent": "1",
"source": source.id,
"target": target.id,
},
)
ET.SubElement(elem, "mxGeometry", {"relative": "1", "as": "geometry"})
return elem
def render_legend(root: ET.Element, plan: DiagramPlan) -> None:
"""Add the compact L2 legend required by SAP solution-diagram guidance."""
x = max(300, plan.width - 560)
y = plan.height - 70
title_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
f"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=10;fontStyle=1;fontColor={MUTED};"
)
text_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
f"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=9;fontColor={MUTED};"
)
cell(root, "legend-title", "Legend", title_style, x, y, 55, 16)
items = [
("legend-service", "Service", BLUE, ZONE_FILL, 65),
("legend-external", "External", MUTED, NEUTRAL_FILL, 75),
("legend-auth", "Auth flow", AUTH_GREEN, "#F5FAE5", 75),
("legend-step", "Flow step", MUTED, PILL_FILL, 75),
]
cursor = x + 60
for item_id, label, stroke, fill, width in items:
swatch_style = (
"rounded=1;whiteSpace=wrap;html=1;absoluteArcSize=1;arcSize=6;"
f"strokeColor={stroke};fillColor={fill};strokeWidth=1;"
)
cell(root, f"{item_id}-swatch", "", swatch_style, cursor, y + 3, 14, 10)
cell(root, f"{item_id}-label", label, text_style, cursor + 18, y, width, 16)
cursor += width + 28
def render(plan: DiagramPlan, out: Path) -> None:
mxfile = ET.Element("mxfile")
diagram = ET.SubElement(mxfile, "diagram", {"id": "sap-semantic", "name": plan.title[:80]})
model = ET.SubElement(
diagram,
"mxGraphModel",
{
"dx": "1200",
"dy": "900",
"grid": "1",
"gridSize": "10",
"guides": "1",
"tooltips": "1",
"connect": "1",
"arrows": "1",
"fold": "1",
"page": "1",
"pageScale": "1",
"pageWidth": str(plan.width),
"pageHeight": str(plan.height),
"math": "0",
"shadow": "0",
},
)
root = ET.SubElement(model, "root")
ET.SubElement(root, "mxCell", {"id": "0"})
ET.SubElement(root, "mxCell", {"id": "1", "parent": "0"})
title_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
f"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=22;fontStyle=1;fontColor={BLUE};"
)
subtitle_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=12;fontColor=#475E75;"
)
cell(root, "title", plan.title, title_style, 30, 20, plan.width - 60, 40)
cell(root, "subtitle", plan.subtitle, subtitle_style, 30, 60, plan.width - 60, 20)
zone_style = (
"rounded=1;whiteSpace=wrap;html=1;absoluteArcSize=1;arcSize=16;"
"strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontStyle=1;"
"align=left;verticalAlign=top;spacing=12;"
)
for zone in plan.zones:
style = f"{zone_style}strokeColor={zone.stroke};fillColor={zone.fill};fontColor={TEXT};"
cell(root, zone.id, zone.label, style, zone.x, zone.y, zone.w, zone.h)
def containing_zone(box: Box) -> Box | None:
for zone in plan.zones:
if (
box.x >= zone.x
and box.y >= zone.y
and box.x + box.w <= zone.x + zone.w
and box.y + box.h <= zone.y + zone.h
):
return zone
return None
card_style = (
"rounded=1;whiteSpace=wrap;html=1;absoluteArcSize=1;arcSize=12;"
"strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontStyle=1;"
"align=center;verticalAlign=middle;spacing=8;"
)
icon_style_base = "ellipse;whiteSpace=wrap;html=1;aspect=fixed;strokeColor=#D5DADD;fillColor=#F5F6F7;"
for box in plan.boxes:
style = f"{card_style}strokeColor={box.stroke};fillColor={box.fill};fontColor={TEXT};"
if box.icon is not None:
style += "spacingLeft=36;"
zone = containing_zone(box)
parent_id = zone.id if zone else "1"
x = box.x - zone.x if zone else box.x
y = box.y - zone.y if zone else box.y
cell(root, box.id, box.label, style, x, y, box.w, box.h, parent_id=parent_id)
icon = icon_style(box.icon)
if icon:
icon_id = f"{box.id}-icon"
icon_cell = ET.SubElement(
root,
"mxCell",
{
"id": icon_id,
"value": "",
"style": icon,
"vertex": "1",
"parent": box.id,
},
)
ET.SubElement(icon_cell, "mxGeometry", {"x": "10", "y": "20", "width": "30", "height": "30", "as": "geometry"})
elif box.icon is not None:
cell(root, f"{box.id}-icon", "", icon_style_base, 10, 20, 30, 30, parent_id=box.id)
boxes_by_id = {box.id: box for box in plan.boxes}
for e in plan.edges:
source = boxes_by_id.get(e.source)
target = boxes_by_id.get(e.target)
if source is None or target is None:
continue
edge(root, e.id, e.label, source, target, e.stroke)
pill_style = (
"rounded=1;whiteSpace=wrap;html=1;absoluteArcSize=1;arcSize=50;"
"strokeColor=#475E75;fillColor=#FCFCFC;strokeWidth=1;fontFamily=Helvetica;"
"fontSize=10;fontStyle=1;fontColor=#475E75;align=center;verticalAlign=middle;"
)
for pill in plan.pills:
cell(root, pill.id, pill.label, pill_style, pill.x, pill.y, pill.w, pill.h)
render_legend(root, plan)
footer_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=11;fontColor=#475E75;"
)
cell(root, "footer", "Diagram Level: 2", footer_style, 30, plan.height - 60, 180, 20)
tree = ET.ElementTree(mxfile)
ET.indent(tree, space=" ")
out.parent.mkdir(parents=True, exist_ok=True)
tree.write(out, encoding="unicode", xml_declaration=False)
def plan_for(description: str, archetype: str | None = None) -> DiagramPlan:
chosen = archetype or infer_archetype(description)
planner = PLANNERS.get(chosen)
if planner is None:
raise ValueError(f"unknown archetype {chosen!r}; choose one of {', '.join(sorted(PLANNERS))}")
return planner(description)
def main(argv: Iterable[str] | None = None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("description", nargs="*", help="diagram request; stdin if omitted")
ap.add_argument("-o", "--out", type=Path, required=True)
ap.add_argument("--archetype", choices=sorted(PLANNERS), help="override automatic archetype detection")
ap.add_argument("--json", action="store_true", help="print selected semantic plan summary")
args = ap.parse_args(list(argv) if argv is not None else None)
description = " ".join(args.description).strip() or sys.stdin.read().strip()
if not description:
print("description required", file=sys.stderr)
return 2
plan = plan_for(description, args.archetype)
render(plan, args.out)
if args.json:
print(
json.dumps(
{
"archetype": plan.archetype,
"title": plan.title,
"out": str(args.out),
"boxes": len(plan.boxes),
"edges": len(plan.edges),
"pills": len(plan.pills),
"zones": len(plan.zones),
},
indent=2,
)
)
else:
print(f"rendered {args.out} using {plan.archetype} archetype")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Scaffold a new SAP architecture diagram by copying the closest reference template.
This script enforces the single most important rule of the skill: never draw
from scratch — always start from a pristine SAP reference template.
It combines `select_reference.py` (rank candidates) with a copy step:
1. Rank bundled templates against the request text.
2. Pick the top match (or honor an explicit --template path).
3. Copy it to the destination, preserving canvas, zones, palette, fonts.
4. Optionally rename the diagram name and the title-band text.
After scaffolding, the LLM should make minimal label edits to fit the request,
then run autofix.py / validate.py / compare.py.
Usage:
scaffold_diagram.py "MCP client calling BTP via Cloud Connector" --out docs/diagram.drawio
scaffold_diagram.py "Agentic AI on BTP with Joule" --out docs/agentic-ai.drawio
scaffold_diagram.py --template ac_RA0029_AgenticAI_root.drawio --out docs/foo.drawio "..."
scaffold_diagram.py --top 5 --dry-run "Agentic AI on BTP"
Exit code:
0 — file scaffolded (or --dry-run printed candidates)
1 — error
2 — usage
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
THIS_DIR = Path(__file__).resolve().parent
SCRIPTS_DIR = THIS_DIR
ASSETS_DIR = THIS_DIR.parent / "assets" / "reference-examples"
# The external corpus cache lives outside the skill. In the original plugin
# repo that was 4 levels above scripts/; an installed skill may sit at any
# depth, so fall back to the current working directory (where this skill's
# docs say caches belong) instead of crashing on a shallow path.
try:
REPO_ROOT = THIS_DIR.parents[4]
except IndexError:
REPO_ROOT = Path.cwd()
_EXTERNAL_CANDIDATES = [
Path.cwd() / ".cache" / "external" / "sap-btp-reference-architectures",
Path.cwd() / ".cache" / "external" / "teched2023-XP286v",
REPO_ROOT / ".cache" / "external" / "sap-btp-reference-architectures",
REPO_ROOT / ".cache" / "external" / "teched2023-XP286v",
]
EXTERNAL_REFERENCE_ROOTS = list(dict.fromkeys(p.resolve() for p in _EXTERNAL_CANDIDATES))
sys.path.insert(0, str(SCRIPTS_DIR))
import select_reference # type: ignore[import-not-found]
def reference_pool(include_external: bool = False) -> list[Path]:
refs = sorted(ASSETS_DIR.rglob("*.drawio"))
if include_external:
for root in EXTERNAL_REFERENCE_ROOTS:
if root.exists():
refs.extend(sorted(root.rglob("*.drawio")))
return sorted(dict.fromkeys(refs))
def rank_candidates(query: str, top: int, *, include_external: bool = False) -> list[select_reference.Candidate]:
refs = reference_pool(include_external)
return sorted(
(select_reference.score(p, query) for p in refs),
key=lambda c: (-c.score, c.path),
)[:top]
def rename_diagram(path: Path, new_name: str) -> bool:
"""Update the first <diagram name="..."> attribute. Returns True on change."""
text = path.read_text(encoding="utf-8")
try:
root = ET.fromstring(text)
except ET.ParseError:
return False
diagram = root.find(".//diagram")
if diagram is None:
return False
diagram.set("name", new_name)
ET.register_namespace("", "")
path.write_text(ET.tostring(root, encoding="unicode"), encoding="utf-8")
return True
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("description", nargs="*", help="diagram request; stdin if omitted")
ap.add_argument(
"-o", "--out",
dest="destination",
type=Path,
help="path to the scaffolded .drawio file (omit with --dry-run)",
)
ap.add_argument("--template", help="explicit template filename (e.g. ac_RA0029_AgenticAI_root.drawio)")
ap.add_argument("--top", type=int, default=5, help="show this many ranked candidates")
ap.add_argument("--dry-run", action="store_true", help="don't copy; just print top candidates")
ap.add_argument("--diagram-name", help="rename the <diagram name=...> attribute after copy")
ap.add_argument(
"--include-external-sap-references",
action="store_true",
help="also rank cached official SAP reference architectures under .cache/external when present",
)
ap.add_argument("--json", action="store_true")
ap.add_argument("--force", action="store_true", help="overwrite destination if it exists")
args = ap.parse_args()
query = " ".join(args.description).strip() or sys.stdin.read().strip()
if not query and not args.template:
print("description or --template required", file=sys.stderr)
return 2
if not ASSETS_DIR.exists():
print(f"{ASSETS_DIR}: reference directory not found", file=sys.stderr)
return 1
chosen: Path | None = None
candidates: list[select_reference.Candidate] = []
if args.template:
pool = reference_pool(args.include_external_sap_references)
candidate_path = (ASSETS_DIR / args.template).resolve()
if not candidate_path.exists():
# Fallback: case-insensitive match by stem or filename
target = args.template.lower()
for p in pool:
if p.name.lower() == target or p.stem.lower() == target.removesuffix(".drawio"):
candidate_path = p
break
if not candidate_path.exists():
scope = "bundled and cached external references" if args.include_external_sap_references else str(ASSETS_DIR)
print(f"--template {args.template!r}: not found in {scope}", file=sys.stderr)
return 1
chosen = candidate_path
else:
candidates = rank_candidates(query, args.top, include_external=args.include_external_sap_references)
if not candidates:
print("no candidates found", file=sys.stderr)
return 1
chosen = Path(candidates[0].path)
if args.dry_run or args.destination is None:
if args.json:
payload = {
"query": query,
"chosen": str(chosen) if chosen else None,
"candidates": [
{"path": c.path, "score": c.score, "reasons": c.reasons[:3]}
for c in candidates
],
}
print(json.dumps(payload, indent=2))
else:
print(f"query : {query}")
print(f"chosen : {chosen}")
if candidates:
print(f"top {len(candidates)} candidates:")
for i, c in enumerate(candidates, 1):
print(f" {i}. {c.score:5.1f} {Path(c.path).name}")
for reason in c.reasons[:2]:
print(f" - {reason}")
return 0
if chosen is None:
print("no template chosen", file=sys.stderr)
return 1
dest = args.destination.resolve()
if dest.exists() and not args.force:
print(f"{dest}: already exists (use --force to overwrite)", file=sys.stderr)
return 1
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(chosen, dest)
renamed = False
if args.diagram_name:
renamed = rename_diagram(dest, args.diagram_name)
if args.json:
payload = {
"query": query,
"template": str(chosen),
"destination": str(dest),
"renamed_diagram": bool(renamed),
"candidates": [
{"path": c.path, "score": c.score, "reasons": c.reasons[:3]}
for c in candidates
],
}
print(json.dumps(payload, indent=2))
else:
print(f"scaffolded {dest} from {chosen.name}")
if args.diagram_name and not renamed:
print(f"warning: --diagram-name {args.diagram_name!r} did not match a <diagram> element")
# Print the SAP design recipe of the chosen template — the patterns
# the LLM (or human) should preserve when relabeling.
recipe = _load_recipe(chosen)
if recipe:
print()
print(f"📐 SAP design recipe of {chosen.name} — preserve these patterns when editing:")
struct = recipe.get("structure_summary", {})
if struct:
print(
f" structure : {struct.get('top_level_zones', 0)} top 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 recipe.get("icon_sizes"):
sizes = ", ".join(f"{n}×{s}" for s, n in list(recipe["icon_sizes"].items())[:4])
print(f" icon sizes: {sizes} (do NOT exceed 48×48 unless ref does)")
if recipe.get("pill_vocab"):
vocab = ", ".join(f"{p!r}" for p in recipe["pill_vocab"][:8])
print(f" pill vocab: {vocab}")
eq = 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"
)
top_zones = [z for z in (recipe.get("zones") or []) if z.get("parent_id") in (None, "1")]
if top_zones:
summary = "; ".join(
f"{(z.get('label') or '(unlabeled)').strip()[:30]} [{z.get('color_role', '?')}]"
for z in top_zones[:5]
)
print(f" top zones : {summary}")
if recipe.get("detected_patterns"):
print(f" patterns : {', '.join(recipe['detected_patterns'][:6])}")
if candidates:
print()
print("alternative templates (open one if the chosen one is the wrong family):")
for i, c in enumerate(candidates[: args.top], 1):
print(f" {i}. {c.score:5.1f} {Path(c.path).name}")
print()
print("Next steps:")
print(f" 1. Read the recipe above. Edit {dest.name} surgically: change labels and add/swap services, but keep canvas/zones/palette/pills exactly as the SAP template defines.")
print(f" 2. python3 {SCRIPTS_DIR}/autofix.py --write {dest}")
print(f" 3. python3 {SCRIPTS_DIR}/validate.py {dest}")
print(f" 4. python3 {SCRIPTS_DIR}/iterate.py {dest} ← shows score + visual feedback for nudge mode")
return 0
def _load_recipe(chosen: Path) -> dict | None:
"""Load the chosen template's deep design profile from the precomputed registry."""
registry_path = ASSETS_DIR / "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(chosen.name)
if __name__ == "__main__":
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Score one .drawio candidate against a corpus of SAP reference diagrams.
Use this after generating a diagram. The best match should usually be the
template you started from. If no reference scores high, the diagram probably
drifted away from SAP Architecture Center structure.
Usage:
score_corpus.py my-diagram.drawio
score_corpus.py --top 10 --min-score 90 my-diagram.drawio
score_corpus.py --references /path/to/SAP/architecture-center my-diagram.drawio
score_corpus.py --min-sap-like 90 semantic-diagram.drawio
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from compare import compare, fingerprint, sap_likeness
from validate import validate
@dataclass
class RankedScore:
reference: str
score: float
breakdown: dict[str, float]
diffs: list[str]
def default_reference_dir() -> Path:
return Path(__file__).resolve().parents[1] / "assets" / "reference-examples"
def collect_references(paths: list[Path]) -> list[Path]:
refs: list[Path] = []
for p in paths:
if p.is_dir():
refs.extend(sorted(p.rglob("*.drawio")))
elif p.suffix.lower() == ".drawio":
refs.append(p)
return refs
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("candidate", type=Path)
ap.add_argument(
"--references",
type=Path,
action="append",
default=None,
help="reference .drawio file or directory; can be passed multiple times",
)
ap.add_argument("--top", type=int, default=5)
ap.add_argument("--min-score", type=float, default=None, help="exit 1 if best score is below this value")
ap.add_argument("--min-sap-like", type=float, default=None, help="exit 1 if reference-free SAP-likeness is below this value")
ap.add_argument("--json", action="store_true")
ap.add_argument("--score", action="store_true", help="print only the best score")
ap.add_argument("--sap-score", action="store_true", help="print only the reference-free SAP-likeness score")
args = ap.parse_args()
if not args.candidate.exists():
print(f"{args.candidate}: candidate not found", file=sys.stderr)
return 2
reference_inputs = args.references or [default_reference_dir()]
refs = collect_references(reference_inputs)
if not refs:
print("no reference .drawio files found", file=sys.stderr)
return 2
candidate_fp = fingerprint(args.candidate)
validation_report = validate(args.candidate)
quality = sap_likeness(candidate_fp, validator_errors=len(validation_report.errors))
ranked: list[RankedScore] = []
for ref in refs:
result = compare(fingerprint(ref), candidate_fp)
ranked.append(RankedScore(str(ref), result.score, result.breakdown, result.diffs))
ranked.sort(key=lambda r: (-r.score, r.reference))
top = ranked[: args.top]
best = top[0].score if top else 0.0
if args.score:
print(f"{best:.1f}")
elif args.sap_score:
print(f"{quality.score:.1f}")
elif args.json:
print(json.dumps({"sap_likeness": asdict(quality), "ranked": [asdict(r) for r in top]}, indent=2))
else:
print(f"candidate : {args.candidate}")
print(f"references: {len(refs)}")
print(f"best : {best:.1f}/100 corpus similarity")
print(f"sap-like : {quality.score:.1f}/100 reference-free quality")
if quality.issues:
print(f"sap issues: {quality.issues[0]}")
for i, item in enumerate(top, 1):
print(f"{i}. {item.score:5.1f} {item.reference}")
if item.diffs:
print(f" - {item.diffs[0]}")
if args.min_score is not None and best < args.min_score:
return 1
if args.min_sap_like is not None and quality.score < args.min_sap_like:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+523
View File
@@ -0,0 +1,523 @@
#!/usr/bin/env python3
"""Rank SAP reference templates for a natural-language diagram request.
This is intentionally simple and dependency-free. The goal is not semantic
search; it is to stop the author from guessing which SAP template to start
from. The script scores filenames plus visible labels in each .drawio file,
adds scenario-family boosts, and prints the best candidates.
Usage:
select_reference.py "CAP app with XSUAA and HANA Cloud"
echo "Joule agent calls S/4HANA through MCP" | select_reference.py
select_reference.py --top 10 --json "Business Data Cloud with Databricks"
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass, field
from functools import lru_cache
from pathlib import Path
STOPWORDS = {
"a", "an", "and", "app", "apps", "arch", "architecture", "as", "at", "between",
"btp", "by", "cloud", "create", "diagram", "draw", "for", "from", "in",
"into", "is", "l0", "l1", "l2", "landscape", "make", "my", "of", "on", "or",
"ref", "reference", "sap", "show", "solution", "the", "to", "using",
"via", "with",
# Generic prompt framing words. Keeping these out avoids accidental matches
# such as "Architecture Center" -> Task Center.
"center", "convention", "conventions", "horizon", "icon", "icons",
"label", "labels", "palette", "preserve", "readable", "rhythm",
"semantic", "semantics", "style", "template", "visual", "zone",
"zones",
}
TOKEN_CANONICAL = {
"adminstrator": "administrator",
"admin": "administrator",
"plaforms": "platforms",
"provisoning": "provisioning",
"ressources": "resources",
"s": "s4hana",
"4hana": "s4hana",
}
SCENARIOS = [
{
"name": "identity-authentication",
"query": {"ias", "identity", "authentication", "authn", "oauth", "oidc", "saml", "single", "sign", "sso", "xsuaa", "jwt", "trust"},
"reference": {"identity", "authentication", "authn", "iam", "xsuaa", "ias", "joule_iam"},
"boost": 18,
},
{
"name": "identity-authorization",
"query": {"authorization", "authz", "role", "roles", "scope", "scopes", "permission", "permissions", "rbac"},
"reference": {"authorization", "authz", "iam", "identity"},
"boost": 18,
},
{
"name": "private-connectivity",
"query": {"private", "privatelink", "link", "connectivity", "connector", "cloudconnector", "scc", "onprem", "premise", "principal", "principalpropagation", "propagation", "odata"},
"reference": {"privatelink", "private", "connector", "cloudconnector", "connectivity", "odata", "e2b"},
"boost": 17,
},
{
"name": "agentic-ai-mcp",
"query": {"agent", "agents", "agentic", "mcp", "a2a", "tool", "tools", "joule", "copilot", "cline", "llm"},
"reference": {"agent", "agentic", "mcp", "a2a", "joule", "genai", "generative"},
"boost": 17,
},
{
"name": "generative-ai-rag",
"query": {"genai", "generative", "rag", "retrieval", "semantic", "embedding", "embeddings", "vector", "prompt"},
"reference": {"genai", "generative", "rag", "semantic", "agent2agent"},
"boost": 16,
},
{
"name": "business-data-cloud",
"query": {"bdc", "business", "data", "databricks", "snowflake", "hana", "datasphere", "analytics", "bw"},
"reference": {"bdc", "businessdatacloud", "databricks", "hyperscalerdata", "dataintegration"},
"boost": 15,
},
{
"name": "event-driven-integration",
"query": {"event", "events", "eventmesh", "eventing", "eda", "queue", "queues", "kafka", "message", "messages"},
"reference": {"eventdriven", "eda", "event", "integration", "e2b", "a2aintegration", "b2bintegration"},
"boost": 15,
},
{
"name": "resiliency",
"query": {"resiliency", "resilience", "multi", "region", "availability", "az", "failover", "load", "balancer", "disaster"},
"reference": {"resiliency", "multiregion", "multiaz", "loadbalancer"},
"boost": 14,
},
{
"name": "multitenant-saas-cap",
"query": {"cap", "saas", "tenant", "tenants", "multitenant", "multitenancy", "subscription"},
"reference": {"susaas", "cap", "multitenant"},
"boost": 14,
},
{
"name": "task-workflow-workzone",
"query": {"task", "tasks", "inbox", "workflow", "workzone", "work", "zone", "launchpad", "process", "automation", "spa"},
"reference": {"taskcenter", "buildworkzone", "buildprocessautomation"},
"boost": 14,
},
{
"name": "devops",
"query": {"devops", "cicd", "ci", "cd", "pipeline", "pipelines", "transport", "deploy", "deployment"},
"reference": {"devops"},
"boost": 20,
},
{
"name": "security-operations",
"query": {"siem", "soar", "threat", "detection", "audit", "security", "etd"},
"reference": {"siem", "soar", "etd"},
"boost": 20,
},
{
"name": "federated-ml",
"query": {"federated", "ml", "machine", "learning", "training", "model", "models", "aicore", "ai"},
"reference": {"federated", "ml", "machine", "learning", "aicore", "ai"},
"boost": 22,
},
{
"name": "edge-integration-cell",
"query": {"edge", "eic", "cell", "pipo", "pi", "po", "runtime", "migration"},
"reference": {"edge", "eic", "cell", "pipo", "integration"},
"boost": 20,
},
{
"name": "successfactors",
"query": {"successfactors", "hxm", "bizx", "employee", "recruiting", "module", "modules", "talent"},
"reference": {"successfactors", "hxm", "bizx", "recruiting"},
"boost": 22,
},
]
@dataclass
class Candidate:
path: str
score: float
reasons: list[str] = field(default_factory=list)
token_hits: list[str] = field(default_factory=list)
metadata_title: str | None = None
metadata_tags: list[str] = field(default_factory=list)
def default_reference_dir() -> Path:
return Path(__file__).resolve().parents[1] / "assets" / "reference-examples"
def metadata_path_for(reference_dir: Path) -> Path:
return reference_dir / "template-metadata.json"
@lru_cache(maxsize=8)
def load_metadata(reference_dir_text: str) -> dict:
path = metadata_path_for(Path(reference_dir_text))
if not path.exists():
return {"templates": {}}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"templates": {}}
def template_metadata(path: Path) -> dict:
metadata = load_metadata(str(path.parent.resolve()))
return metadata.get("templates", {}).get(path.name, {})
def metadata_search_text(path: Path, metadata: dict | None = None) -> str:
metadata = metadata if metadata is not None else template_metadata(path)
if not metadata:
return ""
parts: list[str] = []
for key in ("title", "summary", "family", "level", "domain"):
val = metadata.get(key)
if isinstance(val, str):
parts.append(val)
for key in ("aliases", "tags", "products", "fallback_templates"):
vals = metadata.get(key)
if isinstance(vals, list):
parts.extend(str(v) for v in vals)
return " ".join(parts)
def explicit_metadata_title(path: Path) -> str | None:
val = template_metadata(path).get("title")
return str(val) if val else None
def split_words(text: str) -> list[str]:
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
text = re.sub(r"([a-z])([A-Z])", r"\1 \2", text)
text = text.replace("_", " ").replace("-", " ").replace("/", " ")
return [t.lower() for t in re.findall(r"[A-Za-z0-9]+", text)]
def tokens(text: str) -> set[str]:
out: set[str] = set()
for t in split_words(text):
t = TOKEN_CANONICAL.get(t, t)
if len(t) >= 2 and t not in STOPWORDS:
out.add(t)
joined = "".join(split_words(text))
for compact in (
"xsuaa",
"privatelink",
"workzone",
"taskcenter",
"eventmesh",
"multiaz",
"multiregion",
"businessdatacloud",
"successfactors",
"cloudconnector",
"principalpropagation",
):
if compact in joined:
out.add(compact)
if "businessdatacloud" in out:
out.add("bdc")
if "aicore" in joined or {"ai", "core"} <= out:
out.add("aicore")
if "cloudconnector" in joined:
out.add("cloudconnector")
if "principalpropagation" in joined:
out.add("principalpropagation")
if "s4hana" in joined or "4hana" in out or {"s4", "hana"} <= out:
out.add("s4hana")
if {"ci", "cd"} <= out:
out.add("cicd")
if {"pi", "po"} <= out:
out.add("pipo")
if {"edge", "integration", "cell"} <= out:
out.add("eic")
return out
def drawio_text(path: Path) -> str:
raw = path.read_text(encoding="utf-8", errors="ignore")
parts = [path.stem]
try:
root = ET.fromstring(raw)
for elem in root.iter():
for attr in ("name", "label", "value"):
val = elem.get(attr)
if val:
parts.append(val)
except ET.ParseError:
parts.append(raw[:10000])
visible = html.unescape(" ".join(parts))
visible = re.sub(r"<br\s*/?>", " ", visible, flags=re.I)
visible = re.sub(r"<[^>]+>", " ", visible)
return visible
def phrase_hits(query: str, phrases: list[str]) -> list[str]:
query_clean = " ".join(split_words(query))
hits: list[str] = []
for phrase in phrases:
phrase_clean = " ".join(split_words(str(phrase)))
if len(phrase_clean) >= 4 and phrase_clean in query_clean:
hits.append(str(phrase))
return hits
def exact_stem_mentioned(path: Path, query: str) -> bool:
query_lower = query.lower()
if path.name.lower() in query_lower:
return True
stem_words = " ".join(split_words(path.stem))
query_words = " ".join(split_words(query))
return len(stem_words) >= 8 and stem_words in query_words
def primary_visual_fallback_mentioned(path: Path, query: str) -> bool:
match = re.search(r"Primary SAP visual fallback template:\s*([A-Za-z0-9_.-]+)", query, flags=re.I)
if not match:
return False
return match.group(1).strip().rstrip(".").lower() == path.name.lower()
def explicit_level(query: str) -> str | None:
m = re.search(r"\bL([012])\b", query, flags=re.I)
return f"l{m.group(1)}" if m else None
def explicit_family(query: str) -> str | None:
m = re.search(r"\bRA(\d{4})\b", query, flags=re.I)
return f"ra{m.group(1)}" if m else None
def score(path: Path, query: str) -> Candidate:
q_tokens = tokens(query)
metadata = template_metadata(path)
doc_text = drawio_text(path)
meta_text = metadata_search_text(path, metadata)
d_tokens = tokens(doc_text)
m_tokens = tokens(meta_text)
filename_tokens = tokens(path.stem)
combined_tokens = d_tokens | m_tokens
token_hits = sorted(q_tokens & combined_tokens)
value = len(token_hits) * 2.0
reasons: list[str] = []
if token_hits:
reasons.append("token overlap: " + ", ".join(token_hits[:8]))
filename_hits = sorted(q_tokens & filename_tokens)
if filename_hits:
value += len(filename_hits) * 4.0
reasons.append("filename match: " + ", ".join(filename_hits[:8]))
if exact_stem_mentioned(path, query):
value += 70
reasons.append("exact template filename mentioned (+70)")
if primary_visual_fallback_mentioned(path, query):
value += 90
reasons.append("primary visual fallback match (+90)")
meta_hits = sorted(q_tokens & m_tokens)
if meta_hits:
boost = min(36.0, len(meta_hits) * 4.0)
value += boost
reasons.append("metadata match: " + ", ".join(meta_hits[:8]) + f" (+{int(boost)})")
alias_hits = phrase_hits(query, metadata.get("aliases", []) if isinstance(metadata.get("aliases"), list) else [])
if alias_hits:
boost = min(30.0, 12.0 + (len(alias_hits) - 1) * 6.0)
value += boost
reasons.append("alias phrase match: " + ", ".join(alias_hits[:3]) + f" (+{int(boost)})")
title = metadata.get("title")
if isinstance(title, str) and phrase_hits(query, [title]):
value += 22
reasons.append("metadata title phrase match (+22)")
level = explicit_level(query)
family = explicit_family(query)
filename_lower = path.name.lower()
path_lower = str(path).lower()
metadata_family = str(metadata.get("family", "")).lower()
metadata_level = str(metadata.get("level", "")).lower()
if family:
if family in path_lower or family == metadata_family:
value += 24
reasons.append(f"explicit {family.upper()} family match (+24)")
elif re.search(r"\bra\d{4}\b", path_lower):
value -= 8
reasons.append("different reference family penalty (-8)")
else:
value -= 5
reasons.append("different reference source penalty (-5)")
if level:
if level in filename_lower or level == metadata_level:
value += 10
reasons.append(f"explicit {level.upper()} match")
elif re.search(r"_l[012]\b", filename_lower):
value -= 3
elif "_l2" in filename_lower:
value += 3
reasons.append("default L2 preference")
for scenario in SCENARIOS:
q_hit = q_tokens & scenario["query"]
r_hit = filename_tokens & scenario["reference"]
if not r_hit:
r_hit = m_tokens & scenario["reference"]
if q_hit and r_hit:
boost = float(scenario["boost"])
value += boost
reasons.append(f"{scenario['name']} boost (+{int(boost)})")
strong_query_tags = {
"devops",
"federated",
"ml",
"eic",
"pipo",
"siem",
"soar",
"successfactors",
"embodied",
"agentic",
} & q_tokens
if strong_query_tags and not (strong_query_tags & (filename_tokens | m_tokens)):
value -= 10
reasons.append("strong scenario mismatch penalty (-10)")
# MCP/A2A bonus: only fire when the query is *specifically* about agent-to-
# agent or MCP integration (not when it merely mentions an MCP gateway as
# one of many components in a broader Agentic AI scenario). The previous
# rule unconditionally awarded +20 to A2A_MCP whenever "mcp" appeared,
# incorrectly outranking AgenticAI_root for the canonical RA0029 prompt.
if q_tokens & {"mcp", "a2a"} and filename_tokens & {"mcp", "a2a"}:
# Strong agentic-AI / Joule signals indicate the user wants the
# umbrella RA0029 root template, not the A2A/MCP sub-scenario.
broader_agentic_signal = bool(
q_tokens & {"agentic"}
and (q_tokens & {"joule"} or len(q_tokens & {"agent", "agents", "ai"}) >= 2)
)
if not broader_agentic_signal:
value += 20
reasons.append("exact MCP/A2A filename match (+20)")
else:
value += 6
reasons.append("MCP/A2A filename match dampened (broader agentic-AI scenario) (+6)")
# Primary-template preference within a family: when the user query maps
# to a well-known family (e.g. agentic-ai) without a specific sub-scenario
# signal (no explicit "embodied", "procode", "joule studio", etc.), prefer
# the metadata-flagged "primary" template. This correctly routes the
# canonical "Agentic AI on SAP BTP" prompt to AgenticAI_root.
if metadata.get("primary"):
sub_signals = {
"embodied", "robotics", # → EmbodiedAIAgents
"procode", "developer", "code", "vscode", "ide", # → GenAI_ProCode
"studio", # → Joule_Studio variants
"ecosystem", # → JouleAgentsToolsEcosystem
}
# If query has no specific sub-scenario signal, give the primary template a meaningful boost
if not (q_tokens & sub_signals):
value += 14
reasons.append("metadata primary-template boost (+14)")
if q_tokens & {"xsuaa", "oauth", "oidc", "saml"} and (
{"authentication", "authn"} & filename_tokens or "cloud" in filename_lower and "identity" in filename_lower
):
value += 8
reasons.append("exact authentication filename match (+8)")
if q_tokens & {"bdc", "businessdatacloud", "aicore"} and filename_tokens & {"bdc", "businessdatacloud", "aicore"}:
value += 12
reasons.append("exact BDC / AI Core filename match (+12)")
if "aicore" in q_tokens and "aicore" in filename_tokens:
value += 10
reasons.append("exact AI Core filename match (+10)")
if {"agentic", "ai"} <= q_tokens:
root_agentic = path.name == "ac_RA0029_AgenticAI_root.drawio"
embodied_terms = {"embodied", "robotic", "robotics", "physical"}
root_signals = {
"gateway",
"orchestrator",
"capabilities",
"subaccount",
"businessdatacloud",
"successfactors",
"concur",
"s4hana",
"mcp",
} & q_tokens
if root_agentic and not (q_tokens & embodied_terms):
value += 24
reasons.append("generic Agentic AI root boost (+24)")
if len(root_signals) >= 2:
value += 12
reasons.append("Agentic AI root component match (+12)")
if ("embodied" in filename_tokens or "embodied" in m_tokens) and not (q_tokens & embodied_terms):
value -= 18
reasons.append("embodied-specific template penalty (-18)")
if "joule" in filename_tokens and "joule" not in q_tokens:
value -= 12
reasons.append("Joule-specific template penalty (-12)")
if metadata.get("generic") and strong_query_tags:
value -= 12
reasons.append("generic template penalty for specific scenario (-12)")
# Prefer canonical btp_ examples when equally relevant; otherwise prefer
# Architecture Center diagrams with richer scenario labels.
if path.name.startswith("btp_"):
value += 1.0
if not reasons:
reasons.append("weak lexical match; review manually")
return Candidate(
str(path),
round(value, 1),
reasons,
token_hits[:12],
metadata_title=str(title) if title else None,
metadata_tags=list(metadata.get("tags", []))[:12] if isinstance(metadata.get("tags"), list) else [],
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("description", nargs="*", help="diagram request; stdin is used if omitted")
ap.add_argument("--reference-dir", type=Path, default=default_reference_dir())
ap.add_argument("--top", type=int, default=5)
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
query = " ".join(args.description).strip() or sys.stdin.read().strip()
if not query:
print("description required", file=sys.stderr)
return 2
if not args.reference_dir.exists():
print(f"{args.reference_dir}: reference directory not found", file=sys.stderr)
return 2
refs = sorted(args.reference_dir.rglob("*.drawio"))
ranked = sorted((score(p, query) for p in refs), key=lambda c: (-c.score, c.path))[: args.top]
if args.json:
print(json.dumps([asdict(c) for c in ranked], indent=2))
return 0
print(f"query: {query}")
for i, cand in enumerate(ranked, 1):
print(f"{i}. {cand.score:5.1f} {cand.path}")
for reason in cand.reasons[:3]:
print(f" - {reason}")
return 0
if __name__ == "__main__":
sys.exit(main())
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Pre-render every bundled SAP reference template to PNG and emit an HTML
gallery so a human can browse the catalog and pick the right starting
template visually.
The selector ranks templates from a text prompt, but humans pick faster
when they can see a thumbnail. This is especially useful when the
selector is unsure or when the prompt is vague.
Usage:
template_browser.py
template_browser.py --out-dir .cache/template-browser/
template_browser.py --thumbs-only # skip HTML, just render PNGs
Output:
.cache/template-browser/<template>.png (one per template)
.cache/template-browser/index.html (clickable gallery)
Exit code:
0 — gallery built
1 — render failed for one or more templates
2 — usage / draw.io CLI not found
"""
from __future__ import annotations
import argparse
import html
import json
import sys
from pathlib import Path
THIS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(THIS_DIR))
import render as _render # noqa: E402
ASSETS_DIR = THIS_DIR.parent / "assets" / "reference-examples"
HTML_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SAP Reference Template Gallery</title>
<style>
body {{
font-family: Helvetica, Arial, sans-serif;
margin: 0;
background: #f5f6f7;
color: #1d2d3e;
}}
header {{
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #d5dadd;
}}
header h1 {{ margin: 0; font-size: 18px; }}
header p {{ margin: 4px 0 0; font-size: 13px; color: #556b82; }}
.filter {{
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #d5dadd;
}}
.filter input {{
width: 380px;
padding: 6px 10px;
border: 1px solid #d5dadd;
border-radius: 4px;
font-size: 13px;
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 16px;
padding: 16px 24px;
}}
.card {{
background: #fff;
border: 1px solid #d5dadd;
border-radius: 8px;
overflow: hidden;
}}
.card .meta {{
padding: 10px 14px;
border-bottom: 1px solid #d5dadd;
}}
.card .meta strong {{ font-size: 13px; }}
.card .meta .domain {{
display: inline-block;
margin-left: 6px;
padding: 2px 6px;
font-size: 11px;
background: #ebf8ff;
color: #0070f2;
border-radius: 4px;
}}
.card .meta .level {{
display: inline-block;
margin-left: 4px;
padding: 2px 6px;
font-size: 11px;
background: #f5f6f7;
color: #556b82;
border-radius: 4px;
}}
.card .meta .title {{
display: block;
margin-top: 4px;
color: #556b82;
font-size: 12px;
}}
.card figure {{
margin: 0;
background: #fff;
aspect-ratio: 1.4 / 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}}
.card figure img {{ width: 100%; height: 100%; object-fit: contain; }}
.card .actions {{
padding: 8px 14px;
border-top: 1px solid #d5dadd;
font-size: 12px;
color: #556b82;
}}
code {{ background: #f5f6f7; padding: 1px 5px; border-radius: 3px; font-size: 11px; }}
</style>
</head>
<body>
<header>
<h1>SAP Reference Template Gallery</h1>
<p>{count} bundled SAP templates · click a thumbnail to open the .drawio source · use <code>scaffold_diagram.py --template &lt;name&gt;</code> to start a new diagram from one</p>
</header>
<div class="filter">
<input type="text" id="filter" placeholder="filter by name, domain, family, level…" oninput="filterCards()">
</div>
<section class="grid" id="grid">
{cards}
</section>
<script>
function filterCards() {{
const q = document.getElementById('filter').value.toLowerCase();
for (const card of document.querySelectorAll('.card')) {{
const blob = card.dataset.blob;
card.style.display = blob.includes(q) ? '' : 'none';
}}
}}
</script>
</body>
</html>
"""
CARD_TEMPLATE = """<div class="card" data-blob="{blob}">
<figure><a href="{drawio_link}"><img src="{thumb}" alt="{name}" loading="lazy"></a></figure>
<div class="meta">
<strong>{name}</strong>
<span class="domain">{domain}</span>
<span class="level">{level}</span>
<span class="title">{title}</span>
</div>
<div class="actions">
<code>scaffold_diagram.py --template {name} --out my-diagram.drawio</code>
</div>
</div>"""
def load_metadata() -> dict:
md_path = ASSETS_DIR / "template-metadata.json"
if not md_path.exists():
return {"templates": {}}
return json.loads(md_path.read_text(encoding="utf-8"))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--out-dir", type=Path, default=Path(".cache/template-browser"))
ap.add_argument("--thumbs-only", action="store_true",
help="skip the HTML gallery; just render PNGs")
ap.add_argument("--scale", type=float, default=0.6,
help="render scale for thumbnails (smaller = faster)")
ap.add_argument("--force", action="store_true",
help="re-render existing PNGs (default: skip if PNG newer than .drawio)")
args = ap.parse_args()
cli = _render.find_drawio_cli()
if not cli:
print("draw.io CLI not found", file=sys.stderr)
return 2
out_dir = args.out_dir.resolve()
out_dir.mkdir(parents=True, exist_ok=True)
if not ASSETS_DIR.exists():
print(f"reference-examples directory missing: {ASSETS_DIR}", file=sys.stderr)
return 1
metadata = load_metadata().get("templates", {})
templates = sorted(ASSETS_DIR.glob("*.drawio"))
print(f"rendering {len(templates)} templates to {out_dir} ...", file=sys.stderr)
failures = 0
cards: list[str] = []
for src in templates:
thumb = out_dir / (src.stem + ".png")
if not thumb.exists() or args.force or thumb.stat().st_mtime < src.stat().st_mtime:
rc = _render.render_one(
cli, src, thumb,
fmt="png", scale=args.scale, border=10,
transparent=False, quiet=True,
)
if rc != 0:
failures += 1
print(f" failed: {src.name}", file=sys.stderr)
continue
print(f" rendered {src.name}", file=sys.stderr)
else:
print(f" cached {src.name}", file=sys.stderr)
meta = metadata.get(src.name, {})
name = src.name
title = meta.get("title", "")
domain = meta.get("domain", "")
level = (meta.get("level") or "").upper() or "L?"
family = meta.get("family", "")
aliases = " ".join(meta.get("aliases", []) if isinstance(meta.get("aliases"), list) else [])
tags = " ".join(meta.get("tags", []) if isinstance(meta.get("tags"), list) else [])
blob = " ".join([name, title, domain, family, aliases, tags]).lower()
# link the user back to the template file so they can open it directly
rel_drawio = Path("..") / "plugins" / "sap-architecture" / "skills" / "sap-architecture" / "assets" / "reference-examples" / src.name
cards.append(CARD_TEMPLATE.format(
blob=html.escape(blob),
drawio_link=html.escape(str(rel_drawio)),
thumb=html.escape(thumb.name),
name=html.escape(name),
title=html.escape(title) if title else "",
domain=html.escape(domain) if domain else "",
level=html.escape(level),
))
if not args.thumbs_only:
index = out_dir / "index.html"
index.write_text(
HTML_TEMPLATE.format(count=len(templates), cards="\n".join(cards)),
encoding="utf-8",
)
print(f"\ngallery: {index}", file=sys.stderr)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+924
View File
@@ -0,0 +1,924 @@
#!/usr/bin/env python3
"""Validate a SAP Architecture Center-style .drawio file.
Catches the bugs that make a diagram look unprofessional:
Structural
* malformed XML / missing mxGeometry / duplicate ids / comments
* root-cell skeleton (parentless root + layer first; content on ids 0/1)
* ';base64' inside image= style values (truncates -> blank icons)
* mxCell ids, including draw.io UserObject wrapper ids
Alignment
* x/y/width/height not integer multiples of 10 (grid-snap)
* edge source+target don't share a center axis (bent arrows)
* container children extending outside container bounds
* overlapping siblings (unintended stacking)
Text
* label text wider than the shape (overflow / clipping)
* edge label missing `labelBackgroundColor` (disappears in colored zone)
Style
* colors not in the SAP Horizon palette (warnings)
* missing `absoluteArcSize=1` when `arcSize` is set (percent-rendering bug)
* fontFamily not Helvetica
* strokeWidth outside {1, 1.5, 3, 4}
Exit code:
0 — clean (or only warnings)
1 — errors
2 — usage
Flags:
--strict warnings become errors
--json JSON report to stdout instead of human text
Run: python3 validate.py <file.drawio>
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
# ---------- SAP Horizon palette ----------------------------------------------
# Source of truth, in priority order:
# 1. SAP/btp-solution-diagrams/guideline/docs/btp_guideline/foundation.md
# and diagr_comp/areas.md (primary, semantic, accent)
# 2. Hex values observed in SAP/btp-solution-diagrams/assets/
# editable-diagram-examples/*.drawio (real-world variations)
# 3. SAP/architecture-center/docs/ref-arch/RA*/drawio/*.drawio
SAP_PALETTE = {
# --- foundation.md primary -------------------------------------------------
"#0070F2", "#EBF8FF", # SAP / BTP area: border, fill
"#475E75", "#F5F6F7", # Non-SAP area: border, fill
"#1D2D3E", # Title text
"#556B82", # Body text
# --- foundation.md semantic ------------------------------------------------
"#188918", "#F5FAE5", # Positive (authentication flows)
"#C35500", "#FFF8D6", # Critical
"#D20A0A", "#FFEAF4", # Negative
# --- foundation.md accent (sparingly) -------------------------------------
"#07838F", "#DAFDF5", # Teal
"#5D36FF", "#F1ECFF", # Indigo (authorization flows)
"#CC00DC", "#FFF0FA", # Pink (trust flows)
# --- preset (in drawio-config-all-in-one.json) ----------------------------
"#793802", # Brown — present in preset, no documented role
# --- darker text / accent variants used in real SAP diagrams --------------
"#002A86", "#00185A", "#0057D2", "#2395FF", # SAP blue variants observed in Architecture Center
"#266F3A", # darker positive green
"#470BED", # darker indigo (preset variant of #5D36FF)
"#7F00FF", # alt accent purple
# --- observed grey / neutral variations (real SAP files) ------------------
"#1A2733", # near-black navy used by some diagrams
"#354A5F", # mid grey
"#475E74", "#475F75", # off-by-one variants of #475E75
"#5B738B", # lighter grey
"#595959",
"#D5DADD", "#EAECEE", "#EDEDED", "#EDEFF0", "#EAF8FF", "#EDF8FF", "#ECF8FF",
"#D1EFFF", "#CCDDFF",
"#FCFCFC",
# --- additional colors observed in the bundled 71 reference templates -----
# These are accepted to avoid flagging SAP's own published diagrams as
# off-palette. New generated diagrams should still prefer the documented
# Horizon colors above.
"#178B1B", "#4628EC", "#C0399F", "#1D1B1B", "#00185B", "#0878F5", "#0D3C56",
"#1B91FF", "#1C2D3E", "#6A6A6A", "#89D1FF", "#D2F0FF", "#00144A", "#121212",
"#212121", "#5F6369", "#CC01DB", "#FFCC99", "#0070F3", "#8EA2B5", "#CB00DC",
"#D3E8FD", "#FFE6CC", "#004C99", "#0170F2", "#053B70", "#0A74F3", "#102937",
"#107E3E", "#333333", "#666666", "#6D7F91", "#8695A4", "#BFBFBF", "#D79B00",
"#F5F5F5", "#0080F0", "#3333FF", "#3399FF", "#354C5F", "#4D4D4D", "#6666FF",
"#6C8EBF", "#A100C2", "#B3B3B3", "#B46504", "#C5761C", "#C87515", "#CCE5FF",
"#E7E8E9", "#F65AF2", "#FAD7AC", "#FF8000", "#FFB300", "#0000CC", "#0000FF",
"#003366", "#0040B0", "#006600", "#0066CC", "#006EAF", "#0071F2", "#007FFF",
"#009500", "#00BEF2", "#00CEAC", "#04221C", "#101C22", "#192B3D", "#1BA1E2",
"#221111", "#223548", "#251821", "#314354", "#354B5F", "#36393D", "#384C61",
"#470CED", "#60A917", "#647687", "#6600CC", "#67AB9F", "#7F7F7F", "#808080",
"#9B76FF", "#A8A8FF", "#CCCCFF", "#D6B656", "#DAE8FC", "#DF8C42", "#E07A5F",
"#E6E6E6", "#EEEEEE", "#F2CC8F", "#F9F7ED", "#FF87FF", "#FFB366", "#FFD966",
"#FFF2CC",
# --- basics ----------------------------------------------------------------
"#FFFFFF", "#FFF", "#000000", "#000",
}
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")
# data URIs embed their own palette we should NOT flag
DATA_URI_RE = re.compile(r"data:image/[^&\";]+")
GRID = 10
ALLOWED_STROKE = {"1", "1.5", "2", "3", "4"}
# Canonical SAP flow-pill vocabulary, extracted from the published reference
# corpus. Pills with labels outside this set tend to indicate hand-crafted
# diagrams rather than SAP-style flow narration. We don't enforce strictly —
# many cases legitimately add custom verbs — but emit a warning so reviewers
# can ratify the deviation.
CANONICAL_PILL_LABELS = {
# identity / trust / auth
"trust", "authenticate", "authentication", "authorization",
"identity", "identity lifecycle", "customer-managed identity lifecycle",
"user", "usergroup", "group", "role", "role collection", "role collections",
"policy", "scim", "saml2/oidc", "oidc", "saml", "openid",
# transport
"https", "https/active", "https/standby", "rest", "rest/spi",
"rest/token", "rest / odata", "odata/rest", "odata/rest/soap",
# data flows
"destination", "source", "target", "harmonized api",
"data federation", "data sync", "task data",
# agentic ai vocab seen in RA0029 family
"a2a", "mcp", "ord",
# business
"business data cloud", "business role", "cdm",
# other observed
"role replica",
# semantic renderer labels that follow SAP-style flow narration
"commit", "build & test", "release", "deploy", "connectivity", "private link",
"sql", "security logs", "alerts, findings & enriched events",
"correlated incidents", "status & closure updates", "notification", "open ticket",
# generic but acceptable
"data", "metadata",
}
# Pill labels Codex observed in failed generations (PROMPT, ROUTE, CONTEXT,
# DELEGATE, etc.) — explicit watch-list to surface the most common drift.
NOVELTY_PILL_LABELS = {
"prompt", "route", "context", "delegate", "answer", "ask", "respond",
"query", "fetch", "invoke", "call", "execute", "run", "process",
"send", "receive", "publish", "subscribe", "transform",
}
# Light/neutral page backgrounds we accept. Anything else (dark, branded,
# strongly tinted) is suspect because no SAP reference uses one.
ALLOWED_PAGE_BACKGROUNDS = {None, "", "none", "default", "#ffffff", "#fff", "#FFFFFF", "#FFF"}
# ---------- Report model -----------------------------------------------------
@dataclass
class Issue:
kind: str # "error" | "warning"
category: str # "xml", "align", "text", "style"
msg: str
cell: str | None = None
@dataclass
class Report:
path: str
issues: list[Issue] = field(default_factory=list)
def add(self, kind: str, category: str, msg: str, cell: str | None = None) -> None:
self.issues.append(Issue(kind, category, msg, cell))
@property
def errors(self) -> list[Issue]:
return [i for i in self.issues if i.kind == "error"]
@property
def warnings(self) -> list[Issue]:
return [i for i in self.issues if i.kind == "warning"]
def to_json(self) -> dict:
return {
"path": self.path,
"ok": not self.errors,
"errors": [{"category": i.category, "msg": i.msg, "cell": i.cell} for i in self.errors],
"warnings": [{"category": i.category, "msg": i.msg, "cell": i.cell} for i in self.warnings],
}
# ---------- Geometry helpers -------------------------------------------------
def geom(cell: ET.Element) -> tuple[float, float, float, float] | None:
g = cell.find("mxGeometry")
if g is None:
return None
try:
x = float(g.get("x", "0"))
y = float(g.get("y", "0"))
w = float(g.get("width", "0"))
h = float(g.get("height", "0"))
return x, y, w, h
except ValueError:
return None
def parse_style(style: str | None) -> dict[str, str]:
if not style:
return {}
out: dict[str, str] = {}
for part in style.split(";"):
part = part.strip()
if not part:
continue
if "=" in part:
k, v = part.split("=", 1)
out[k.strip()] = v.strip()
else:
out[part] = "1"
return out
def approx_text_width(text: str, font_size: float, bold: bool = False) -> float:
"""Crude width estimate in px. Good enough to catch egregious overflow."""
if not text:
return 0.0
# avg char width ≈ 0.55 × font_size for Helvetica regular, 0.60 bold
coef = 0.60 if bold else 0.55
return len(text) * font_size * coef
def visible_label_lines(label: str) -> list[str]:
"""Reduce an HTML-ish draw.io label to visible text lines."""
label = html.unescape(label or "")
label = re.sub(r"</(?:div|p|li)>", "\n", label, flags=re.I)
label = re.sub(r"<br\s*/?>", "\n", label, flags=re.I)
no_tags = re.sub(r"<[^>]+>", "", label)
lines = [re.sub(r"\s+", " ", line).strip() for line in no_tags.splitlines()]
return [line for line in lines if line]
def strip_html(label: str) -> str:
"""Reduce HTML label to its visible text (rough)."""
return " ".join(visible_label_lines(label))
def html_font_size(label: str, fallback: float) -> float:
"""Best-effort font-size extraction from draw.io rich text labels."""
sizes: list[float] = []
for value in re.findall(r"font-size\s*:\s*([0-9.]+)\s*px", label or "", flags=re.I):
try:
sizes.append(float(value))
except ValueError:
pass
for value in re.findall(r"<font[^>]*\bsize=[\"']?([0-9]+)", label or "", flags=re.I):
# draw.io/browser HTML font size 1 renders small; this is only a fit heuristic.
sizes.append({"1": 10.0, "2": 11.0, "3": 12.0, "4": 14.0, "5": 18.0, "6": 24.0, "7": 32.0}.get(value, fallback))
return min(sizes) if sizes else fallback
def bbox_overlap(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
"""Return overlap area in px². 0 if no overlap."""
ax, ay, aw, ah = a
bx, by, bw, bh = b
dx = max(0.0, min(ax + aw, bx + bw) - max(ax, bx))
dy = max(0.0, min(ay + ah, by + bh) - max(ay, by))
return dx * dy
# ---------- Validators -------------------------------------------------------
def validate(path: Path) -> Report:
report = Report(path=str(path))
text = path.read_text(encoding="utf-8")
if COMMENT_RE.search(text):
report.add("error", "xml", "XML comments (<!-- -->) forbidden — strip them")
try:
root = ET.fromstring(text)
except ET.ParseError as exc:
report.add("error", "xml", f"XML parse error: {exc}")
return report
# ---- collect cells & basic structural checks ---------------------------
parent_by_elem = {id(child): parent for parent in root.iter() for child in list(parent)}
def effective_cell_id(cell: ET.Element) -> str | None:
cid = cell.get("id")
if cid:
return cid
parent = parent_by_elem.get(id(cell))
if parent is not None and parent.tag == "UserObject":
return parent.get("id")
return None
graphs = root.findall(".//mxGraphModel") or [root]
# Page background colour — SAP diagrams are always on a white/transparent canvas.
for graph_index, graph in enumerate(graphs):
bg = graph.get("background") or graph.get("pageBackgroundColor")
if bg and bg.strip().lower() not in {b.lower() for b in ALLOWED_PAGE_BACKGROUNDS if b}:
suffix = "" if len(graphs) == 1 else f" (page {graph_index + 1})"
report.add(
"error",
"style",
f"page background {bg!r}{suffix} — SAP diagrams use a white/transparent canvas; "
"remove the dark/branded background.",
)
def scoped_id(graph_index: int, cell_id: str) -> str:
return cell_id if len(graphs) == 1 else f"{graph_index}:{cell_id}"
cells: dict[str, ET.Element] = {}
cell_scopes: dict[str, int] = {}
duplicate_ids: set[tuple[int, str]] = set()
for graph_index, graph in enumerate(graphs):
seen_in_graph: set[str] = set()
for cell in graph.iter("mxCell"):
cid = effective_cell_id(cell)
if cid is None:
report.add("error", "xml", "mxCell without id attribute")
continue
if cid in seen_in_graph:
duplicate_ids.add((graph_index, cid))
seen_in_graph.add(cid)
key = scoped_id(graph_index, cid)
cells[key] = cell
cell_scopes[key] = graph_index
is_vertex = cell.get("vertex") == "1"
is_edge = cell.get("edge") == "1"
if (is_vertex or is_edge) and cell.find("mxGeometry") is None:
report.add("error", "xml", "vertex/edge missing <mxGeometry>", cell=key)
for graph_index, cid in duplicate_ids:
suffix = "" if len(graphs) == 1 else f" in diagram page {graph_index + 1}"
report.add("error", "xml", f"duplicate id {cid!r}{suffix}")
# ---- root-cell skeleton ------------------------------------------------
# mxGraph needs a root cell (no parent, not vertex/edge) and a layer cell
# parented to it as the first two cells of each page. SAP files often use
# namespaced ids (e.g. <prefix>-0/<prefix>-1) rather than literal 0/1, so
# we check structure, not the id values. A content cell reusing literal
# "0"/"1" is a collision hazard when pages are merged into files that DO
# use the default ids — warn on that.
for graph_index, graph in enumerate(graphs):
suffix = "" if len(graphs) == 1 else f" in diagram page {graph_index + 1}"
page_cells = list(graph.iter("mxCell"))
if not page_cells:
continue
first = page_cells[0]
if first.get("parent") is not None or first.get("vertex") == "1" or first.get("edge") == "1":
report.add(
"error", "xml",
f"page has no root cell skeleton{suffix} — the first mxCell must be the parentless "
"root (then a layer cell parented to it) before any content; draw.io rejects files without it",
)
elif len(page_cells) >= 2:
layer = page_cells[1]
if layer.get("parent") != first.get("id") or layer.get("vertex") == "1" or layer.get("edge") == "1":
report.add(
"warning", "xml",
f"second cell is not a layer parented to the root{suffix} (draw.io convention: root then layer)",
)
for c in page_cells[2:]:
if effective_cell_id(c) in ("0", "1") and (c.get("vertex") == "1" or c.get("edge") == "1"):
report.add(
"warning", "xml",
f"content cell uses id {effective_cell_id(c)!r}{suffix} — mxGraph's default root/layer ids; "
"collision-prone, namespace generated ids (n-<id>)",
cell=scoped_id(graph_index, effective_cell_id(c)),
)
for cid, cell in cells.items():
if cell.get("edge") != "1":
continue
scope = cell_scopes[cid]
for attr in ("source", "target"):
ref = cell.get(attr)
if ref and scoped_id(scope, ref) not in cells:
report.add("warning", "xml", f"edge references missing {attr} id {ref!r}", cell=cid)
abs_geom_cache: dict[str, tuple[float, float, float, float] | None] = {}
def absolute_geom(cid: str) -> tuple[float, float, float, float] | None:
"""Return geometry in page coordinates, resolving draw.io child parents."""
if cid in abs_geom_cache:
return abs_geom_cache[cid]
cell = cells.get(cid)
if cell is None:
abs_geom_cache[cid] = None
return None
g = geom(cell)
if not g:
abs_geom_cache[cid] = None
return None
x, y, w, h = g
parent_id = cell.get("parent")
if parent_id and parent_id not in {"0", "1"}:
parent_key = scoped_id(cell_scopes[cid], parent_id)
pg = absolute_geom(parent_key)
if pg:
x += pg[0]
y += pg[1]
abs_geom_cache[cid] = (x, y, w, h)
return abs_geom_cache[cid]
# ---- style / palette ---------------------------------------------------
palette_text = DATA_URI_RE.sub("", text)
foreign = {m.upper() for m in HEX_RE.findall(palette_text)} - {c.upper() for c in SAP_PALETTE}
for color in sorted(foreign):
report.add("warning", "style", f"off-palette color {color}")
# ---- per-cell checks ---------------------------------------------------
grid_total = 0
grid_off = 0
grid_examples: list[str] = []
for cid, cell in cells.items():
style = parse_style(cell.get("style"))
# Grid snap
g = geom(cell)
if g:
x, y, w, h = g
for name, val in (("x", x), ("y", y), ("width", w), ("height", h)):
grid_total += 1
if abs(val - round(val)) > 0.01 or int(round(val)) % GRID != 0:
grid_off += 1
if len(grid_examples) < 5:
grid_examples.append(f"{cid}.{name}={val!r}")
# absoluteArcSize when arcSize present
if "arcSize" in style and style.get("absoluteArcSize") != "1":
report.add(
"warning",
"style",
"arcSize without absoluteArcSize=1 renders as percentage",
cell=cid,
)
# Font family
ff = style.get("fontFamily")
if ff and ff.lower() != "helvetica":
report.add("warning", "style", f"fontFamily={ff!r} (expected Helvetica)", cell=cid)
# Image source hygiene
raw_style = cell.get("style") or ""
if "image=" in raw_style and ";base64," in raw_style:
report.add(
"error", "style",
"';base64' inside image= style — draw.io truncates the value at the ';' "
"and the icon renders blank; use image=data:<mime>,<b64> (payload still base64, marker dropped)",
cell=cid,
)
image = style.get("image")
if image and image.startswith(("http://", "https://")):
report.add("warning", "style", "external image URL — prefer bundled SAP inline assets", cell=cid)
elif image and not (image.startswith("data:image/") or image == "img/lib/sap/SAP_Logo.svg"):
report.add("warning", "style", f"non-bundled image source {image!r}", cell=cid)
# Stroke width
sw = style.get("strokeWidth")
if sw and sw not in ALLOWED_STROKE:
report.add("warning", "style", f"strokeWidth={sw!r} (expected one of {sorted(ALLOWED_STROKE)})", cell=cid)
# Edge-label background
if cell.get("edge") == "1":
val = cell.get("value") or ""
if val.strip() and not style.get("labelBackgroundColor"):
report.add(
"warning",
"text",
"edge label without labelBackgroundColor (will bleed into zone fill)",
cell=cid,
)
if "endArrow" not in style and "startArrow" not in style:
report.add("warning", "style", "edge without endArrow style", cell=cid)
# Text overflow (vertex only, has a label, has geometry)
if cell.get("vertex") == "1" and g:
raw_label = cell.get("value") or ""
label = strip_html(raw_label)
if label and style.get("autosize") != "1" and style.get("shape") != "image" and "image" not in style:
font_size = html_font_size(raw_label, float(style.get("fontSize", "12")))
bold = style.get("fontStyle", "0") in {"1", "3", "5", "7"}
spacing = float(style.get("spacingLeft", "0")) + float(style.get("spacingRight", "0"))
wrap = style.get("whiteSpace") == "wrap" and style.get("html") == "1"
effective_w = g[2] - spacing - 6 # 6 px slop
if wrap:
# With wrapping, only the single longest token needs to fit
longest = max(label.split(), key=len, default="")
need = approx_text_width(longest, font_size, bold)
if effective_w > 0 and need > effective_w + 6:
report.add(
"warning",
"text",
f"longest word '{longest}' ~{int(need)}px > shape width {int(g[2])}px — clip",
cell=cid,
)
else:
longest_line = max(visible_label_lines(raw_label) or [label], key=len)
need = approx_text_width(longest_line, font_size, bold)
if effective_w > 0 and need > effective_w + 6:
report.add(
"warning",
"text",
f"label ~{int(need)}px wider than shape ({int(g[2])}px) — text will clip",
cell=cid,
)
if grid_total:
snap_rate = 1.0 - (grid_off / grid_total)
if snap_rate < 0.95:
examples = f"; examples: {', '.join(grid_examples)}" if grid_examples else ""
report.add(
"warning",
"align",
f"grid-snap rate {snap_rate * 100:.1f}% below recommended 95% "
f"({grid_off}/{grid_total} geometry values off {GRID}-px grid){examples}",
)
# ---- pill / flow-narration vocabulary check ---------------------------
# A pill is roughly arcSize >= 40 with a label. SAP's published corpus uses
# a small canonical vocabulary (TRUST, Authenticate, A2A, MCP, ORD, HTTPS,
# OData/REST, …). Custom verbs like PROMPT/ROUTE/CONTEXT/DELEGATE indicate
# an LLM hand-crafted the diagram instead of starting from a template.
# Track the labels we saw, then warn for each one outside the canon.
seen_pill_labels: list[tuple[str, str]] = [] # (label, cid)
novelty_pills: list[tuple[str, str]] = []
for cid, cell in cells.items():
if cell.get("vertex") != "1":
continue
style = parse_style(cell.get("style"))
try:
arc = int(float(style.get("arcSize", "0")))
except ValueError:
arc = 0
if arc < 40:
continue
# Pill must be small (single line, < 200 px wide). Larger rounded
# shapes can be cards or banners, which use a separate label vocab.
g = geom(cell)
if not g or g[2] > 220 or g[3] > 60:
continue
raw = cell.get("value") or ""
# UserObject wrapping: a parent UserObject may carry the visible label
if not raw:
parent = parent_by_elem.get(id(cell))
if parent is not None and parent.tag == "UserObject":
raw = parent.get("value") or parent.get("label") or ""
label_text = strip_html(raw).strip()
if not label_text:
continue
seen_pill_labels.append((label_text, cid))
normalized = label_text.lower()
if normalized in CANONICAL_PILL_LABELS:
continue
# Single-token novelty pill — the most common LLM drift mode.
first_token = normalized.split()[0] if normalized else ""
if first_token in NOVELTY_PILL_LABELS:
novelty_pills.append((label_text, cid))
report.add(
"warning",
"text",
f"flow pill {label_text!r} is not in the canonical SAP vocabulary "
"(TRUST/Authenticate/A2A/MCP/ORD/HTTPS/OData/REST/…). "
"Replace with a SAP-style verb or remove the pill.",
cell=cid,
)
# ---- sibling overlap checks (vertices sharing a parent) ---------------
def is_transparent_or_chrome(cell: ET.Element) -> bool:
"""Cells that float on top of others by design and shouldn't be flagged."""
s = parse_style(cell.get("style"))
if s.get("fillColor") in (None, "none"):
return True
if s.get("shape") in ("ellipse", "image"):
return True
# pill (arcSize >= 40 roughly)
try:
if int(s.get("arcSize", "0")) >= 40:
return True
except ValueError:
pass
# text-only cells
if s.get("text") == "1" or s.get("strokeColor") == "none":
return True
return False
by_parent: dict[str, list[tuple[str, tuple[float, float, float, float], ET.Element]]] = {}
for cid, cell in cells.items():
if cell.get("vertex") != "1":
continue
parent = scoped_id(cell_scopes[cid], cell.get("parent") or "")
g = geom(cell)
if not g or g[2] <= 0 or g[3] <= 0:
continue
by_parent.setdefault(parent, []).append((cid, g, cell))
for parent, members in by_parent.items():
for i in range(len(members)):
for j in range(i + 1, len(members)):
ida, ga, ca = members[i]
idb, gb, cb = members[j]
ov = bbox_overlap(ga, gb)
if ov <= 100: # ignore slivers
continue
ax, ay, aw, ah = ga
bx, by_, bw, bh = gb
contains = (ax <= bx and ay <= by_ and ax + aw >= bx + bw and ay + ah >= by_ + bh) or (
bx <= ax and by_ <= ay and bx + bw >= ax + aw and by_ + bh >= ay + ah
)
if contains:
continue
# Pills / icons / text / transparent cells are allowed to float over frames
if is_transparent_or_chrome(ca) or is_transparent_or_chrome(cb):
continue
report.add(
"warning",
"align",
f"cells {ida} and {idb} overlap by {int(ov)}px² (same parent {parent})",
)
# ---- bent-edge detection ----------------------------------------------
for cid, cell in cells.items():
if cell.get("edge") != "1":
continue
src_id = cell.get("source")
tgt_id = cell.get("target")
if not src_id or not tgt_id:
continue
style = parse_style(cell.get("style"))
if style.get("edgeStyle") != "orthogonalEdgeStyle":
continue # non-orthogonal edges may legitimately curve
# Skip edges with explicit entry/exit anchors — author has chosen the docking
if any(k in style for k in ("entryX", "exitX", "entryY", "exitY")):
continue
scope = cell_scopes[cid]
src = cells.get(scoped_id(scope, src_id))
tgt = cells.get(scoped_id(scope, tgt_id))
if src is None or tgt is None:
continue
gs = absolute_geom(scoped_id(scope, src_id))
gt = absolute_geom(scoped_id(scope, tgt_id))
if not gs or not gt:
continue
cx_s = gs[0] + gs[2] / 2
cy_s = gs[1] + gs[3] / 2
cx_t = gt[0] + gt[2] / 2
cy_t = gt[1] + gt[3] / 2
aligned_v = abs(cx_s - cx_t) <= 1.0 # centers on same vertical
aligned_h = abs(cy_s - cy_t) <= 1.0 # centers on same horizontal
if not (aligned_v or aligned_h):
# Is there overlap on an axis? If boxes overlap on X the edge can still drop straight
overlap_x = min(gs[0] + gs[2], gt[0] + gt[2]) - max(gs[0], gt[0])
overlap_y = min(gs[1] + gs[3], gt[1] + gt[3]) - max(gs[1], gt[1])
if overlap_x < 10 and overlap_y < 10:
report.add(
"warning",
"align",
f"edge {cid}: source/target centers differ on both axes "
f"(Δx={cx_s - cx_t:.0f}, Δy={cy_s - cy_t:.0f}) — arrow will bend. "
"Either snap centers or add entryX/exitX anchors.",
cell=cid,
)
# ---- duplicate SAP logos check ----------------------------------------
# SAP guideline: "It is not recommended to use too many SAP logos in the
# same diagram." (product_names.md). One inline SAP_Logo.svg per zone-band
# is acceptable; more than ~4 in a single page is suspicious.
sap_logo_count = 0
for cid, cell in cells.items():
style = parse_style(cell.get("style"))
image = style.get("image")
if image and "sap_logo" in image.lower():
sap_logo_count += 1
if sap_logo_count > 6:
report.add(
"warning",
"style",
f"{sap_logo_count} SAP logos detected — SAP recommends limiting logo "
"repetition. Use text-only product labels instead beyond zone branding.",
)
# ---- icon-size check ---------------------------------------------------
# SAP corpus: icons cluster tightly at 32x32 (224x), 48x48 (157x), 28x28 (85x).
# Icons larger than ~64 px overpower cards and overlap their text — the
# most common visible bug from LLM-generated diagrams.
for cid, cell in cells.items():
if cell.get("vertex") != "1":
continue
style = parse_style(cell.get("style"))
is_icon = (
style.get("shape") == "image"
and style.get("image", "").startswith(("data:image/svg", "data:image/png"))
) or "mxgraph.sap.icon" in (cell.get("style") or "")
if not is_icon:
continue
g = geom(cell)
if not g:
continue
_, _, w, h = g
# Only flag near-square images that are big in both dimensions. SAP uses
# horizontal text banners (e.g. 67x18 for product names) which are
# technically `shape=image` but aren't "icons" in the visual-bug sense.
is_square_ish = 0.5 <= (w / max(1, h)) <= 2.0
if w > 64 and h > 64 and is_square_ish:
report.add(
"warning",
"style",
f"icon w={int(w)}×h={int(h)} is oversized — SAP corpus standard is 32×32 (most common) "
"or 48×48 for focal anchors. Resize this icon to avoid overlapping card text.",
cell=cid,
)
# ---- icon-overlapping-text check --------------------------------------
# An icon dropped on top of a card's text region is the signature failure
# of LLM-placed icons. Detect by: icon vertex whose bounding box overlaps
# a non-image vertex by more than 25% of the icon's area, where the other
# vertex has a non-empty visible label.
icon_cells: list[tuple[str, tuple[float, float, float, float]]] = []
text_vertex_cells: list[tuple[str, tuple[float, float, float, float]]] = []
for cid, cell in cells.items():
if cell.get("vertex") != "1":
continue
g = geom(cell)
if not g:
continue
style = parse_style(cell.get("style"))
is_icon = (
style.get("shape") == "image"
and style.get("image", "").startswith(("data:image/svg", "data:image/png"))
) or "mxgraph.sap.icon" in (cell.get("style") or "")
if is_icon:
icon_cells.append((cid, g))
continue
# text-bearing card: has a non-empty visible label and a fill (so it's a card, not chrome)
raw = cell.get("value") or ""
label = strip_html(raw).strip()
if not label:
continue
if style.get("fillColor", "").lower() in ("none", ""):
continue
if style.get("shape") in ("ellipse",):
continue # ellipses are often legend dots, not text cards
text_vertex_cells.append((cid, g))
for icon_id, ig in icon_cells:
ix, iy, iw, ih = ig
icon_area = max(1.0, iw * ih)
for txt_id, tg in text_vertex_cells:
ov = bbox_overlap(ig, tg)
if ov < icon_area * 0.25:
continue
# Skip if icon is entirely INSIDE the card (intentional inline placement)
tx, ty, tw, th = tg
if tx <= ix and ty <= iy and tx + tw >= ix + iw and ty + th >= iy + ih:
continue
report.add(
"warning",
"align",
f"icon {icon_id} overlaps card {txt_id} by {int(ov)}px² — icon will block "
"card text. Move the icon to a dedicated empty region OR shrink it to 32×32 "
"and tuck it inside the card.",
cell=icon_id,
)
# ---- edges passing through other cells --------------------------------
# An edge from A to B should not run its straight-line path across the
# bounding box of unrelated card C. This produces the visible bug of
# "arrows that look like they hit the card." Sample 50 points along the
# edge's straight path; flag if any unrelated vertex contains > 5 of them.
other_cells_by_scope: dict[int, list[tuple[str, tuple[float, float, float, float]]]] = {}
for cid, cell in cells.items():
if cell.get("vertex") != "1":
continue
g = absolute_geom(cid)
if not g or g[2] <= 0 or g[3] <= 0:
continue
scope = cell_scopes.get(cid, 0)
other_cells_by_scope.setdefault(scope, []).append((cid, g))
for cid, cell in cells.items():
if cell.get("edge") != "1":
continue
s_id, t_id = cell.get("source"), cell.get("target")
if not s_id or not t_id:
continue
scope = cell_scopes.get(cid, 0)
src = cells.get(scoped_id(scope, s_id))
tgt = cells.get(scoped_id(scope, t_id))
if src is None or tgt is None:
continue
sg = absolute_geom(scoped_id(scope, s_id))
tg = absolute_geom(scoped_id(scope, t_id))
if not sg or not tg:
continue
# Endpoints: card centers (this is where draw.io draws straight edges to)
sx, sy = sg[0] + sg[2] / 2, sg[1] + sg[3] / 2
tx, ty = tg[0] + tg[2] / 2, tg[1] + tg[3] / 2
# Walk the straight line; collect any cell that is hit by > 5 sample points
through: dict[str, int] = {}
steps = 50
for i in range(1, steps):
tparam = i / steps
px = sx + (tx - sx) * tparam
py = sy + (ty - sy) * tparam
for other_id, og in other_cells_by_scope.get(scope, []):
if other_id == src.get("id") or other_id == tgt.get("id"):
continue
if other_id in (s_id, t_id):
continue
ox, oy, ow, oh = og
if ox < px < ox + ow and oy < py < oy + oh:
through[other_id] = through.get(other_id, 0) + 1
# Report edges that clearly cross unrelated cells (parents/zones excluded)
# A "parent" of source/target is acceptable (the edge enters its own zone)
src_parent = src.get("parent")
tgt_parent = tgt.get("parent")
for other_id, count in sorted(through.items(), key=lambda kv: -kv[1]):
# Require substantial coverage: > 25% of the sampled path AND a
# minimum absolute step count. Otherwise we get noise from edges
# that just clip the corner of an unrelated cell.
if count < 13:
continue
if other_id in {src_parent, tgt_parent}:
continue # edge passes through its own zone — fine
# Also skip if "other" is a transparent / chrome cell (no fill)
other_cell = cells.get(other_id)
if other_cell is None:
continue
if is_transparent_or_chrome(other_cell):
continue
ostyle = parse_style(other_cell.get("style"))
if ostyle.get("fillColor", "").lower() in ("none", "") and ostyle.get("shape") not in ("image",):
continue
# Skip cells with no visible label — they're decorative chrome
# (background bands, page borders, container shells). The user
# only cares when an arrow visibly crosses a labeled card.
other_raw = other_cell.get("value") or ""
other_label = strip_html(other_raw).strip()
if not other_label:
continue
# Skip the source/target's container ancestors (zones the edge
# legitimately enters or leaves)
anc_id = other_cell.get("parent")
ancestors_of_obstacle = set()
while anc_id:
ancestors_of_obstacle.add(anc_id)
anc = cells.get(anc_id)
if anc is None:
break
anc_id = anc.get("parent")
if src.get("id") in ancestors_of_obstacle or tgt.get("id") in ancestors_of_obstacle:
continue
report.add(
"warning",
"align",
f"edge {cid} ({s_id}{t_id}) passes through cell {other_id} "
f"(label={other_label[:40]!r}). Add entryX/exitX/entryY/exitY anchors to dock the edge "
"on the card's edge, OR set edgeStyle=orthogonalEdgeStyle and reposition cells so "
"the edge can route around the obstacle.",
cell=cid,
)
break # one warning per edge is enough; LLM will fix and re-run
return report
# ---------- Output ----------------------------------------------------------
def print_text(report: Report) -> None:
path = report.path
if not report.issues:
print(f"{path}: OK")
return
for i in report.warnings:
loc = f" [{i.cell}]" if i.cell else ""
print(f"{path}: warning ({i.category}){loc}: {i.msg}")
for i in report.errors:
loc = f" [{i.cell}]" if i.cell else ""
print(f"{path}: error ({i.category}){loc}: {i.msg}")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("files", nargs="+")
ap.add_argument("--strict", action="store_true", help="warnings fail the run")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
rc = 0
reports = []
for f in args.files:
p = Path(f)
if not p.exists():
print(f"{p}: not found", file=sys.stderr)
rc = 1
continue
r = validate(p)
reports.append(r)
if r.errors or (args.strict and r.warnings):
rc = 1
if args.json:
print(json.dumps([r.to_json() for r in reports], indent=2))
else:
for r in reports:
print_text(r)
return rc
if __name__ == "__main__":
sys.exit(main())