Import sap-architecture skill
This commit is contained in:
@@ -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())
|
||||
@@ -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("&#10;", " ").replace(" ", " ").replace("\\n", " ")
|
||||
value = re.sub(r"<br\s*/?>", " ", value, flags=re.I)
|
||||
value = re.sub(r"<[^>]+>", " ", value)
|
||||
value = re.sub(r" |\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())
|
||||
@@ -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("&#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())
|
||||
@@ -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())
|
||||
@@ -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" ", " ", 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
@@ -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, {'"': """})
|
||||
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, {'"': """}))
|
||||
|
||||
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())
|
||||
@@ -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 ( , &), 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, {'"': """})
|
||||
|
||||
# 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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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" ", " ", 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())
|
||||
@@ -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(" ", " ")
|
||||
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())
|
||||
@@ -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())
|
||||
@@ -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><mxGraphModel></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 \"<service>\" --x <X> --y <Y></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 <file></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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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 <name></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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user