Import sap-architecture skill

This commit is contained in:
2026-07-24 20:47:16 +00:00
commit f3699e6f43
121 changed files with 127110 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
#!/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 <diagram name="..."> 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 <diagram name=...> 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 <diagram> 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())