Import sap-architecture skill
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user