269 lines
11 KiB
Python
269 lines
11 KiB
Python
#!/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())
|