#!/usr/bin/env python3 """Scaffold a new SAP architecture diagram by copying the closest reference template. This script enforces the single most important rule of the skill: never draw from scratch — always start from a pristine SAP reference template. It combines `select_reference.py` (rank candidates) with a copy step: 1. Rank bundled templates against the request text. 2. Pick the top match (or honor an explicit --template path). 3. Copy it to the destination, preserving canvas, zones, palette, fonts. 4. Optionally rename the diagram name and the title-band text. After scaffolding, the LLM should make minimal label edits to fit the request, then run autofix.py / validate.py / compare.py. Usage: scaffold_diagram.py "MCP client calling BTP via Cloud Connector" --out docs/diagram.drawio scaffold_diagram.py "Agentic AI on BTP with Joule" --out docs/agentic-ai.drawio scaffold_diagram.py --template ac_RA0029_AgenticAI_root.drawio --out docs/foo.drawio "..." scaffold_diagram.py --top 5 --dry-run "Agentic AI on BTP" Exit code: 0 — file scaffolded (or --dry-run printed candidates) 1 — error 2 — usage """ from __future__ import annotations import argparse import json import shutil import sys import xml.etree.ElementTree as ET from pathlib import Path THIS_DIR = Path(__file__).resolve().parent SCRIPTS_DIR = THIS_DIR ASSETS_DIR = THIS_DIR.parent / "assets" / "reference-examples" # The external corpus cache lives outside the skill. In the original plugin # repo that was 4 levels above scripts/; an installed skill may sit at any # depth, so fall back to the current working directory (where this skill's # docs say caches belong) instead of crashing on a shallow path. try: REPO_ROOT = THIS_DIR.parents[4] except IndexError: REPO_ROOT = Path.cwd() _EXTERNAL_CANDIDATES = [ Path.cwd() / ".cache" / "external" / "sap-btp-reference-architectures", Path.cwd() / ".cache" / "external" / "teched2023-XP286v", REPO_ROOT / ".cache" / "external" / "sap-btp-reference-architectures", REPO_ROOT / ".cache" / "external" / "teched2023-XP286v", ] EXTERNAL_REFERENCE_ROOTS = list(dict.fromkeys(p.resolve() for p in _EXTERNAL_CANDIDATES)) sys.path.insert(0, str(SCRIPTS_DIR)) import select_reference # type: ignore[import-not-found] def reference_pool(include_external: bool = False) -> list[Path]: refs = sorted(ASSETS_DIR.rglob("*.drawio")) if include_external: for root in EXTERNAL_REFERENCE_ROOTS: if root.exists(): refs.extend(sorted(root.rglob("*.drawio"))) return sorted(dict.fromkeys(refs)) def rank_candidates(query: str, top: int, *, include_external: bool = False) -> list[select_reference.Candidate]: refs = reference_pool(include_external) return sorted( (select_reference.score(p, query) for p in refs), key=lambda c: (-c.score, c.path), )[:top] def rename_diagram(path: Path, new_name: str) -> bool: """Update the first attribute. Returns True on change.""" text = path.read_text(encoding="utf-8") try: root = ET.fromstring(text) except ET.ParseError: return False diagram = root.find(".//diagram") if diagram is None: return False diagram.set("name", new_name) ET.register_namespace("", "") path.write_text(ET.tostring(root, encoding="unicode"), encoding="utf-8") return True def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("description", nargs="*", help="diagram request; stdin if omitted") ap.add_argument( "-o", "--out", dest="destination", type=Path, help="path to the scaffolded .drawio file (omit with --dry-run)", ) ap.add_argument("--template", help="explicit template filename (e.g. ac_RA0029_AgenticAI_root.drawio)") ap.add_argument("--top", type=int, default=5, help="show this many ranked candidates") ap.add_argument("--dry-run", action="store_true", help="don't copy; just print top candidates") ap.add_argument("--diagram-name", help="rename the attribute after copy") ap.add_argument( "--include-external-sap-references", action="store_true", help="also rank cached official SAP reference architectures under .cache/external when present", ) ap.add_argument("--json", action="store_true") ap.add_argument("--force", action="store_true", help="overwrite destination if it exists") args = ap.parse_args() query = " ".join(args.description).strip() or sys.stdin.read().strip() if not query and not args.template: print("description or --template required", file=sys.stderr) return 2 if not ASSETS_DIR.exists(): print(f"{ASSETS_DIR}: reference directory not found", file=sys.stderr) return 1 chosen: Path | None = None candidates: list[select_reference.Candidate] = [] if args.template: pool = reference_pool(args.include_external_sap_references) candidate_path = (ASSETS_DIR / args.template).resolve() if not candidate_path.exists(): # Fallback: case-insensitive match by stem or filename target = args.template.lower() for p in pool: if p.name.lower() == target or p.stem.lower() == target.removesuffix(".drawio"): candidate_path = p break if not candidate_path.exists(): scope = "bundled and cached external references" if args.include_external_sap_references else str(ASSETS_DIR) print(f"--template {args.template!r}: not found in {scope}", file=sys.stderr) return 1 chosen = candidate_path else: candidates = rank_candidates(query, args.top, include_external=args.include_external_sap_references) if not candidates: print("no candidates found", file=sys.stderr) return 1 chosen = Path(candidates[0].path) if args.dry_run or args.destination is None: if args.json: payload = { "query": query, "chosen": str(chosen) if chosen else None, "candidates": [ {"path": c.path, "score": c.score, "reasons": c.reasons[:3]} for c in candidates ], } print(json.dumps(payload, indent=2)) else: print(f"query : {query}") print(f"chosen : {chosen}") if candidates: print(f"top {len(candidates)} candidates:") for i, c in enumerate(candidates, 1): print(f" {i}. {c.score:5.1f} {Path(c.path).name}") for reason in c.reasons[:2]: print(f" - {reason}") return 0 if chosen is None: print("no template chosen", file=sys.stderr) return 1 dest = args.destination.resolve() if dest.exists() and not args.force: print(f"{dest}: already exists (use --force to overwrite)", file=sys.stderr) return 1 dest.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(chosen, dest) renamed = False if args.diagram_name: renamed = rename_diagram(dest, args.diagram_name) if args.json: payload = { "query": query, "template": str(chosen), "destination": str(dest), "renamed_diagram": bool(renamed), "candidates": [ {"path": c.path, "score": c.score, "reasons": c.reasons[:3]} for c in candidates ], } print(json.dumps(payload, indent=2)) else: print(f"scaffolded {dest} from {chosen.name}") if args.diagram_name and not renamed: print(f"warning: --diagram-name {args.diagram_name!r} did not match a element") # Print the SAP design recipe of the chosen template — the patterns # the LLM (or human) should preserve when relabeling. recipe = _load_recipe(chosen) if recipe: print() print(f"📐 SAP design recipe of {chosen.name} — preserve these patterns when editing:") struct = recipe.get("structure_summary", {}) if struct: print( f" structure : {struct.get('top_level_zones', 0)} top zones, " f"{struct.get('nested_zones', 0)} nested, " f"{struct.get('cards', 0)} cards, " f"{struct.get('icons', 0)} icons, " f"{struct.get('pills', 0)} pills, " f"{struct.get('edges', 0)} edges" ) if recipe.get("icon_sizes"): sizes = ", ".join(f"{n}×{s}" for s, n in list(recipe["icon_sizes"].items())[:4]) print(f" icon sizes: {sizes} (do NOT exceed 48×48 unless ref does)") if recipe.get("pill_vocab"): vocab = ", ".join(f"{p!r}" for p in recipe["pill_vocab"][:8]) print(f" pill vocab: {vocab}") eq = recipe.get("edge_quality", {}) if eq.get("total"): print( f" edges : {eq['total']} total, " f"{eq.get('with_anchors', 0)} use entryX/exitX anchors, " f"{eq.get('orthogonal', 0)} orthogonalEdgeStyle" ) top_zones = [z for z in (recipe.get("zones") or []) if z.get("parent_id") in (None, "1")] if top_zones: summary = "; ".join( f"{(z.get('label') or '(unlabeled)').strip()[:30]} [{z.get('color_role', '?')}]" for z in top_zones[:5] ) print(f" top zones : {summary}") if recipe.get("detected_patterns"): print(f" patterns : {', '.join(recipe['detected_patterns'][:6])}") if candidates: print() print("alternative templates (open one if the chosen one is the wrong family):") for i, c in enumerate(candidates[: args.top], 1): print(f" {i}. {c.score:5.1f} {Path(c.path).name}") print() print("Next steps:") print(f" 1. Read the recipe above. Edit {dest.name} surgically: change labels and add/swap services, but keep canvas/zones/palette/pills exactly as the SAP template defines.") print(f" 2. python3 {SCRIPTS_DIR}/autofix.py --write {dest}") print(f" 3. python3 {SCRIPTS_DIR}/validate.py {dest}") print(f" 4. python3 {SCRIPTS_DIR}/iterate.py {dest} ← shows score + visual feedback for nudge mode") return 0 def _load_recipe(chosen: Path) -> dict | None: """Load the chosen template's deep design profile from the precomputed registry.""" registry_path = ASSETS_DIR / "template-profiles.json" if not registry_path.exists(): return None try: reg = json.loads(registry_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None return (reg.get("templates") or {}).get(chosen.name) if __name__ == "__main__": sys.exit(main())