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:
root
2026-04-25 21:15:21 -03:00
parent 2491c38d4b
commit b30a4f0d32
635 changed files with 365317 additions and 1014 deletions

View 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()