The diagram path now follows a documented standard procedure (lookup the closest Oracle Architecture Center reference → confirm components → author absolute_layout → spec validator → render → visually verify) and ships persistent guardrails so layout regressions can't recur. Persistent procedure changes (apply to all users, all sessions): - tools/diagram_spec_validator.py — geometry checks (CONTAINER_TOO_THIN, CONTAINER_PADDING_VIOLATION, LABEL_OVERFLOW_PARENT) run BEFORE either renderer (drawio + PPTX). Catches the subnet-collapse / label-overflow bugs that the post-render drawio validator missed. - tools/oci_diagram_gen.py + tools/oci_pptx_diagram_gen.py — call the spec validator before emitting any output. Adds mysql / mysql_heatwave type aliases. - tools/archcenter_pattern_lookup.py — scores against cached page descriptions (not just the 1-line summary), supports --queries for multi-fragment composition, and applies synonym expansion via kb/architecture-center/synonyms.yaml so "LB HA cross AD" matches "load balancer high availability availability domain". - kb/architecture-center/synonyms.yaml — canonical synonym table (load balancer, autonomous database, data guard, …) used by the lookup scorer. KB enrichment: - tools/archcenter_description_fetcher.py + 121 cached _description.md under kb/diagram/assets/archcenter-refs/<slug>/. Removes the runtime dependency on docs.oracle.com when authoring specs and feeds the pattern-lookup scorer. - 110+ cached .drawio / .svg / .png references for offline reuse, plus the OCI Toolkit v24.2 import (kb/diagram/assets/oci-toolkit-drawio). Documentation: - docs/skill/output-formats.md — new "Standard diagram-generation procedure (MANDATORY)" + geometry rules + the new validator entry. - SKILL.md option 2 — references the mandatory procedure. - README.md — describes the spec validator, archcenter_pattern_lookup and description fetcher, and updates the KB-health table. Tooling that backs the procedure (cumulative across recent sessions): tools/archcenter_case_runner.py, archcenter_batch_driver.py, archcenter_zip_downloader.py, drawio_visual_validator.py, drawio_fidelity_eval.py, harvest_drawio_icon.py, import_oci_library.py, oci_pptx_diagram_gen.py, oci_pptx_render.py, refresh_pptx_icon_index.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
249 lines
11 KiB
Python
249 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Evaluate an Architecture Center reconstruction against an official PNG asset.
|
|
|
|
The evaluator consumes the `absolute_layout` used by the editable draw.io and
|
|
native PPTX renderers, creates a comparable raster view, and records objective
|
|
image metrics plus structural coverage. It is a benchmark aid, not a substitute
|
|
for human review in draw.io/PowerPoint.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageOps
|
|
|
|
|
|
PALETTE = {
|
|
"bark": "#312D2A",
|
|
"teal": "#2D5967",
|
|
"gray": "#9E9892",
|
|
"region": "#F5F4F2",
|
|
"ad": "#DFDCD8",
|
|
"orange": "#AA643B",
|
|
"white": "#FFFFFF",
|
|
"badge": "#70665E",
|
|
}
|
|
|
|
|
|
def _font(size: int, bold: bool = False):
|
|
candidates = [
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
|
|
]
|
|
for candidate in candidates:
|
|
if Path(candidate).is_file():
|
|
return ImageFont.truetype(candidate, size)
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _text(draw, xy, text, fill=PALETTE["bark"], size=13, bold=False, anchor="mm", align="center"):
|
|
draw.multiline_text(xy, text or "", fill=fill, font=_font(size, bold), anchor=anchor, align=align, spacing=2)
|
|
|
|
|
|
def _dashed_rect(draw, box, outline, width=2, dash=8, gap=5):
|
|
x1, y1, x2, y2 = box
|
|
for x in range(x1, x2, dash + gap):
|
|
draw.line([(x, y1), (min(x + dash, x2), y1)], fill=outline, width=width)
|
|
draw.line([(x, y2), (min(x + dash, x2), y2)], fill=outline, width=width)
|
|
for y in range(y1, y2, dash + gap):
|
|
draw.line([(x1, y), (x1, min(y + dash, y2))], fill=outline, width=width)
|
|
draw.line([(x2, y), (x2, min(y + dash, y2))], fill=outline, width=width)
|
|
|
|
|
|
def _arrow(draw, points, fill=PALETTE["bark"], width=2):
|
|
if len(points) < 2:
|
|
return
|
|
draw.line(points, fill=fill, width=width, joint="curve")
|
|
x1, y1 = points[-2]
|
|
x2, y2 = points[-1]
|
|
angle = math.atan2(y2 - y1, x2 - x1)
|
|
size = 8
|
|
left = (x2 - size * math.cos(angle - math.pi / 6), y2 - size * math.sin(angle - math.pi / 6))
|
|
right = (x2 - size * math.cos(angle + math.pi / 6), y2 - size * math.sin(angle + math.pi / 6))
|
|
draw.polygon([(x2, y2), left, right], fill=fill)
|
|
|
|
|
|
def _service(draw, item):
|
|
x, y = int(item["x"]), int(item["y"])
|
|
w, h = int(item.get("w", 60)), int(item.get("h", 70))
|
|
icon_h = max(30, min(48, h - 22))
|
|
cx = x + w // 2
|
|
icon_top = y + 2
|
|
draw.rounded_rectangle(
|
|
[cx - icon_h // 2, icon_top, cx + icon_h // 2, icon_top + icon_h],
|
|
radius=3, outline=PALETTE["teal"], width=2, fill=PALETTE["white"],
|
|
)
|
|
kind = item.get("type", "")
|
|
if kind in {"load_balancer", "internet_gateway"}:
|
|
draw.line([(cx - 14, icon_top + icon_h // 2), (cx + 14, icon_top + icon_h // 2)], fill=PALETTE["teal"], width=3)
|
|
draw.line([(cx, icon_top + 8), (cx, icon_top + icon_h - 8)], fill=PALETTE["teal"], width=3)
|
|
elif kind in {"compute", "network_firewall"}:
|
|
for dx in (-10, 0, 10):
|
|
for dy in (10, 20, 30):
|
|
draw.rectangle([cx + dx - 3, icon_top + dy - 3, cx + dx + 3, icon_top + dy + 3], outline=PALETTE["teal"], width=1)
|
|
elif kind in {"monitoring", "notifications"}:
|
|
draw.line([(cx - 16, icon_top + 30), (cx - 4, icon_top + 18), (cx + 6, icon_top + 26), (cx + 15, icon_top + 12)], fill=PALETTE["teal"], width=2)
|
|
elif kind in {"functions", "object_storage"}:
|
|
draw.rectangle([cx - 15, icon_top + 13, cx + 15, icon_top + 30], outline=PALETTE["teal"], width=2)
|
|
draw.line([(cx - 10, icon_top + 22), (cx + 10, icon_top + 22)], fill=PALETTE["teal"], width=2)
|
|
label = str(item.get("label", "")).replace("\\n", "\n")
|
|
if label:
|
|
_text(draw, (cx, y + h - 12), label, size=13, bold=False)
|
|
|
|
|
|
def render_layout(spec_path: Path, output_path: Path) -> dict:
|
|
spec = yaml.safe_load(spec_path.read_text())
|
|
layout = spec["absolute_layout"]
|
|
canvas = layout.get("canvas") or {}
|
|
width = int(canvas.get("width", 850))
|
|
height = int(canvas.get("height", 770))
|
|
image = Image.new("RGB", (width, height), PALETTE["white"])
|
|
draw = ImageDraw.Draw(image)
|
|
|
|
for item in layout.get("containers") or []:
|
|
x, y = int(item["x"]), int(item["y"])
|
|
w, h = int(item["w"]), int(item["h"])
|
|
kind = item.get("type")
|
|
box = [x, y, x + w, y + h]
|
|
if kind == "region":
|
|
draw.rounded_rectangle(box, radius=3, outline=PALETTE["gray"], fill=PALETTE["region"], width=1)
|
|
elif kind == "ad":
|
|
draw.rounded_rectangle(box, radius=3, outline=PALETTE["gray"], fill=PALETTE["ad"], width=1)
|
|
elif kind in {"vcn", "subnet"}:
|
|
_dashed_rect(draw, box, PALETTE["orange"], width=2)
|
|
label = str(item.get("label", "")).replace("\\n", "\n")
|
|
if label:
|
|
color = PALETTE["orange"] if kind in {"vcn", "subnet"} else PALETTE["bark"]
|
|
_text(draw, (x + w // 2 if item.get("align") == "ctr" else x + 12, y + 16), label, fill=color, size=13, bold=bool(item.get("bold", True)), anchor="mm" if item.get("align") == "ctr" else "lm")
|
|
|
|
for item in layout.get("external") or []:
|
|
x, y = int(item["x"]), int(item["y"])
|
|
cx = x + int(item.get("w", 70)) // 2
|
|
draw.ellipse([cx - 18, y + 6, cx - 6, y + 18], outline=PALETTE["teal"], width=2)
|
|
draw.arc([cx - 24, y + 18, cx, y + 44], 200, 340, fill=PALETTE["teal"], width=2)
|
|
draw.ellipse([cx + 6, y + 4, cx + 20, y + 18], outline=PALETTE["teal"], width=2)
|
|
draw.arc([cx, y + 18, cx + 28, y + 46], 200, 340, fill=PALETTE["teal"], width=2)
|
|
_text(draw, (x + int(item.get("w", 70)) // 2, y + 54), item.get("label", ""), size=13)
|
|
|
|
services = {item["id"]: item for item in layout.get("services") or []}
|
|
services.update({item["id"]: item for item in layout.get("external") or []})
|
|
for item in layout.get("services") or []:
|
|
_service(draw, item)
|
|
|
|
for conn in layout.get("connections") or []:
|
|
points = conn.get("points") or conn.get("waypoints")
|
|
if not points:
|
|
continue
|
|
pts = [(int(x), int(y)) for x, y in points]
|
|
_arrow(draw, pts)
|
|
if conn.get("flow_order"):
|
|
bx = int(pts[0][0] + (pts[-1][0] - pts[0][0]) * float(conn.get("badge_t", 0.25)))
|
|
by = int(pts[0][1] + (pts[-1][1] - pts[0][1]) * float(conn.get("badge_t", 0.25)))
|
|
draw.ellipse([bx - 10, by - 10, bx + 10, by + 10], fill=PALETTE["badge"])
|
|
_text(draw, (bx, by), str(conn["flow_order"]), fill=PALETTE["white"], size=12, bold=True)
|
|
|
|
for item in layout.get("labels") or []:
|
|
_text(
|
|
draw,
|
|
(int(item["x"]) + int(item.get("w", 100)) // 2, int(item["y"]) + int(item.get("h", 24)) // 2),
|
|
str(item.get("text", "")).replace("\\n", "\n"),
|
|
size=max(9, int(item.get("fontSize", 760)) // 70),
|
|
bold=bool(item.get("bold", False)),
|
|
)
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(output_path)
|
|
return {
|
|
"canvas": [width, height],
|
|
"containers": len(layout.get("containers") or []),
|
|
"services": len(layout.get("services") or []),
|
|
"external": len(layout.get("external") or []),
|
|
"connections": len(layout.get("connections") or []),
|
|
"labels": len(layout.get("labels") or []),
|
|
}
|
|
|
|
|
|
def _dhash(image: Image.Image, hash_size: int = 8) -> int:
|
|
gray = ImageOps.grayscale(image).resize((hash_size + 1, hash_size), Image.Resampling.LANCZOS)
|
|
pixels = list(gray.getdata())
|
|
value = 0
|
|
for row in range(hash_size):
|
|
for col in range(hash_size):
|
|
left = pixels[row * (hash_size + 1) + col]
|
|
right = pixels[row * (hash_size + 1) + col + 1]
|
|
value = (value << 1) | int(left > right)
|
|
return value
|
|
|
|
|
|
def compare(reference_path: Path, candidate_path: Path, diff_path: Path) -> dict:
|
|
reference = Image.open(reference_path).convert("RGB")
|
|
candidate = Image.open(candidate_path).convert("RGB").resize(reference.size, Image.Resampling.LANCZOS)
|
|
diff = ImageChops.difference(reference, candidate)
|
|
diff_path.parent.mkdir(parents=True, exist_ok=True)
|
|
ImageOps.autocontrast(diff).save(diff_path)
|
|
hist = diff.histogram()
|
|
sq = sum(value * ((idx % 256) ** 2) for idx, value in enumerate(hist))
|
|
rms = math.sqrt(sq / float(reference.size[0] * reference.size[1] * 3))
|
|
similarity = max(0.0, 1.0 - (rms / 255.0))
|
|
h1 = _dhash(reference)
|
|
h2 = _dhash(candidate)
|
|
hamming = (h1 ^ h2).bit_count()
|
|
return {
|
|
"reference_size": list(reference.size),
|
|
"candidate_size": list(candidate.size),
|
|
"rms_diff": round(rms, 3),
|
|
"pixel_similarity": round(similarity, 4),
|
|
"dhash_hamming": hamming,
|
|
"diff_path": str(diff_path),
|
|
}
|
|
|
|
|
|
def write_report(report_path: Path, payload: dict) -> None:
|
|
status = "PASS" if payload["comparison"]["pixel_similarity"] >= payload["threshold"] else "FAIL"
|
|
lines = [
|
|
f"# Architecture Center Diagram Evaluation",
|
|
"",
|
|
f"- Status: **{status}**",
|
|
f"- Threshold: {payload['threshold']}",
|
|
f"- Pixel similarity: {payload['comparison']['pixel_similarity']}",
|
|
f"- RMS diff: {payload['comparison']['rms_diff']}",
|
|
f"- dHash hamming distance: {payload['comparison']['dhash_hamming']}",
|
|
f"- Structural counts: {json.dumps(payload['structure'], sort_keys=True)}",
|
|
"",
|
|
"## Notes",
|
|
"",
|
|
"This raster is generated from the same absolute layout consumed by the editable draw.io and native PPTX renderers. Use it as automated regression evidence; final visual approval still requires opening the editable artifacts.",
|
|
]
|
|
report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Evaluate an Architecture Center reconstruction.")
|
|
parser.add_argument("--spec", required=True, type=Path)
|
|
parser.add_argument("--reference", required=True, type=Path)
|
|
parser.add_argument("--render", required=True, type=Path)
|
|
parser.add_argument("--diff", required=True, type=Path)
|
|
parser.add_argument("--report", required=True, type=Path)
|
|
parser.add_argument("--json", required=True, type=Path)
|
|
parser.add_argument("--threshold", type=float, default=0.82)
|
|
args = parser.parse_args()
|
|
|
|
structure = render_layout(args.spec, args.render)
|
|
comparison = compare(args.reference, args.render, args.diff)
|
|
payload = {
|
|
"threshold": args.threshold,
|
|
"structure": structure,
|
|
"comparison": comparison,
|
|
}
|
|
args.json.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
write_report(args.report, payload)
|
|
print(json.dumps(payload, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|