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:
393
tools/drawio_template_transform.py
Normal file
393
tools/drawio_template_transform.py
Normal file
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
drawio template transform — surgical edits on Oracle reference architecture
|
||||
.drawio files so we can reshape them into customer-specific diagrams without
|
||||
losing Oracle's visual polish (multi-cell stencils, palette, proper layout).
|
||||
|
||||
Typical workflow:
|
||||
|
||||
t = DrawioTemplate("kb/diagram/assets/archcenter-refs/.../in-region-dataguard.drawio")
|
||||
# relabel the title
|
||||
title_id = t.find_label("Oracle Exadata")[0]
|
||||
t.set_label(title_id, "MELI Fraud — As-Is")
|
||||
# clone an Exadata-box subtree to add a third DB
|
||||
new_ids = t.clone_subtree(exa_box_id, dx=300)
|
||||
t.save("out.drawio")
|
||||
|
||||
Key ops:
|
||||
- clone_subtree(cell_id, dx, dy) — deep copy with fresh IDs; dx/dy shifts
|
||||
the copy and every descendant's absolute geometry.
|
||||
- delete_subtree(cell_id) — remove a cell and every descendant.
|
||||
- set_label / set_style / move / set_geometry — surgical edits.
|
||||
- add_cell — inject a new raw cell (text block, rectangle, edge).
|
||||
|
||||
Cells without explicit ``id`` in Oracle's exports keep auto-generated integer
|
||||
ids once loaded; clones always get prefixed globally-unique ids.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class DrawioTemplate:
|
||||
"""A loaded .drawio file with surgical edit helpers."""
|
||||
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.tree = ET.parse(self.path)
|
||||
self.root = self.tree.getroot()
|
||||
self.model_root = self.root.find(".//root")
|
||||
if self.model_root is None:
|
||||
raise ValueError(f"No <root> element in {path}")
|
||||
self._next_synth = 10000
|
||||
self._ensure_ids()
|
||||
self._index()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Index / ID management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_ids(self):
|
||||
"""Oracle exports often omit ``id`` on ordinary cells (only the root
|
||||
0/1 cells carry ids). For surgical work we need every cell to be
|
||||
addressable, so we assign synthetic ids to anything missing one."""
|
||||
for c in self.model_root.iter("mxCell"):
|
||||
if not c.get("id"):
|
||||
c.set("id", f"syn{self._next_synth}")
|
||||
self._next_synth += 1
|
||||
|
||||
def _index(self):
|
||||
self.cells: dict[str, ET.Element] = {
|
||||
c.get("id"): c for c in self.model_root.iter("mxCell")
|
||||
}
|
||||
# Reverse index: parent -> [children]
|
||||
self.children: dict[str, list[str]] = {}
|
||||
for cid, c in self.cells.items():
|
||||
p = c.get("parent") or ""
|
||||
self.children.setdefault(p, []).append(cid)
|
||||
|
||||
def reindex(self):
|
||||
self._index()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookup helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def find_label(self, contains: str, case_sensitive: bool = False) -> list[str]:
|
||||
"""Return ids of cells whose `value` text contains `contains`
|
||||
(HTML tags stripped)."""
|
||||
hits = []
|
||||
needle = contains if case_sensitive else contains.lower()
|
||||
for cid, c in self.cells.items():
|
||||
raw = c.get("value") or ""
|
||||
clean = re.sub(r"<[^>]+>", " ", raw)
|
||||
clean = re.sub(r"&[a-z]+;", " ", clean)
|
||||
clean = re.sub(r"\s+", " ", clean).strip()
|
||||
hay = clean if case_sensitive else clean.lower()
|
||||
if needle in hay:
|
||||
hits.append(cid)
|
||||
return hits
|
||||
|
||||
def find_style(self, contains: str) -> list[str]:
|
||||
return [cid for cid, c in self.cells.items()
|
||||
if contains in (c.get("style") or "")]
|
||||
|
||||
def descendants(self, cell_id: str, include_self: bool = True) -> list[str]:
|
||||
"""Return cell_id and every descendant (BFS) — ordered so that
|
||||
parents appear before their children."""
|
||||
order: list[str] = []
|
||||
if include_self:
|
||||
order.append(cell_id)
|
||||
queue = [cell_id]
|
||||
seen = {cell_id}
|
||||
while queue:
|
||||
cur = queue.pop(0)
|
||||
for child in self.children.get(cur, []):
|
||||
if child not in seen:
|
||||
seen.add(child)
|
||||
order.append(child)
|
||||
queue.append(child)
|
||||
return order
|
||||
|
||||
def subtree_bbox(self, cell_id: str) -> tuple[float, float, float, float]:
|
||||
"""Absolute bounding box (x, y, right, bottom) of a subtree.
|
||||
Assumes drawio's relative-to-parent coordinates; walks up to get
|
||||
absolute positions."""
|
||||
def abs_xy(cid: str) -> tuple[float, float]:
|
||||
x = y = 0.0
|
||||
cur = cid
|
||||
while cur and cur in self.cells:
|
||||
c = self.cells[cur]
|
||||
geo = c.find("mxGeometry")
|
||||
if geo is not None:
|
||||
try:
|
||||
x += float(geo.get("x") or 0)
|
||||
y += float(geo.get("y") or 0)
|
||||
except ValueError:
|
||||
pass
|
||||
parent = c.get("parent") or ""
|
||||
if parent in ("0", "1", ""):
|
||||
break
|
||||
cur = parent
|
||||
return x, y
|
||||
|
||||
xs = []
|
||||
ys = []
|
||||
rxs = []
|
||||
bys = []
|
||||
for cid in self.descendants(cell_id):
|
||||
c = self.cells[cid]
|
||||
geo = c.find("mxGeometry")
|
||||
if geo is None:
|
||||
continue
|
||||
x, y = abs_xy(cid)
|
||||
try:
|
||||
w = float(geo.get("width") or 0)
|
||||
h = float(geo.get("height") or 0)
|
||||
except ValueError:
|
||||
w = h = 0
|
||||
xs.append(x); ys.append(y); rxs.append(x + w); bys.append(y + h)
|
||||
if not xs:
|
||||
return 0, 0, 0, 0
|
||||
return min(xs), min(ys), max(rxs), max(bys)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mutation: clone / delete
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def clone_subtree(
|
||||
self,
|
||||
cell_id: str,
|
||||
dx: float = 0,
|
||||
dy: float = 0,
|
||||
id_prefix: Optional[str] = None,
|
||||
) -> dict[str, str]:
|
||||
"""Deep-copy a subtree, assign fresh ids, shift top-level geometry by
|
||||
(dx, dy). Returns a map old_id → new_id so callers can address the
|
||||
clone afterwards."""
|
||||
if id_prefix is None:
|
||||
id_prefix = f"c{uuid4().hex[:6]}_"
|
||||
old_ids = self.descendants(cell_id)
|
||||
id_map = {old: f"{id_prefix}{old}" for old in old_ids}
|
||||
|
||||
new_cells: list[ET.Element] = []
|
||||
for old in old_ids:
|
||||
src = self.cells[old]
|
||||
dup = copy.deepcopy(src)
|
||||
dup.set("id", id_map[old])
|
||||
# Rewrite parent / source / target if they point into the subtree
|
||||
for attr in ("parent", "source", "target"):
|
||||
val = dup.get(attr)
|
||||
if val in id_map:
|
||||
dup.set(attr, id_map[val])
|
||||
new_cells.append(dup)
|
||||
|
||||
# Shift the top-level (root of the clone) geometry so the copy
|
||||
# doesn't overlap the original.
|
||||
if new_cells and (dx or dy):
|
||||
top = new_cells[0]
|
||||
geo = top.find("mxGeometry")
|
||||
if geo is not None:
|
||||
try:
|
||||
gx = float(geo.get("x") or 0)
|
||||
gy = float(geo.get("y") or 0)
|
||||
geo.set("x", str(gx + dx))
|
||||
geo.set("y", str(gy + dy))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for nc in new_cells:
|
||||
self.model_root.append(nc)
|
||||
self._index()
|
||||
return id_map
|
||||
|
||||
def delete_subtree(self, cell_id: str):
|
||||
doomed = set(self.descendants(cell_id))
|
||||
for cid in list(doomed):
|
||||
cell = self.cells.get(cid)
|
||||
if cell is not None:
|
||||
self.model_root.remove(cell)
|
||||
self._index()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mutation: attributes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_label(self, cell_id: str, text: str):
|
||||
self.cells[cell_id].set("value", text)
|
||||
|
||||
def set_style(self, cell_id: str, style: str):
|
||||
self.cells[cell_id].set("style", style)
|
||||
|
||||
def patch_style(self, cell_id: str, **kv):
|
||||
"""Merge key=value pairs into the cell's style string (overwriting
|
||||
any existing key)."""
|
||||
cell = self.cells[cell_id]
|
||||
style = cell.get("style") or ""
|
||||
for k, v in kv.items():
|
||||
if re.search(rf"{re.escape(k)}=[^;]+", style):
|
||||
style = re.sub(rf"{re.escape(k)}=[^;]+", f"{k}={v}", style)
|
||||
else:
|
||||
if style and not style.endswith(";"):
|
||||
style += ";"
|
||||
style += f"{k}={v};"
|
||||
cell.set("style", style)
|
||||
|
||||
def move(self, cell_id: str, dx: float, dy: float):
|
||||
geo = self.cells[cell_id].find("mxGeometry")
|
||||
if geo is None:
|
||||
return
|
||||
try:
|
||||
gx = float(geo.get("x") or 0)
|
||||
gy = float(geo.get("y") or 0)
|
||||
except ValueError:
|
||||
return
|
||||
geo.set("x", str(gx + dx))
|
||||
geo.set("y", str(gy + dy))
|
||||
|
||||
def set_geometry(
|
||||
self,
|
||||
cell_id: str,
|
||||
x: Optional[float] = None,
|
||||
y: Optional[float] = None,
|
||||
w: Optional[float] = None,
|
||||
h: Optional[float] = None,
|
||||
):
|
||||
geo = self.cells[cell_id].find("mxGeometry")
|
||||
if geo is None:
|
||||
return
|
||||
if x is not None: geo.set("x", str(x))
|
||||
if y is not None: geo.set("y", str(y))
|
||||
if w is not None: geo.set("width", str(w))
|
||||
if h is not None: geo.set("height", str(h))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Additions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_cell(
|
||||
self,
|
||||
value: str = "",
|
||||
style: str = "",
|
||||
parent: str = "1",
|
||||
x: float = 0,
|
||||
y: float = 0,
|
||||
w: float = 120,
|
||||
h: float = 40,
|
||||
vertex: bool = True,
|
||||
edge: bool = False,
|
||||
source: Optional[str] = None,
|
||||
target: Optional[str] = None,
|
||||
) -> str:
|
||||
cid = f"add_{uuid4().hex[:10]}"
|
||||
cell = ET.SubElement(self.model_root, "mxCell")
|
||||
cell.set("id", cid)
|
||||
cell.set("value", value)
|
||||
cell.set("style", style)
|
||||
cell.set("parent", parent)
|
||||
if vertex and not edge:
|
||||
cell.set("vertex", "1")
|
||||
if edge:
|
||||
cell.set("edge", "1")
|
||||
if source: cell.set("source", source)
|
||||
if target: cell.set("target", target)
|
||||
geo = ET.SubElement(cell, "mxGeometry")
|
||||
if edge:
|
||||
geo.set("relative", "1")
|
||||
else:
|
||||
geo.set("x", str(x))
|
||||
geo.set("y", str(y))
|
||||
geo.set("width", str(w))
|
||||
geo.set("height", str(h))
|
||||
geo.set("as", "geometry")
|
||||
self._index()
|
||||
return cid
|
||||
|
||||
def add_text(self, text: str, x: float, y: float, w: float, h: float,
|
||||
font_size: int = 14, font_color: str = "#312D2A",
|
||||
bold: bool = False, parent: str = "1") -> str:
|
||||
style = (
|
||||
f"text;html=1;align=left;verticalAlign=middle;whiteSpace=wrap;"
|
||||
f"fontFamily=Oracle Sans;fontSize={font_size};fontColor={font_color};"
|
||||
f"{'fontStyle=1;' if bold else ''}"
|
||||
f"strokeColor=none;fillColor=none;"
|
||||
)
|
||||
return self.add_cell(text, style, parent, x, y, w, h)
|
||||
|
||||
def add_banner(self, text: str, x: float, y: float, w: float, h: float = 36,
|
||||
color: str = "#C74634", font_color: str = "#FCFBFA") -> str:
|
||||
style = (
|
||||
f"rounded=1;whiteSpace=wrap;html=1;fillColor={color};strokeColor=none;"
|
||||
f"fontColor={font_color};fontSize=16;fontStyle=1;fontFamily=Oracle Sans;"
|
||||
f"align=center;verticalAlign=middle;arcSize=4;"
|
||||
)
|
||||
return self.add_cell(text, style, "1", x, y, w, h)
|
||||
|
||||
def add_edge(self, source: str, target: str, label: str = "",
|
||||
stroke: str = "#312D2A", dashed: bool = False) -> str:
|
||||
style = (
|
||||
f"endArrow=open;endFill=0;startArrow=none;html=1;"
|
||||
f"strokeColor={stroke};strokeWidth=1.5;rounded=1;"
|
||||
f"edgeStyle=orthogonalEdgeStyle;endSize=8;"
|
||||
f"fontFamily=Oracle Sans;fontSize=11;fontColor={stroke};"
|
||||
f"labelBackgroundColor=#FFFFFF;"
|
||||
f"{'dashed=1;dashPattern=8 4;' if dashed else ''}"
|
||||
)
|
||||
return self.add_cell(label, style, "1", 0, 0, 0, 0,
|
||||
vertex=False, edge=True,
|
||||
source=source, target=target)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Save
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save(self, path: str | Path):
|
||||
out = Path(path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.tree.write(out, encoding="unicode", xml_declaration=False)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Convenience: list the cells in a template for exploration.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def describe(path: str | Path, max_cells: int = 200) -> None:
|
||||
t = DrawioTemplate(path)
|
||||
print(f"{path}: {len(t.cells)} cells")
|
||||
for i, (cid, c) in enumerate(t.cells.items()):
|
||||
if i >= max_cells:
|
||||
print(f" ... (+{len(t.cells)-i} more)")
|
||||
break
|
||||
val = (c.get("value") or "").strip()
|
||||
val_clean = re.sub(r"<[^>]+>", " ", val)
|
||||
val_clean = re.sub(r"\s+", " ", val_clean).strip()
|
||||
style = c.get("style") or ""
|
||||
style_short = (
|
||||
"stencil" if "shape=stencil" in style else
|
||||
"mxgraph" if "shape=mxgraph" in style else
|
||||
"image" if "shape=image" in style else
|
||||
"edge" if c.get("edge") == "1" else
|
||||
"cont" if "container=1" in style else
|
||||
"text" if "text;" in style or "align=" in style else
|
||||
"v" if c.get("vertex") == "1" else
|
||||
"-"
|
||||
)
|
||||
geo = c.find("mxGeometry")
|
||||
xy = ""
|
||||
if geo is not None:
|
||||
xy = f"x={geo.get('x','-')} y={geo.get('y','-')} w={geo.get('width','-')} h={geo.get('height','-')}"
|
||||
print(f" {cid:>8} p={c.get('parent','-'):<8} {style_short:<7} {val_clean[:48]:<50} {xy}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
describe(sys.argv[1])
|
||||
else:
|
||||
print("Usage: python tools/drawio_template_transform.py <path-to-template.drawio>")
|
||||
Reference in New Issue
Block a user