forked from diegoecab/oci-deal-accelerator
Diagram generation: ref-arch-driven procedure + spec validator + KB enrichment
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>
This commit is contained in:
248
tools/oci_archcenter_eval.py
Normal file
248
tools/oci_archcenter_eval.py
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user