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