Files
sap-architecture/scripts/select_reference.py
T
2026-07-24 20:47:16 +00:00

524 lines
20 KiB
Python

#!/usr/bin/env python3
"""Rank SAP reference templates for a natural-language diagram request.
This is intentionally simple and dependency-free. The goal is not semantic
search; it is to stop the author from guessing which SAP template to start
from. The script scores filenames plus visible labels in each .drawio file,
adds scenario-family boosts, and prints the best candidates.
Usage:
select_reference.py "CAP app with XSUAA and HANA Cloud"
echo "Joule agent calls S/4HANA through MCP" | select_reference.py
select_reference.py --top 10 --json "Business Data Cloud with Databricks"
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass, field
from functools import lru_cache
from pathlib import Path
STOPWORDS = {
"a", "an", "and", "app", "apps", "arch", "architecture", "as", "at", "between",
"btp", "by", "cloud", "create", "diagram", "draw", "for", "from", "in",
"into", "is", "l0", "l1", "l2", "landscape", "make", "my", "of", "on", "or",
"ref", "reference", "sap", "show", "solution", "the", "to", "using",
"via", "with",
# Generic prompt framing words. Keeping these out avoids accidental matches
# such as "Architecture Center" -> Task Center.
"center", "convention", "conventions", "horizon", "icon", "icons",
"label", "labels", "palette", "preserve", "readable", "rhythm",
"semantic", "semantics", "style", "template", "visual", "zone",
"zones",
}
TOKEN_CANONICAL = {
"adminstrator": "administrator",
"admin": "administrator",
"plaforms": "platforms",
"provisoning": "provisioning",
"ressources": "resources",
"s": "s4hana",
"4hana": "s4hana",
}
SCENARIOS = [
{
"name": "identity-authentication",
"query": {"ias", "identity", "authentication", "authn", "oauth", "oidc", "saml", "single", "sign", "sso", "xsuaa", "jwt", "trust"},
"reference": {"identity", "authentication", "authn", "iam", "xsuaa", "ias", "joule_iam"},
"boost": 18,
},
{
"name": "identity-authorization",
"query": {"authorization", "authz", "role", "roles", "scope", "scopes", "permission", "permissions", "rbac"},
"reference": {"authorization", "authz", "iam", "identity"},
"boost": 18,
},
{
"name": "private-connectivity",
"query": {"private", "privatelink", "link", "connectivity", "connector", "cloudconnector", "scc", "onprem", "premise", "principal", "principalpropagation", "propagation", "odata"},
"reference": {"privatelink", "private", "connector", "cloudconnector", "connectivity", "odata", "e2b"},
"boost": 17,
},
{
"name": "agentic-ai-mcp",
"query": {"agent", "agents", "agentic", "mcp", "a2a", "tool", "tools", "joule", "copilot", "cline", "llm"},
"reference": {"agent", "agentic", "mcp", "a2a", "joule", "genai", "generative"},
"boost": 17,
},
{
"name": "generative-ai-rag",
"query": {"genai", "generative", "rag", "retrieval", "semantic", "embedding", "embeddings", "vector", "prompt"},
"reference": {"genai", "generative", "rag", "semantic", "agent2agent"},
"boost": 16,
},
{
"name": "business-data-cloud",
"query": {"bdc", "business", "data", "databricks", "snowflake", "hana", "datasphere", "analytics", "bw"},
"reference": {"bdc", "businessdatacloud", "databricks", "hyperscalerdata", "dataintegration"},
"boost": 15,
},
{
"name": "event-driven-integration",
"query": {"event", "events", "eventmesh", "eventing", "eda", "queue", "queues", "kafka", "message", "messages"},
"reference": {"eventdriven", "eda", "event", "integration", "e2b", "a2aintegration", "b2bintegration"},
"boost": 15,
},
{
"name": "resiliency",
"query": {"resiliency", "resilience", "multi", "region", "availability", "az", "failover", "load", "balancer", "disaster"},
"reference": {"resiliency", "multiregion", "multiaz", "loadbalancer"},
"boost": 14,
},
{
"name": "multitenant-saas-cap",
"query": {"cap", "saas", "tenant", "tenants", "multitenant", "multitenancy", "subscription"},
"reference": {"susaas", "cap", "multitenant"},
"boost": 14,
},
{
"name": "task-workflow-workzone",
"query": {"task", "tasks", "inbox", "workflow", "workzone", "work", "zone", "launchpad", "process", "automation", "spa"},
"reference": {"taskcenter", "buildworkzone", "buildprocessautomation"},
"boost": 14,
},
{
"name": "devops",
"query": {"devops", "cicd", "ci", "cd", "pipeline", "pipelines", "transport", "deploy", "deployment"},
"reference": {"devops"},
"boost": 20,
},
{
"name": "security-operations",
"query": {"siem", "soar", "threat", "detection", "audit", "security", "etd"},
"reference": {"siem", "soar", "etd"},
"boost": 20,
},
{
"name": "federated-ml",
"query": {"federated", "ml", "machine", "learning", "training", "model", "models", "aicore", "ai"},
"reference": {"federated", "ml", "machine", "learning", "aicore", "ai"},
"boost": 22,
},
{
"name": "edge-integration-cell",
"query": {"edge", "eic", "cell", "pipo", "pi", "po", "runtime", "migration"},
"reference": {"edge", "eic", "cell", "pipo", "integration"},
"boost": 20,
},
{
"name": "successfactors",
"query": {"successfactors", "hxm", "bizx", "employee", "recruiting", "module", "modules", "talent"},
"reference": {"successfactors", "hxm", "bizx", "recruiting"},
"boost": 22,
},
]
@dataclass
class Candidate:
path: str
score: float
reasons: list[str] = field(default_factory=list)
token_hits: list[str] = field(default_factory=list)
metadata_title: str | None = None
metadata_tags: list[str] = field(default_factory=list)
def default_reference_dir() -> Path:
return Path(__file__).resolve().parents[1] / "assets" / "reference-examples"
def metadata_path_for(reference_dir: Path) -> Path:
return reference_dir / "template-metadata.json"
@lru_cache(maxsize=8)
def load_metadata(reference_dir_text: str) -> dict:
path = metadata_path_for(Path(reference_dir_text))
if not path.exists():
return {"templates": {}}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"templates": {}}
def template_metadata(path: Path) -> dict:
metadata = load_metadata(str(path.parent.resolve()))
return metadata.get("templates", {}).get(path.name, {})
def metadata_search_text(path: Path, metadata: dict | None = None) -> str:
metadata = metadata if metadata is not None else template_metadata(path)
if not metadata:
return ""
parts: list[str] = []
for key in ("title", "summary", "family", "level", "domain"):
val = metadata.get(key)
if isinstance(val, str):
parts.append(val)
for key in ("aliases", "tags", "products", "fallback_templates"):
vals = metadata.get(key)
if isinstance(vals, list):
parts.extend(str(v) for v in vals)
return " ".join(parts)
def explicit_metadata_title(path: Path) -> str | None:
val = template_metadata(path).get("title")
return str(val) if val else None
def split_words(text: str) -> list[str]:
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
text = re.sub(r"([a-z])([A-Z])", r"\1 \2", text)
text = text.replace("_", " ").replace("-", " ").replace("/", " ")
return [t.lower() for t in re.findall(r"[A-Za-z0-9]+", text)]
def tokens(text: str) -> set[str]:
out: set[str] = set()
for t in split_words(text):
t = TOKEN_CANONICAL.get(t, t)
if len(t) >= 2 and t not in STOPWORDS:
out.add(t)
joined = "".join(split_words(text))
for compact in (
"xsuaa",
"privatelink",
"workzone",
"taskcenter",
"eventmesh",
"multiaz",
"multiregion",
"businessdatacloud",
"successfactors",
"cloudconnector",
"principalpropagation",
):
if compact in joined:
out.add(compact)
if "businessdatacloud" in out:
out.add("bdc")
if "aicore" in joined or {"ai", "core"} <= out:
out.add("aicore")
if "cloudconnector" in joined:
out.add("cloudconnector")
if "principalpropagation" in joined:
out.add("principalpropagation")
if "s4hana" in joined or "4hana" in out or {"s4", "hana"} <= out:
out.add("s4hana")
if {"ci", "cd"} <= out:
out.add("cicd")
if {"pi", "po"} <= out:
out.add("pipo")
if {"edge", "integration", "cell"} <= out:
out.add("eic")
return out
def drawio_text(path: Path) -> str:
raw = path.read_text(encoding="utf-8", errors="ignore")
parts = [path.stem]
try:
root = ET.fromstring(raw)
for elem in root.iter():
for attr in ("name", "label", "value"):
val = elem.get(attr)
if val:
parts.append(val)
except ET.ParseError:
parts.append(raw[:10000])
visible = html.unescape(" ".join(parts))
visible = re.sub(r"<br\s*/?>", " ", visible, flags=re.I)
visible = re.sub(r"<[^>]+>", " ", visible)
return visible
def phrase_hits(query: str, phrases: list[str]) -> list[str]:
query_clean = " ".join(split_words(query))
hits: list[str] = []
for phrase in phrases:
phrase_clean = " ".join(split_words(str(phrase)))
if len(phrase_clean) >= 4 and phrase_clean in query_clean:
hits.append(str(phrase))
return hits
def exact_stem_mentioned(path: Path, query: str) -> bool:
query_lower = query.lower()
if path.name.lower() in query_lower:
return True
stem_words = " ".join(split_words(path.stem))
query_words = " ".join(split_words(query))
return len(stem_words) >= 8 and stem_words in query_words
def primary_visual_fallback_mentioned(path: Path, query: str) -> bool:
match = re.search(r"Primary SAP visual fallback template:\s*([A-Za-z0-9_.-]+)", query, flags=re.I)
if not match:
return False
return match.group(1).strip().rstrip(".").lower() == path.name.lower()
def explicit_level(query: str) -> str | None:
m = re.search(r"\bL([012])\b", query, flags=re.I)
return f"l{m.group(1)}" if m else None
def explicit_family(query: str) -> str | None:
m = re.search(r"\bRA(\d{4})\b", query, flags=re.I)
return f"ra{m.group(1)}" if m else None
def score(path: Path, query: str) -> Candidate:
q_tokens = tokens(query)
metadata = template_metadata(path)
doc_text = drawio_text(path)
meta_text = metadata_search_text(path, metadata)
d_tokens = tokens(doc_text)
m_tokens = tokens(meta_text)
filename_tokens = tokens(path.stem)
combined_tokens = d_tokens | m_tokens
token_hits = sorted(q_tokens & combined_tokens)
value = len(token_hits) * 2.0
reasons: list[str] = []
if token_hits:
reasons.append("token overlap: " + ", ".join(token_hits[:8]))
filename_hits = sorted(q_tokens & filename_tokens)
if filename_hits:
value += len(filename_hits) * 4.0
reasons.append("filename match: " + ", ".join(filename_hits[:8]))
if exact_stem_mentioned(path, query):
value += 70
reasons.append("exact template filename mentioned (+70)")
if primary_visual_fallback_mentioned(path, query):
value += 90
reasons.append("primary visual fallback match (+90)")
meta_hits = sorted(q_tokens & m_tokens)
if meta_hits:
boost = min(36.0, len(meta_hits) * 4.0)
value += boost
reasons.append("metadata match: " + ", ".join(meta_hits[:8]) + f" (+{int(boost)})")
alias_hits = phrase_hits(query, metadata.get("aliases", []) if isinstance(metadata.get("aliases"), list) else [])
if alias_hits:
boost = min(30.0, 12.0 + (len(alias_hits) - 1) * 6.0)
value += boost
reasons.append("alias phrase match: " + ", ".join(alias_hits[:3]) + f" (+{int(boost)})")
title = metadata.get("title")
if isinstance(title, str) and phrase_hits(query, [title]):
value += 22
reasons.append("metadata title phrase match (+22)")
level = explicit_level(query)
family = explicit_family(query)
filename_lower = path.name.lower()
path_lower = str(path).lower()
metadata_family = str(metadata.get("family", "")).lower()
metadata_level = str(metadata.get("level", "")).lower()
if family:
if family in path_lower or family == metadata_family:
value += 24
reasons.append(f"explicit {family.upper()} family match (+24)")
elif re.search(r"\bra\d{4}\b", path_lower):
value -= 8
reasons.append("different reference family penalty (-8)")
else:
value -= 5
reasons.append("different reference source penalty (-5)")
if level:
if level in filename_lower or level == metadata_level:
value += 10
reasons.append(f"explicit {level.upper()} match")
elif re.search(r"_l[012]\b", filename_lower):
value -= 3
elif "_l2" in filename_lower:
value += 3
reasons.append("default L2 preference")
for scenario in SCENARIOS:
q_hit = q_tokens & scenario["query"]
r_hit = filename_tokens & scenario["reference"]
if not r_hit:
r_hit = m_tokens & scenario["reference"]
if q_hit and r_hit:
boost = float(scenario["boost"])
value += boost
reasons.append(f"{scenario['name']} boost (+{int(boost)})")
strong_query_tags = {
"devops",
"federated",
"ml",
"eic",
"pipo",
"siem",
"soar",
"successfactors",
"embodied",
"agentic",
} & q_tokens
if strong_query_tags and not (strong_query_tags & (filename_tokens | m_tokens)):
value -= 10
reasons.append("strong scenario mismatch penalty (-10)")
# MCP/A2A bonus: only fire when the query is *specifically* about agent-to-
# agent or MCP integration (not when it merely mentions an MCP gateway as
# one of many components in a broader Agentic AI scenario). The previous
# rule unconditionally awarded +20 to A2A_MCP whenever "mcp" appeared,
# incorrectly outranking AgenticAI_root for the canonical RA0029 prompt.
if q_tokens & {"mcp", "a2a"} and filename_tokens & {"mcp", "a2a"}:
# Strong agentic-AI / Joule signals indicate the user wants the
# umbrella RA0029 root template, not the A2A/MCP sub-scenario.
broader_agentic_signal = bool(
q_tokens & {"agentic"}
and (q_tokens & {"joule"} or len(q_tokens & {"agent", "agents", "ai"}) >= 2)
)
if not broader_agentic_signal:
value += 20
reasons.append("exact MCP/A2A filename match (+20)")
else:
value += 6
reasons.append("MCP/A2A filename match dampened (broader agentic-AI scenario) (+6)")
# Primary-template preference within a family: when the user query maps
# to a well-known family (e.g. agentic-ai) without a specific sub-scenario
# signal (no explicit "embodied", "procode", "joule studio", etc.), prefer
# the metadata-flagged "primary" template. This correctly routes the
# canonical "Agentic AI on SAP BTP" prompt to AgenticAI_root.
if metadata.get("primary"):
sub_signals = {
"embodied", "robotics", # → EmbodiedAIAgents
"procode", "developer", "code", "vscode", "ide", # → GenAI_ProCode
"studio", # → Joule_Studio variants
"ecosystem", # → JouleAgentsToolsEcosystem
}
# If query has no specific sub-scenario signal, give the primary template a meaningful boost
if not (q_tokens & sub_signals):
value += 14
reasons.append("metadata primary-template boost (+14)")
if q_tokens & {"xsuaa", "oauth", "oidc", "saml"} and (
{"authentication", "authn"} & filename_tokens or "cloud" in filename_lower and "identity" in filename_lower
):
value += 8
reasons.append("exact authentication filename match (+8)")
if q_tokens & {"bdc", "businessdatacloud", "aicore"} and filename_tokens & {"bdc", "businessdatacloud", "aicore"}:
value += 12
reasons.append("exact BDC / AI Core filename match (+12)")
if "aicore" in q_tokens and "aicore" in filename_tokens:
value += 10
reasons.append("exact AI Core filename match (+10)")
if {"agentic", "ai"} <= q_tokens:
root_agentic = path.name == "ac_RA0029_AgenticAI_root.drawio"
embodied_terms = {"embodied", "robotic", "robotics", "physical"}
root_signals = {
"gateway",
"orchestrator",
"capabilities",
"subaccount",
"businessdatacloud",
"successfactors",
"concur",
"s4hana",
"mcp",
} & q_tokens
if root_agentic and not (q_tokens & embodied_terms):
value += 24
reasons.append("generic Agentic AI root boost (+24)")
if len(root_signals) >= 2:
value += 12
reasons.append("Agentic AI root component match (+12)")
if ("embodied" in filename_tokens or "embodied" in m_tokens) and not (q_tokens & embodied_terms):
value -= 18
reasons.append("embodied-specific template penalty (-18)")
if "joule" in filename_tokens and "joule" not in q_tokens:
value -= 12
reasons.append("Joule-specific template penalty (-12)")
if metadata.get("generic") and strong_query_tags:
value -= 12
reasons.append("generic template penalty for specific scenario (-12)")
# Prefer canonical btp_ examples when equally relevant; otherwise prefer
# Architecture Center diagrams with richer scenario labels.
if path.name.startswith("btp_"):
value += 1.0
if not reasons:
reasons.append("weak lexical match; review manually")
return Candidate(
str(path),
round(value, 1),
reasons,
token_hits[:12],
metadata_title=str(title) if title else None,
metadata_tags=list(metadata.get("tags", []))[:12] if isinstance(metadata.get("tags"), list) else [],
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("description", nargs="*", help="diagram request; stdin is used if omitted")
ap.add_argument("--reference-dir", type=Path, default=default_reference_dir())
ap.add_argument("--top", type=int, default=5)
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
query = " ".join(args.description).strip() or sys.stdin.read().strip()
if not query:
print("description required", file=sys.stderr)
return 2
if not args.reference_dir.exists():
print(f"{args.reference_dir}: reference directory not found", file=sys.stderr)
return 2
refs = sorted(args.reference_dir.rglob("*.drawio"))
ranked = sorted((score(p, query) for p in refs), key=lambda c: (-c.score, c.path))[: args.top]
if args.json:
print(json.dumps([asdict(c) for c in ranked], indent=2))
return 0
print(f"query: {query}")
for i, cand in enumerate(ranked, 1):
print(f"{i}. {cand.score:5.1f} {cand.path}")
for reason in cand.reasons[:3]:
print(f" - {reason}")
return 0
if __name__ == "__main__":
sys.exit(main())