initial version

This commit is contained in:
2026-03-18 18:03:44 -03:00
commit 9d269e902b
136 changed files with 28175 additions and 0 deletions

649
tools/feature_matrix_cli.py Normal file
View File

@@ -0,0 +1,649 @@
#!/usr/bin/env python3
"""
OCI Deal Accelerator — ADB Feature Compatibility Matrix CLI
Query, compare, and update the ADB feature compatibility matrix from
the command line.
Usage:
python feature_matrix_cli.py check "auto scaling" adb_s 23ai
python feature_matrix_cli.py compare adb_s exacs 23ai
python feature_matrix_cli.py gaps adb_s 23ai
python feature_matrix_cli.py update "Auto Scaling (OCPU)" adb_s 23ai --status GA --notes "Updated"
python feature_matrix_cli.py export --format markdown
python feature_matrix_cli.py export --format csv
"""
import argparse
import csv
import io
import os
import sys
from datetime import date
from typing import Any, Dict, List, Optional, Tuple
VALID_CONFIDENCE = {"validated", "observed", "reported", "inferred"}
import yaml
# =============================================================================
# Constants
# =============================================================================
MATRIX_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "kb", "compatibility", "adb-feature-matrix.yaml",
)
VALID_STATUSES = {"GA", "GA_CAVEAT", "PREVIEW", "LIMITED", "NOT_AVAIL", "BROKEN", "UNTESTED"}
# ANSI color codes for terminal output
_COLORS = {
"GA": "\033[32m", # green
"GA_CAVEAT": "\033[33m", # yellow
"PREVIEW": "\033[36m", # cyan
"LIMITED": "\033[93m", # bright yellow
"NOT_AVAIL": "\033[31m", # red
"BROKEN": "\033[1;31m", # red bold
"UNTESTED": "\033[90m", # gray
}
_RESET = "\033[0m"
# =============================================================================
# Helpers
# =============================================================================
def _color(status: str) -> str:
"""Wrap a status string with the appropriate ANSI color."""
code = _COLORS.get(status, "")
return f"{code}{status}{_RESET}" if code else status
def _supports_color() -> bool:
"""Return True when stdout is a tty that likely supports ANSI escapes."""
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
def _plain(status: str) -> str:
"""Return the status without color codes."""
return status
def _get_color_fn():
"""Return the appropriate formatter (colored or plain)."""
return _color if _supports_color() else _plain
def load_matrix(path: str) -> Dict[str, Any]:
"""Load the YAML matrix file and return its contents as a dict."""
resolved = os.path.realpath(path)
if not os.path.isfile(resolved):
print(f"Error: Matrix file not found at {resolved}", file=sys.stderr)
sys.exit(1)
with open(resolved, "r", encoding="utf-8") as fh:
try:
data = yaml.safe_load(fh)
except yaml.YAMLError as exc:
print(f"Error: Failed to parse YAML — {exc}", file=sys.stderr)
sys.exit(1)
if not data or "features" not in data:
print("Error: Matrix file is missing 'features' key.", file=sys.stderr)
sys.exit(1)
return data
def save_matrix(path: str, data: Dict[str, Any]) -> None:
"""Write the matrix dict back to YAML, preserving readability."""
resolved = os.path.realpath(path)
with open(resolved, "w", encoding="utf-8") as fh:
yaml.dump(
data,
fh,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=120,
)
def get_version_ids(data: Dict[str, Any]) -> List[str]:
"""Return ordered list of version ids from the matrix."""
return [v["id"] for v in data.get("versions", [])]
def get_deployment_ids(data: Dict[str, Any]) -> List[str]:
"""Return ordered list of deployment type ids from the matrix."""
return [d["id"] for d in data.get("deployment_types", [])]
def get_deployment_name(data: Dict[str, Any], deploy_id: str) -> str:
"""Look up the human-readable name for a deployment id."""
for d in data.get("deployment_types", []):
if d["id"] == deploy_id:
return d.get("name", deploy_id)
return deploy_id
def validate_deployment(data: Dict[str, Any], deploy_id: str) -> None:
"""Exit with an error if the deployment id is not in the matrix."""
valid = get_deployment_ids(data)
if deploy_id not in valid:
print(
f"Error: Unknown deployment type '{deploy_id}'. "
f"Valid types: {', '.join(valid)}",
file=sys.stderr,
)
sys.exit(1)
def validate_version(data: Dict[str, Any], version: str) -> None:
"""Exit with an error if the version is not in the matrix."""
valid = get_version_ids(data)
if version not in valid:
print(
f"Error: Unknown version '{version}'. Valid versions: {', '.join(valid)}",
file=sys.stderr,
)
sys.exit(1)
def find_features(data: Dict[str, Any], query: str) -> List[Dict[str, Any]]:
"""
Find features whose name matches *query* (case-insensitive, partial).
Returns a list of matching feature dicts.
"""
query_lower = query.lower()
return [
f for f in data["features"]
if query_lower in f["name"].lower()
]
def get_cell(feature: Dict[str, Any], deploy: str, version: str) -> Optional[Dict[str, Any]]:
"""Return the matrix cell dict for a feature/deploy/version, or None."""
return feature.get("matrix", {}).get(deploy, {}).get(version)
# =============================================================================
# Library API — importable functions for programmatic use and testing
# =============================================================================
def check(feature_name: str, deployment: str, version: str, matrix_path: str = MATRIX_PATH) -> Dict[str, Any]:
"""Look up a feature/deployment/version and return {status, notes, feature_name}."""
data = load_matrix(matrix_path)
matches = find_features(data, feature_name)
if not matches:
raise LookupError(f"No features matching '{feature_name}'")
if len(matches) > 1:
raise LookupError(
f"Ambiguous feature name '{feature_name}'. Matches: "
+ ", ".join(m["name"] for m in matches)
)
feature = matches[0]
cell = get_cell(feature, deployment, version)
if cell is None:
return {"status": "UNTESTED", "notes": "no entry in matrix", "feature_name": feature["name"]}
return {
"status": cell.get("status", "UNTESTED"),
"notes": cell.get("notes", ""),
"feature_name": feature["name"],
}
def compare(deploy1: str, deploy2: str, version: str, matrix_path: str = MATRIX_PATH) -> List[Dict[str, Any]]:
"""Compare two deployment types. Returns list of dicts with name, status1, status2."""
data = load_matrix(matrix_path)
results = []
for feature in data["features"]:
cell1 = get_cell(feature, deploy1, version)
cell2 = get_cell(feature, deploy2, version)
results.append({
"name": feature["name"],
"status1": cell1["status"] if cell1 else "UNTESTED",
"status2": cell2["status"] if cell2 else "UNTESTED",
})
return results
def gaps(deployment: str, version: str, matrix_path: str = MATRIX_PATH) -> List[Dict[str, Any]]:
"""List features that are NOT_AVAIL, BROKEN, or LIMITED."""
data = load_matrix(matrix_path)
gap_statuses = {"NOT_AVAIL", "BROKEN", "LIMITED"}
results = []
for feature in data["features"]:
cell = get_cell(feature, deployment, version)
if cell is None:
continue
status = cell.get("status", "UNTESTED")
if status in gap_statuses:
results.append({
"name": feature["name"],
"status": status,
"notes": cell.get("notes", ""),
})
return results
def export_markdown(output_path: str, matrix_path: str = MATRIX_PATH) -> None:
"""Export the full matrix as a markdown table to a file."""
data = load_matrix(matrix_path)
versions = get_version_ids(data)
deployments = get_deployment_ids(data)
columns = [(d, v) for d in deployments for v in versions]
col_labels = [f"{d}/{v}" for d, v in columns]
features = data["features"]
feat_width = max((len(f["name"]) for f in features), default=7)
feat_width = max(feat_width, len("Feature"))
col_widths = [max(len(lbl), 10) for lbl in col_labels]
lines = []
header = f"| {'Feature':<{feat_width}} |"
for lbl, w in zip(col_labels, col_widths):
header += f" {lbl:<{w}} |"
lines.append(header)
sep = f"|{'-' * (feat_width + 2)}|"
for w in col_widths:
sep += f"{'-' * (w + 2)}|"
lines.append(sep)
for feature in features:
row = f"| {feature['name']:<{feat_width}} |"
for (d, v), w in zip(columns, col_widths):
cell = get_cell(feature, d, v)
status = cell["status"] if cell else "-"
row += f" {status:<{w}} |"
lines.append(row)
with open(output_path, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
def export_csv(output_path: str, matrix_path: str = MATRIX_PATH) -> None:
"""Export the full matrix as CSV to a file."""
data = load_matrix(matrix_path)
versions = get_version_ids(data)
deployments = get_deployment_ids(data)
columns = [(d, v) for d in deployments for v in versions]
col_labels = [f"{d}/{v}" for d, v in columns]
with open(output_path, "w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["Feature", "Category"] + col_labels)
for feature in data["features"]:
row = [feature["name"], feature.get("category", "")]
for d, v in columns:
cell = get_cell(feature, d, v)
row.append(cell["status"] if cell else "")
writer.writerow(row)
# =============================================================================
# Subcommands (CLI handlers)
# =============================================================================
def cmd_check(args: argparse.Namespace) -> None:
"""Look up a specific feature / deployment / version combo."""
data = load_matrix(MATRIX_PATH)
validate_deployment(data, args.deployment)
validate_version(data, args.version)
matches = find_features(data, args.feature)
if not matches:
print(f"No features matching '{args.feature}'.")
sys.exit(1)
if len(matches) > 1:
print(f"Ambiguous feature name '{args.feature}'. Multiple matches:")
for m in matches:
print(f" - {m['name']}")
print("\nPlease be more specific.")
sys.exit(1)
feature = matches[0]
cell = get_cell(feature, args.deployment, args.version)
cfn = _get_color_fn()
deploy_name = get_deployment_name(data, args.deployment)
print(f"Feature: {feature['name']}")
print(f"Deployment: {deploy_name} ({args.deployment})")
print(f"Version: {args.version}")
print()
if cell is None:
print(f"Status: {cfn('UNTESTED')} (no entry in matrix)")
else:
status = cell.get("status", "UNTESTED")
print(f"Status: {cfn(status)}")
notes = cell.get("notes")
if notes:
print(f"Notes: {notes}")
def cmd_compare(args: argparse.Namespace) -> None:
"""Side-by-side comparison of two deployment types for a version."""
data = load_matrix(MATRIX_PATH)
validate_deployment(data, args.deploy1)
validate_deployment(data, args.deploy2)
validate_version(data, args.version)
cfn = _get_color_fn()
name1 = get_deployment_name(data, args.deploy1)
name2 = get_deployment_name(data, args.deploy2)
# Determine column widths
feature_names = [f["name"] for f in data["features"]]
col_feat = max(len(n) for n in feature_names) + 2
col_status = max(len(name1), len(name2), 12) + 2
header = (
f"{'Feature':<{col_feat}}"
f" {name1:<{col_status}}"
f" {name2:<{col_status}}"
)
print(f"Comparison: {name1} vs {name2} (version {args.version})")
print()
print(header)
print("-" * len(header))
for feature in data["features"]:
cell1 = get_cell(feature, args.deploy1, args.version)
cell2 = get_cell(feature, args.deploy2, args.version)
s1 = cell1["status"] if cell1 else "UNTESTED"
s2 = cell2["status"] if cell2 else "UNTESTED"
# For plain (non-tty) we can just print aligned; for color we need
# to pad BEFORE wrapping with color codes so alignment is correct.
s1_padded = f"{s1:<{col_status}}"
s2_padded = f"{s2:<{col_status}}"
if _supports_color():
s1_display = f"{_COLORS.get(s1, '')}{s1_padded}{_RESET}"
s2_display = f"{_COLORS.get(s2, '')}{s2_padded}{_RESET}"
else:
s1_display = s1_padded
s2_display = s2_padded
print(f"{feature['name']:<{col_feat}} {s1_display} {s2_display}")
def cmd_gaps(args: argparse.Namespace) -> None:
"""List features that are NOT_AVAIL, BROKEN, or LIMITED for a deploy/version."""
data = load_matrix(MATRIX_PATH)
validate_deployment(data, args.deployment)
validate_version(data, args.version)
cfn = _get_color_fn()
deploy_name = get_deployment_name(data, args.deployment)
gap_statuses = {"NOT_AVAIL", "BROKEN", "LIMITED"}
gaps: List[Tuple[str, str, Optional[str]]] = []
for feature in data["features"]:
cell = get_cell(feature, args.deployment, args.version)
if cell is None:
# No entry — could be untested or implicitly not available
continue
status = cell.get("status", "UNTESTED")
if status in gap_statuses:
gaps.append((feature["name"], status, cell.get("notes")))
print(f"Gaps for {deploy_name} ({args.deployment}), version {args.version}:")
print()
if not gaps:
print(" No gaps found — all mapped features are GA, GA_CAVEAT, PREVIEW, or UNTESTED.")
return
name_width = max(len(g[0]) for g in gaps) + 2
for name, status, notes in gaps:
status_display = cfn(status)
line = f" {name:<{name_width}} {status_display}"
if notes:
line += f"{notes}"
print(line)
def cmd_update(args: argparse.Namespace) -> None:
"""Modify a specific cell in the matrix."""
status = args.status.upper()
if status not in VALID_STATUSES:
print(
f"Error: Invalid status '{args.status}'. "
f"Valid statuses: {', '.join(sorted(VALID_STATUSES))}",
file=sys.stderr,
)
sys.exit(1)
data = load_matrix(MATRIX_PATH)
validate_deployment(data, args.deployment)
validate_version(data, args.version)
matches = find_features(data, args.feature)
if not matches:
print(f"Error: No features matching '{args.feature}'.", file=sys.stderr)
sys.exit(1)
if len(matches) > 1:
print(f"Ambiguous feature name '{args.feature}'. Multiple matches:")
for m in matches:
print(f" - {m['name']}")
print("\nPlease use the exact feature name.")
sys.exit(1)
feature = matches[0]
# Ensure nested dicts exist
if "matrix" not in feature:
feature["matrix"] = {}
if args.deployment not in feature["matrix"]:
feature["matrix"][args.deployment] = {}
cell: Dict[str, Any] = {"status": status}
if args.notes:
cell["notes"] = args.notes
# Add contributor attribution if provided
contributor_name = getattr(args, "name", None)
contributor_team = getattr(args, "team", None)
if contributor_name and contributor_team:
contributor = {
"name": contributor_name,
"team": contributor_team,
"date": date.today().isoformat(),
}
confidence = getattr(args, "confidence", None)
if confidence and confidence in VALID_CONFIDENCE:
contributor["confidence"] = confidence
cell["contributor"] = contributor
# Add field_finding_ref if provided
field_ref = getattr(args, "field_finding_ref", None)
if field_ref:
cell["field_finding_ref"] = field_ref
feature["matrix"][args.deployment][args.version] = cell
# Update last_verified at root
data["last_verified"] = date.today().isoformat()
save_matrix(MATRIX_PATH, data)
cfn = _get_color_fn()
print(f"Updated '{feature['name']}' / {args.deployment} / {args.version}:")
print(f" Status: {cfn(status)}")
if args.notes:
print(f" Notes: {args.notes}")
print(f" last_verified set to {data['last_verified']}")
def cmd_export(args: argparse.Namespace) -> None:
"""Export the full matrix as markdown or CSV."""
data = load_matrix(MATRIX_PATH)
versions = get_version_ids(data)
deployments = get_deployment_ids(data)
# Build column headers: each column is deploy + version
columns: List[Tuple[str, str]] = []
for d in deployments:
for v in versions:
columns.append((d, v))
col_labels = [f"{d}/{v}" for d, v in columns]
if args.format == "csv":
_export_csv(data, columns, col_labels)
else:
_export_markdown(data, columns, col_labels)
def _export_markdown(
data: Dict[str, Any],
columns: List[Tuple[str, str]],
col_labels: List[str],
) -> None:
"""Print a markdown table to stdout."""
features = data["features"]
# Column widths
feat_width = max(len(f["name"]) for f in features)
feat_width = max(feat_width, len("Feature"))
col_widths = [max(len(lbl), 10) for lbl in col_labels]
# Header row
header = f"| {'Feature':<{feat_width}} |"
for lbl, w in zip(col_labels, col_widths):
header += f" {lbl:<{w}} |"
print(header)
# Separator
sep = f"|{'-' * (feat_width + 2)}|"
for w in col_widths:
sep += f"{'-' * (w + 2)}|"
print(sep)
# Data rows
for feature in features:
row = f"| {feature['name']:<{feat_width}} |"
for (d, v), w in zip(columns, col_widths):
cell = get_cell(feature, d, v)
status = cell["status"] if cell else "-"
row += f" {status:<{w}} |"
print(row)
def _export_csv(
data: Dict[str, Any],
columns: List[Tuple[str, str]],
col_labels: List[str],
) -> None:
"""Print CSV to stdout."""
output = io.StringIO()
writer = csv.writer(output)
# Header
writer.writerow(["Feature", "Category"] + col_labels)
for feature in data["features"]:
row = [feature["name"], feature.get("category", "")]
for d, v in columns:
cell = get_cell(feature, d, v)
row.append(cell["status"] if cell else "")
writer.writerow(row)
print(output.getvalue(), end="")
# =============================================================================
# CLI entry point
# =============================================================================
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="feature_matrix_cli",
description="Query and update the ADB feature compatibility matrix.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# --- check ---
p_check = subparsers.add_parser(
"check",
help="Look up a specific feature/deployment/version combo.",
)
p_check.add_argument("feature", help="Feature name (case-insensitive, partial match).")
p_check.add_argument("deployment", help="Deployment type id (e.g. adb_s, exacs).")
p_check.add_argument("version", help="Version id (e.g. 23ai, 26ai).")
# --- compare ---
p_compare = subparsers.add_parser(
"compare",
help="Side-by-side comparison of two deployment types for a version.",
)
p_compare.add_argument("deploy1", help="First deployment type id.")
p_compare.add_argument("deploy2", help="Second deployment type id.")
p_compare.add_argument("version", help="Version id.")
# --- gaps ---
p_gaps = subparsers.add_parser(
"gaps",
help="List features that are NOT_AVAIL, BROKEN, or LIMITED.",
)
p_gaps.add_argument("deployment", help="Deployment type id.")
p_gaps.add_argument("version", help="Version id.")
# --- update ---
p_update = subparsers.add_parser(
"update",
help="Modify a specific cell in the matrix.",
)
p_update.add_argument("feature", help="Feature name (case-insensitive, partial match).")
p_update.add_argument("deployment", help="Deployment type id.")
p_update.add_argument("version", help="Version id.")
p_update.add_argument("--status", required=True, help="New status value.")
p_update.add_argument("--notes", default=None, help="Optional notes for the cell.")
p_update.add_argument("--name", default=None, help="Contributor name for attribution.")
p_update.add_argument("--team", default=None, help="Contributor team for attribution.")
p_update.add_argument("--confidence", default=None, help="Confidence level: validated/observed/reported/inferred.")
p_update.add_argument("--field-finding-ref", default=None, help="Reference to field finding ID.")
# --- export ---
p_export = subparsers.add_parser(
"export",
help="Export the full matrix as markdown or CSV.",
)
p_export.add_argument(
"--format",
required=True,
choices=["markdown", "csv"],
help="Output format.",
)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
dispatch = {
"check": cmd_check,
"compare": cmd_compare,
"gaps": cmd_gaps,
"update": cmd_update,
"export": cmd_export,
}
handler = dispatch.get(args.command)
if handler is None:
parser.print_help()
sys.exit(1)
handler(args)
if __name__ == "__main__":
main()

716
tools/findings_cli.py Normal file
View File

@@ -0,0 +1,716 @@
#!/usr/bin/env python3
"""
OCI Deal Accelerator — Field Findings CLI
Search, add, update, confirm, and report on field findings tracked in
kb/field-findings/tracker.yaml.
Usage:
python findings_cli.py list # List all findings
python findings_cli.py list --severity HIGH # Filter by severity
python findings_cli.py list --product "ADB-S" # Filter by product
python findings_cli.py list --tag dep # Filter by tag
python findings_cli.py list --client "Pepe" # Filter by client
python findings_cli.py list --status open # Filter by status
python findings_cli.py search "maintenance window" # Full-text search
python findings_cli.py add --name "Name" --team "Team" --confidence validated ...
python findings_cli.py add # Interactive mode
python findings_cli.py update FF-202603-001 --status resolved --resolution "Fixed"
python findings_cli.py confirm FF-202603-001 --name "Name" --team "Team" --note "Confirmed"
python findings_cli.py aer # After-Engagement Review (interactive)
python findings_cli.py stats # Summary statistics
"""
import argparse
import difflib
import os
import sys
from collections import Counter
from datetime import date, datetime
from typing import Any, Dict, List, Optional
import yaml
# =============================================================================
# Constants
# =============================================================================
TRACKER_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "kb", "field-findings", "tracker.yaml",
)
TAGS_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "config", "kb-tags.yaml",
)
VALID_SEVERITIES = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}
VALID_STATUSES = {"open", "resolved", "wontfix", "acknowledged", "monitoring"}
VALID_CATEGORIES = {"bug", "limitation", "undocumented", "gotcha", "workaround", "performance"}
VALID_CONFIDENCE = {"validated", "observed", "reported", "inferred"}
# ANSI colors
_SEV_COLORS = {
"CRITICAL": "\033[1;31m", # red bold
"HIGH": "\033[31m", # red
"MEDIUM": "\033[33m", # yellow
"LOW": "\033[36m", # cyan
"INFO": "\033[37m", # white
}
_RESET = "\033[0m"
# =============================================================================
# Data helpers
# =============================================================================
def load_tracker(path: str = TRACKER_PATH) -> Dict[str, Any]:
"""Load the tracker YAML and return the full dict."""
resolved = os.path.realpath(path)
if not os.path.isfile(resolved):
print(f"Error: Tracker file not found at {resolved}", file=sys.stderr)
sys.exit(1)
with open(resolved, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not data or "findings" not in data:
print("Error: Tracker file missing 'findings' key.", file=sys.stderr)
sys.exit(1)
return data
def save_tracker(data: Dict[str, Any], path: str = TRACKER_PATH) -> None:
"""Write tracker data back to YAML."""
resolved = os.path.realpath(path)
data["last_updated"] = date.today().isoformat()
with open(resolved, "w", encoding="utf-8") as fh:
yaml.dump(
data,
fh,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=120,
)
def _get_findings(path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
"""Return the findings list from the tracker."""
return load_tracker(path).get("findings", [])
def _next_id(findings: List[Dict[str, Any]]) -> str:
"""Generate the next FF-YYYYMM-NNN id."""
prefix = f"FF-{date.today().strftime('%Y%m')}-"
existing = [
int(f["id"].split("-")[-1])
for f in findings
if f["id"].startswith(prefix)
]
next_num = max(existing, default=0) + 1
return f"{prefix}{next_num:03d}"
def _sev_color(severity: str) -> str:
"""Wrap severity with ANSI color if terminal supports it."""
if not (hasattr(sys.stdout, "isatty") and sys.stdout.isatty()):
return severity
code = _SEV_COLORS.get(severity, "")
return f"{code}{severity}{_RESET}" if code else severity
def _get_client(finding: Dict[str, Any]) -> str:
"""Get client from finding, supporting both old and new format."""
contributor = finding.get("contributor")
if isinstance(contributor, dict):
return str(contributor.get("client", ""))
return str(finding.get("client", ""))
def _get_reporter(finding: Dict[str, Any]) -> str:
"""Get reporter name, supporting both old and new format."""
contributor = finding.get("contributor")
if isinstance(contributor, dict):
return str(contributor.get("name", ""))
return str(finding.get("reported_by", ""))
# =============================================================================
# Tag validation
# =============================================================================
def _load_taxonomy() -> List[str]:
"""Load all valid tags from the taxonomy config."""
resolved = os.path.realpath(TAGS_PATH)
if not os.path.isfile(resolved):
return []
with open(resolved, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not data or "taxonomy" not in data:
return []
all_tags = []
for category_tags in data["taxonomy"].values():
if isinstance(category_tags, list):
all_tags.extend(str(t) for t in category_tags)
return all_tags
def validate_tags(tags: List[str], taxonomy: Optional[List[str]] = None) -> List[str]:
"""Validate tags against taxonomy. Returns list of warning messages."""
if taxonomy is None:
taxonomy = _load_taxonomy()
if not taxonomy:
return []
warnings = []
for tag in tags:
if tag not in taxonomy:
close = difflib.get_close_matches(tag, taxonomy, n=1, cutoff=0.5)
if close:
warnings.append(f"WARNING: Unknown tag '{tag}'. Did you mean '{close[0]}'?")
else:
warnings.append(f"WARNING: Unknown tag '{tag}'. Not in taxonomy.")
return warnings
# =============================================================================
# Library API — importable functions for programmatic use and testing
# =============================================================================
def search(query: str, tracker_path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
"""Full-text search across summary, detail, workaround, and tags."""
findings = _get_findings(tracker_path)
query_lower = query.lower()
results = []
for f in findings:
searchable = " ".join([
str(f.get("summary", "")),
str(f.get("detail", "")),
str(f.get("workaround", "")),
" ".join(str(t) for t in f.get("tags", [])),
]).lower()
if query_lower in searchable:
results.append(f)
return results
def filter_by_severity(severity: str, tracker_path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
"""Return findings matching the given severity."""
findings = _get_findings(tracker_path)
return [f for f in findings if f.get("severity") == severity]
def filter_by_client(client: str, tracker_path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
"""Return findings whose client field contains the query string."""
findings = _get_findings(tracker_path)
client_lower = client.lower()
return [f for f in findings if client_lower in _get_client(f).lower()]
def filter_findings(
findings: List[Dict[str, Any]],
severity: Optional[str] = None,
product: Optional[str] = None,
tag: Optional[str] = None,
client: Optional[str] = None,
status: Optional[str] = None,
category: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Apply multiple filters to a findings list."""
results = findings
if severity:
results = [f for f in results if f.get("severity") == severity.upper()]
if product:
product_lower = product.lower()
results = [f for f in results if product_lower in str(f.get("product", "")).lower()]
if tag:
tag_lower = tag.lower()
results = [f for f in results if tag_lower in [str(t).lower() for t in f.get("tags", [])]]
if client:
client_lower = client.lower()
results = [f for f in results if client_lower in _get_client(f).lower()]
if status:
results = [f for f in results if f.get("status") == status]
if category:
results = [f for f in results if f.get("category") == category]
return results
def add(
tracker_path: str = TRACKER_PATH,
name: str = "",
team: str = "",
confidence: str = "validated",
client: str = "",
context: str = "",
product: str = "",
version: str = "",
severity: str = "MEDIUM",
category: str = "gotcha",
summary: str = "",
detail: str = "",
workaround: str = "",
tags: Optional[List[str]] = None,
status: str = "open",
date_str: Optional[str] = None,
oracle_sr: str = "",
reported_by: str = "",
affects_matrix: Optional[str] = None,
) -> Dict[str, Any]:
"""Add a new finding programmatically. Returns the new entry dict.
Supports both new contributor block (name/team/confidence) and legacy
reported_by for backward compatibility.
"""
data = load_tracker(tracker_path)
findings = data.get("findings", [])
contributor_name = name or reported_by
contributor = {
"name": contributor_name,
"team": team,
"date": date_str or date.today().isoformat(),
"confidence": confidence if confidence in VALID_CONFIDENCE else "validated",
}
if client:
contributor["client"] = client
if context:
contributor["context"] = context
entry = {
"id": _next_id(findings),
"date": date_str or date.today().isoformat(),
"contributor": contributor,
"reported_by": contributor_name,
"product": product,
"version": version,
"severity": severity.upper(),
"category": category,
"summary": summary,
"detail": detail,
"workaround": workaround,
"oracle_sr": oracle_sr,
"status": status,
"resolved_date": None,
"resolution": None,
"affects_matrix": affects_matrix,
"tags": tags or [],
"confirmations": [],
}
# Insert at top (newest first)
findings.insert(0, entry)
data["findings"] = findings
save_tracker(data, tracker_path)
return entry
def confirm(
finding_id: str,
name: str,
team: str,
note: str = "",
tracker_path: str = TRACKER_PATH,
) -> Dict[str, Any]:
"""Add a confirmation to an existing finding. Returns the confirmation dict."""
data = load_tracker(tracker_path)
findings = data.get("findings", [])
target = None
for f in findings:
if f["id"] == finding_id:
target = f
break
if target is None:
raise LookupError(f"Finding '{finding_id}' not found.")
confirmation = {
"name": name,
"team": team,
"date": date.today().isoformat(),
}
if note:
confirmation["note"] = note
if "confirmations" not in target:
target["confirmations"] = []
target["confirmations"].append(confirmation)
save_tracker(data, tracker_path)
return confirmation
def stats(tracker_path: str = TRACKER_PATH) -> Dict[str, Any]:
"""Return summary statistics about findings."""
findings = _get_findings(tracker_path)
return {
"total": len(findings),
"by_severity": dict(Counter(f.get("severity", "UNKNOWN") for f in findings)),
"by_product": dict(Counter(f.get("product", "UNKNOWN") for f in findings)),
"by_status": dict(Counter(f.get("status", "unknown") for f in findings)),
"by_category": dict(Counter(f.get("category", "unknown") for f in findings)),
}
# =============================================================================
# CLI subcommands
# =============================================================================
def cmd_list(args: argparse.Namespace) -> None:
"""List findings with optional filters."""
data = load_tracker(TRACKER_PATH)
findings = data.get("findings", [])
results = filter_findings(
findings,
severity=args.severity,
product=args.product,
tag=args.tag,
client=args.client,
status=args.status,
category=getattr(args, "category", None),
)
if not results:
print("No findings match the given filters.")
return
# Table output
id_w, date_w, sev_w, prod_w = 16, 12, 10, 30
header = f"{'ID':<{id_w}} {'Date':<{date_w}} {'Severity':<{sev_w}} {'Product':<{prod_w}} Summary"
print(header)
print("-" * len(header))
for f in results:
fid = f.get("id", "?")
fdate = str(f.get("date", "?"))
sev = f.get("severity", "?")
prod = str(f.get("product", "?"))[:prod_w]
summary = str(f.get("summary", ""))[:60]
print(f"{fid:<{id_w}} {fdate:<{date_w}} {_sev_color(sev):<{sev_w}} {prod:<{prod_w}} {summary}")
def cmd_search(args: argparse.Namespace) -> None:
"""Full-text search."""
results = search(args.query)
if not results:
print(f"No findings matching '{args.query}'.")
return
print(f"Found {len(results)} finding(s) matching '{args.query}':\n")
for f in results:
sev = f.get("severity", "?")
print(f" {f['id']} [{_sev_color(sev)}] {f.get('summary', '')}")
def cmd_add(args: argparse.Namespace) -> None:
"""Add a new finding."""
if args.summary:
# Non-interactive mode
tags_list = [t.strip() for t in args.tags.split(",")] if args.tags else []
# Validate tags
tag_warnings = validate_tags(tags_list)
for w in tag_warnings:
print(w, file=sys.stderr)
entry = add(
name=args.name or args.reported_by or "",
team=args.team or "",
confidence=args.confidence or "validated",
client=args.client or "",
context=args.context or "",
product=args.product or "",
version=args.version or "",
severity=args.severity or "MEDIUM",
category=args.category or "gotcha",
summary=args.summary,
detail=args.detail or "",
workaround=args.workaround or "",
tags=tags_list,
date_str=args.date,
oracle_sr=args.oracle_sr or "",
affects_matrix=args.affects_matrix or None,
)
else:
# Interactive mode
print("Add new field finding (press Enter to skip optional fields):\n")
contributor_name = input("Your name: ").strip()
contributor_team = input("Your team: ").strip()
contributor_client = input("Client (optional, press Enter to skip): ").strip()
contributor_context = input("Context (optional): ").strip()
confidence = input("Confidence [validated/observed/reported/inferred]: ").strip() or "validated"
tags_str = input("Tags (comma-separated): ").strip()
tags_list = [t.strip() for t in tags_str.split(",") if t.strip()]
tag_warnings = validate_tags(tags_list)
for w in tag_warnings:
print(w)
entry = add(
date_str=input("Date [today]: ").strip() or None,
name=contributor_name,
team=contributor_team,
client=contributor_client,
context=contributor_context,
confidence=confidence,
product=input("Product: ").strip(),
version=input("Version: ").strip(),
severity=input("Severity (CRITICAL/HIGH/MEDIUM/LOW/INFO) [MEDIUM]: ").strip() or "MEDIUM",
category=input("Category (bug/limitation/undocumented/gotcha/workaround/performance) [gotcha]: ").strip() or "gotcha",
summary=input("Summary (one line): ").strip(),
detail=input("Detail: ").strip(),
workaround=input("Workaround: ").strip(),
tags=tags_list,
oracle_sr=input("Oracle SR#: ").strip(),
affects_matrix=input("Affects matrix entry (feature name, or blank): ").strip() or None,
)
print(f"\nAdded: {entry['id']} [{entry['severity']}] {entry['summary']}")
def cmd_confirm(args: argparse.Namespace) -> None:
"""Add a confirmation to an existing finding."""
try:
confirmation = confirm(
finding_id=args.finding_id,
name=args.name,
team=args.team,
note=args.note or "",
)
print(f"Confirmed {args.finding_id}:")
print(f" by: {confirmation['name']} ({confirmation.get('team', '')})")
if confirmation.get("note"):
print(f" note: {confirmation['note']}")
except LookupError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_aer(args: argparse.Namespace) -> None:
"""After-Engagement Review — interactive post-engagement knowledge capture."""
print("After-Engagement Review")
print("=" * 23)
client = input("Client: ").strip()
architect = input("Architect: ").strip()
team = input("Team: ").strip() or "Field Architecture"
print()
q1 = input("1. What did we learn that the KB doesn't cover?\n > ").strip()
print()
q2 = input("2. What went wrong that others should know about?\n > ").strip()
print()
q3 = input("3. What worked well that we should capture as a pattern?\n > ").strip()
print()
print("Saving...")
created = []
# Create finding from Q2 (what went wrong) if provided
if q2:
entry = add(
name=architect,
team=team,
client=client,
context="After-Engagement Review",
confidence="observed",
product=input("Product for issue (Q2): ").strip() or "General",
severity=input("Severity for issue (CRITICAL/HIGH/MEDIUM/LOW/INFO) [MEDIUM]: ").strip() or "MEDIUM",
category="gotcha",
summary=q2[:120],
detail=q2,
)
created.append(f" -> Created finding {entry['id']} ({q2[:60]})")
# Create finding from Q1 (KB gap) if provided
if q1:
entry = add(
name=architect,
team=team,
client=client,
context="After-Engagement Review",
confidence="observed",
product=input("Product for KB gap (Q1): ").strip() or "General",
severity="INFO",
category="undocumented",
summary=f"KB gap: {q1[:110]}",
detail=q1,
)
created.append(f" -> Created finding {entry['id']} (KB gap: {q1[:50]})")
if q3:
created.append(f" -> Pattern suggestion noted: {q3[:60]}...")
created.append(f" (Create manually: kb/patterns/<name>.yaml)")
if created:
for line in created:
print(line)
else:
print(" No items to create. Review complete.")
def cmd_update(args: argparse.Namespace) -> None:
"""Update an existing finding."""
data = load_tracker(TRACKER_PATH)
findings = data.get("findings", [])
target = None
for f in findings:
if f["id"] == args.finding_id:
target = f
break
if target is None:
print(f"Error: Finding '{args.finding_id}' not found.", file=sys.stderr)
sys.exit(1)
updated_fields = []
if args.status:
target["status"] = args.status
updated_fields.append(f"status -> {args.status}")
if args.resolution:
target["resolution"] = args.resolution
updated_fields.append(f"resolution -> {args.resolution}")
if args.resolved_date:
target["resolved_date"] = args.resolved_date
updated_fields.append(f"resolved_date -> {args.resolved_date}")
if args.workaround:
target["workaround"] = args.workaround
updated_fields.append("workaround updated")
if args.oracle_sr:
target["oracle_sr"] = args.oracle_sr
updated_fields.append(f"oracle_sr -> {args.oracle_sr}")
if not updated_fields:
print("No fields to update. Use --status, --resolution, --resolved-date, --workaround, or --oracle-sr.")
return
save_tracker(data, TRACKER_PATH)
print(f"Updated {args.finding_id}:")
for field in updated_fields:
print(f" {field}")
def cmd_stats(args: argparse.Namespace) -> None:
"""Display summary statistics."""
s = stats()
print(f"Total findings: {s['total']}\n")
print("By Severity:")
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]:
count = s["by_severity"].get(sev, 0)
if count:
print(f" {_sev_color(sev):<10} {count}")
print("\nBy Status:")
for status, count in sorted(s["by_status"].items()):
print(f" {status:<15} {count}")
print("\nBy Product:")
for product, count in sorted(s["by_product"].items(), key=lambda x: -x[1]):
print(f" {product:<35} {count}")
print("\nBy Category:")
for cat, count in sorted(s["by_category"].items(), key=lambda x: -x[1]):
print(f" {cat:<15} {count}")
# =============================================================================
# CLI entry point
# =============================================================================
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="findings_cli",
description="Manage the Field Findings Tracker.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# --- list ---
p_list = subparsers.add_parser("list", help="List findings with optional filters.")
p_list.add_argument("--severity", help="Filter by severity.")
p_list.add_argument("--product", help="Filter by product (partial match).")
p_list.add_argument("--tag", help="Filter by tag.")
p_list.add_argument("--client", help="Filter by client (partial match).")
p_list.add_argument("--status", help="Filter by status.")
p_list.add_argument("--category", help="Filter by category.")
# --- search ---
p_search = subparsers.add_parser("search", help="Full-text search.")
p_search.add_argument("query", help="Search term.")
# --- add ---
p_add = subparsers.add_parser("add", help="Add a new finding.")
p_add.add_argument("--date", default=None, help="Date (YYYY-MM-DD). Defaults to today.")
p_add.add_argument("--name", help="Contributor name.")
p_add.add_argument("--team", help="Contributor team.")
p_add.add_argument("--confidence", help="Confidence level: validated/observed/reported/inferred.")
p_add.add_argument("--context", help="Engagement context (PoC, migration, assessment).")
p_add.add_argument("--reported-by", help="(Legacy) Who reported the finding. Use --name instead.")
p_add.add_argument("--client", help="Client name or reference.")
p_add.add_argument("--product", help="OCI product/service affected.")
p_add.add_argument("--version", help="Product version.")
p_add.add_argument("--severity", help="CRITICAL/HIGH/MEDIUM/LOW/INFO.")
p_add.add_argument("--category", help="bug/limitation/undocumented/gotcha/workaround/performance.")
p_add.add_argument("--summary", help="One-line summary.")
p_add.add_argument("--detail", help="Full description.")
p_add.add_argument("--workaround", help="Known workaround.")
p_add.add_argument("--tags", help="Comma-separated tags.")
p_add.add_argument("--oracle-sr", help="Oracle SR number.")
p_add.add_argument("--affects-matrix", help="Feature matrix entry this relates to.")
# --- update ---
p_update = subparsers.add_parser("update", help="Update an existing finding.")
p_update.add_argument("finding_id", help="Finding ID (e.g., FF-202603-001).")
p_update.add_argument("--status", help="New status.")
p_update.add_argument("--resolution", help="Resolution description.")
p_update.add_argument("--resolved-date", help="Resolved date (YYYY-MM-DD).")
p_update.add_argument("--workaround", help="Updated workaround.")
p_update.add_argument("--oracle-sr", help="Oracle SR number.")
# --- confirm ---
p_confirm = subparsers.add_parser("confirm", help="Add a confirmation to an existing finding.")
p_confirm.add_argument("finding_id", help="Finding ID to confirm (e.g., FF-202603-001).")
p_confirm.add_argument("--name", required=True, help="Your name.")
p_confirm.add_argument("--team", required=True, help="Your team.")
p_confirm.add_argument("--note", help="Confirmation note.")
# --- aer ---
subparsers.add_parser("aer", help="After-Engagement Review (interactive).")
# --- stats ---
subparsers.add_parser("stats", help="Show summary statistics.")
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
dispatch = {
"list": cmd_list,
"search": cmd_search,
"add": cmd_add,
"update": cmd_update,
"confirm": cmd_confirm,
"aer": cmd_aer,
"stats": cmd_stats,
}
handler = dispatch.get(args.command)
if handler is None:
parser.print_help()
sys.exit(1)
handler(args)
if __name__ == "__main__":
main()

504
tools/kb_cli.py Normal file
View File

@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""
OCI Deal Accelerator — Unified KB Management CLI
Provides a single entry point for KB management tasks:
- Health dashboard
- Contribution stats and leaderboard
- Stale content reporting
- Confidence decay reporting
- Changelog management for service files
- Cross-KB search
- Domain owner listing
Usage:
python kb_cli.py health # Overall KB health dashboard
python kb_cli.py stats contributors # Contribution leaderboard
python kb_cli.py stats stale # Stale content report
python kb_cli.py stats decay # Confidence decay report
python kb_cli.py changelog <file> --name "Name" --team "Team" --change "Description"
python kb_cli.py search "vector search" # Search across ALL KB files
python kb_cli.py owners # Show domain owners
"""
import argparse
import os
import re
import sys
from collections import Counter
from datetime import date, datetime
from typing import Any, Dict, List, Optional
import yaml
# =============================================================================
# Constants
# =============================================================================
PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
KB_ROOT = os.path.join(PROJECT_ROOT, "kb")
GOVERNANCE_PATH = os.path.join(PROJECT_ROOT, "config", "kb-governance.yaml")
OWNERS_PATH = os.path.join(PROJECT_ROOT, "config", "kb-owners.yaml")
TRACKER_PATH = os.path.join(PROJECT_ROOT, "kb", "field-findings", "tracker.yaml")
MATRIX_PATH = os.path.join(PROJECT_ROOT, "kb", "compatibility", "adb-feature-matrix.yaml")
_RESET = "\033[0m"
_GREEN = "\033[32m"
_YELLOW = "\033[33m"
_RED = "\033[31m"
_BOLD = "\033[1m"
_CYAN = "\033[36m"
def _supports_color() -> bool:
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
def _c(text: str, color: str) -> str:
if not _supports_color():
return text
return f"{color}{text}{_RESET}"
# =============================================================================
# Loaders
# =============================================================================
def _load_yaml(path: str) -> Optional[Dict[str, Any]]:
resolved = os.path.realpath(path)
if not os.path.isfile(resolved):
return None
with open(resolved, "r", encoding="utf-8") as fh:
return yaml.safe_load(fh)
def _parse_date(d: Any) -> Optional[date]:
if isinstance(d, date) and not isinstance(d, datetime):
return d
if isinstance(d, datetime):
return d.date()
if isinstance(d, str):
try:
return datetime.strptime(d, "%Y-%m-%d").date()
except ValueError:
pass
# Try YYYY-MM format
try:
return datetime.strptime(d + "-01", "%Y-%m-%d").date()
except ValueError:
return None
return None
def _days_ago(d: Any) -> Optional[int]:
parsed = _parse_date(d)
if parsed is None:
return None
return (date.today() - parsed).days
def _all_kb_files() -> List[str]:
"""Return all YAML files under kb/."""
files = []
for dirpath, _dirnames, filenames in os.walk(KB_ROOT):
for filename in filenames:
if filename.endswith((".yaml", ".yml")):
files.append(os.path.join(dirpath, filename))
return sorted(files)
# =============================================================================
# Health dashboard
# =============================================================================
def cmd_health(args: argparse.Namespace) -> None:
"""Overall KB health dashboard."""
governance = _load_yaml(GOVERNANCE_PATH) or {}
warning_days = governance.get("freshness", {}).get("warning_days", 180)
stale_days = governance.get("freshness", {}).get("stale_days", 365)
decay_config = governance.get("confidence_decay", {})
# File freshness
kb_files = _all_kb_files()
fresh_count = stale_count = expired_count = 0
stale_files = []
for filepath in kb_files:
data = _load_yaml(filepath)
rel = os.path.relpath(filepath, PROJECT_ROOT)
last = None
if data:
last = data.get("last_verified") or data.get("last_updated")
age = _days_ago(last) if last else None
if age is None:
mtime = os.path.getmtime(filepath)
mdate = datetime.fromtimestamp(mtime).date()
age = (date.today() - mdate).days
if age > stale_days:
expired_count += 1
stale_files.append((rel, age, "expired"))
elif age > warning_days:
stale_count += 1
stale_files.append((rel, age, "stale"))
else:
fresh_count += 1
# Findings stats
tracker = _load_yaml(TRACKER_PATH) or {}
findings = tracker.get("findings", [])
status_counts = Counter(f.get("status", "unknown") for f in findings)
# Feature matrix stats
matrix = _load_yaml(MATRIX_PATH) or {}
features = matrix.get("features", [])
cell_counts: Dict[str, int] = Counter()
for feat in features:
for deploy_versions in feat.get("matrix", {}).values():
for ver_data in deploy_versions.values():
if isinstance(ver_data, dict):
cell_counts[ver_data.get("status", "UNTESTED")] += 1
# Contributor stats (last 90 days)
contributor_counts: Dict[str, int] = Counter()
for f in findings:
contrib = f.get("contributor", {})
if isinstance(contrib, dict):
name = contrib.get("name", "Unknown")
cdate = _parse_date(contrib.get("date", f.get("date")))
if cdate and (date.today() - cdate).days <= 90:
contributor_counts[name] += 1
for c in f.get("confirmations", []):
cdate = _parse_date(c.get("date"))
if cdate and (date.today() - cdate).days <= 90:
contributor_counts[c.get("name", "Unknown")] += 1
# Owner check
owners = _load_yaml(OWNERS_PATH) or {}
unassigned = []
for domain in owners.get("domains", []):
if domain.get("owner", {}).get("name") == "TBD":
unassigned.append(domain.get("area", ""))
# Print dashboard
total_files = len(kb_files)
print(_c("KB HEALTH DASHBOARD", _BOLD))
print("=" * 50)
print()
print(f"Files: {total_files} total | {_c(str(fresh_count), _GREEN)} fresh | "
f"{_c(str(stale_count), _YELLOW)} stale | {_c(str(expired_count), _RED)} expired")
print()
print(f"Findings: {len(findings)} total | "
f"{status_counts.get('open', 0)} open | "
f"{status_counts.get('acknowledged', 0)} acknowledged | "
f"{status_counts.get('resolved', 0)} resolved | "
f"{status_counts.get('monitoring', 0)} monitoring")
print()
if cell_counts:
parts = []
for status in ["GA", "GA_CAVEAT", "LIMITED", "NOT_AVAIL", "UNTESTED"]:
if cell_counts.get(status, 0) > 0:
parts.append(f"{status}: {cell_counts[status]}")
print(f"Feature Matrix: {len(features)} features")
print(f" {' | '.join(parts)}")
print()
if contributor_counts:
print("Contributors (last 90 days):")
for name, count in contributor_counts.most_common(10):
print(f" {name:<30} {count} contributions")
print()
needs_attention = []
for filepath, age, status in stale_files:
needs_attention.append(f" {filepath} -- {status} ({age} days old)")
untested = cell_counts.get("UNTESTED", 0)
if untested > 0:
needs_attention.append(f" {untested} UNTESTED cells in feature matrix")
for area in unassigned:
needs_attention.append(f" {area} -- no owner assigned")
if needs_attention:
print("Needs Attention:")
for item in needs_attention:
print(item)
else:
print(_c("All areas healthy.", _GREEN))
# =============================================================================
# Stats commands
# =============================================================================
def cmd_stats_contributors(args: argparse.Namespace) -> None:
"""Show contribution leaderboard."""
tracker = _load_yaml(TRACKER_PATH) or {}
findings = tracker.get("findings", [])
# Count findings per contributor
finding_counts: Dict[str, Dict[str, int]] = {}
for f in findings:
contrib = f.get("contributor", {})
if isinstance(contrib, dict):
name = contrib.get("name", "Unknown")
team = contrib.get("team", "")
else:
name = str(f.get("reported_by", "Unknown"))
team = ""
key = f"{name}|{team}"
if key not in finding_counts:
finding_counts[key] = {"findings": 0, "confirmations": 0, "team": team}
finding_counts[key]["findings"] += 1
for c in f.get("confirmations", []):
cname = c.get("name", "Unknown")
cteam = c.get("team", "")
ckey = f"{cname}|{cteam}"
if ckey not in finding_counts:
finding_counts[ckey] = {"findings": 0, "confirmations": 0, "team": cteam}
finding_counts[ckey]["confirmations"] += 1
if not finding_counts:
print("No contributions found.")
return
print(f"{'Contributor':<25} {'Team':<25} {'Findings':>8} {'Confirms':>8} {'Total':>8}")
print("-" * 80)
for key, counts in sorted(finding_counts.items(), key=lambda x: -(x[1]["findings"] + x[1]["confirmations"])):
name = key.split("|")[0]
total = counts["findings"] + counts["confirmations"]
print(f"{name:<25} {counts['team']:<25} {counts['findings']:>8} {counts['confirmations']:>8} {total:>8}")
def cmd_stats_stale(args: argparse.Namespace) -> None:
"""Show stale content report."""
governance = _load_yaml(GOVERNANCE_PATH) or {}
warning_days = governance.get("freshness", {}).get("warning_days", 180)
stale_days = governance.get("freshness", {}).get("stale_days", 365)
kb_files = _all_kb_files()
stale = []
for filepath in kb_files:
data = _load_yaml(filepath)
rel = os.path.relpath(filepath, PROJECT_ROOT)
last = None
if data:
last = data.get("last_verified") or data.get("last_updated")
age = _days_ago(last) if last else None
if age is None:
mtime = os.path.getmtime(filepath)
mdate = datetime.fromtimestamp(mtime).date()
age = (date.today() - mdate).days
if age > warning_days:
status = "STALE" if age > stale_days else "WARNING"
stale.append((rel, age, status))
if not stale:
print(_c("No stale content found.", _GREEN))
return
print(f"{'File':<50} {'Age':>8} {'Status':>10}")
print("-" * 70)
for filepath, age, status in sorted(stale, key=lambda x: -x[1]):
sc = _RED if status == "STALE" else _YELLOW
print(f"{filepath:<50} {age:>5}d {_c(status, sc)}")
def cmd_stats_decay(args: argparse.Namespace) -> None:
"""Show confidence decay report."""
governance = _load_yaml(GOVERNANCE_PATH) or {}
decay_config = governance.get("confidence_decay", {})
tracker = _load_yaml(TRACKER_PATH) or {}
findings = tracker.get("findings", [])
if not findings:
print("No findings found.")
return
print(f"{'ID':<18} {'Status':<10} {'Summary':<45} {'Confidence':<12} {'Age'}")
print("-" * 100)
for f in findings:
fid = f.get("id", "???")
contributor = f.get("contributor", {})
confidence = contributor.get("confidence", "validated") if isinstance(contributor, dict) else "validated"
finding_date = contributor.get("date", f.get("date")) if isinstance(contributor, dict) else f.get("date")
age = _days_ago(finding_date)
summary = str(f.get("summary", ""))[:43]
if age is None:
status = "UNKNOWN"
else:
thresholds = decay_config.get(confidence, {"fresh": 180, "stale": 365, "expired": 730})
if age <= thresholds.get("fresh", 180):
status = "FRESH"
elif age <= thresholds.get("stale", 365):
status = "STALE"
else:
status = "EXPIRED"
color = _GREEN if status == "FRESH" else (_YELLOW if status == "STALE" else _RED)
age_str = f"{age}d" if age is not None else "?"
print(f"{fid:<18} {_c(status, color):<10} {summary:<45} {confidence:<12} {age_str}")
# =============================================================================
# Changelog command
# =============================================================================
def cmd_changelog(args: argparse.Namespace) -> None:
"""Add a changelog entry to a KB file."""
filepath = os.path.realpath(args.file)
if not os.path.isfile(filepath):
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
with open(filepath, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if data is None:
data = {}
if "changelog" not in data:
data["changelog"] = []
entry = {
"date": date.today().isoformat(),
"contributor": {"name": args.name, "team": args.team},
"change": args.change,
}
data["changelog"].append(entry)
with open(filepath, "w", encoding="utf-8") as fh:
yaml.dump(data, fh, default_flow_style=False, sort_keys=False, allow_unicode=True, width=120)
rel = os.path.relpath(filepath, PROJECT_ROOT)
print(f"Added changelog entry to {rel}:")
print(f" date: {entry['date']}")
print(f" contributor: {args.name} ({args.team})")
print(f" change: {args.change}")
# =============================================================================
# Search command
# =============================================================================
def cmd_search(args: argparse.Namespace) -> None:
"""Search across ALL KB files."""
query = args.query.lower()
kb_files = _all_kb_files()
results = []
for filepath in kb_files:
with open(filepath, "r", encoding="utf-8") as fh:
content = fh.read()
if query in content.lower():
rel = os.path.relpath(filepath, PROJECT_ROOT)
# Find matching lines
matches = []
for i, line in enumerate(content.splitlines(), 1):
if query in line.lower():
matches.append((i, line.strip()[:80]))
results.append((rel, matches))
if not results:
print(f"No results for '{args.query}'.")
return
print(f"Found matches in {len(results)} file(s) for '{args.query}':\n")
for filepath, matches in results:
print(f" {_c(filepath, _CYAN)}")
for lineno, text in matches[:3]:
print(f" L{lineno}: {text}")
if len(matches) > 3:
print(f" ... and {len(matches) - 3} more match(es)")
print()
# =============================================================================
# Owners command
# =============================================================================
def cmd_owners(args: argparse.Namespace) -> None:
"""Show domain owners."""
owners = _load_yaml(OWNERS_PATH)
if not owners or "domains" not in owners:
print("No owners configuration found.")
return
print(f"{'Area':<30} {'Owner':<25} {'Team':<25} {'Review Cadence'}")
print("-" * 100)
for domain in owners["domains"]:
area = domain.get("area", "")
owner = domain.get("owner", {})
name = owner.get("name", "TBD")
team = owner.get("team", "")
cadence = domain.get("review_cadence", "")
name_display = _c(name, _RED) if name == "TBD" else name
print(f"{area:<30} {name_display:<25} {team:<25} {cadence}")
# =============================================================================
# CLI entry point
# =============================================================================
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="kb_cli",
description="Unified KB management CLI.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# --- health ---
subparsers.add_parser("health", help="Overall KB health dashboard.")
# --- stats ---
p_stats = subparsers.add_parser("stats", help="KB statistics.")
stats_sub = p_stats.add_subparsers(dest="stats_type", required=True)
stats_sub.add_parser("contributors", help="Contribution leaderboard.")
stats_sub.add_parser("stale", help="Stale content report.")
stats_sub.add_parser("decay", help="Confidence decay report.")
# --- changelog ---
p_changelog = subparsers.add_parser("changelog", help="Add changelog entry to a KB file.")
p_changelog.add_argument("file", help="Path to the KB YAML file.")
p_changelog.add_argument("--name", required=True, help="Contributor name.")
p_changelog.add_argument("--team", required=True, help="Contributor team.")
p_changelog.add_argument("--change", required=True, help="Description of the change.")
# --- search ---
p_search = subparsers.add_parser("search", help="Search across all KB files.")
p_search.add_argument("query", help="Search term.")
# --- owners ---
subparsers.add_parser("owners", help="Show domain owners.")
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if args.command == "health":
cmd_health(args)
elif args.command == "stats":
dispatch = {
"contributors": cmd_stats_contributors,
"stale": cmd_stats_stale,
"decay": cmd_stats_decay,
}
dispatch[args.stats_type](args)
elif args.command == "changelog":
cmd_changelog(args)
elif args.command == "search":
cmd_search(args)
elif args.command == "owners":
cmd_owners(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()

398
tools/kb_linter.py Normal file
View File

@@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""
OCI Deal Accelerator — KB Linter
Validates KB files against governance rules:
- Contributor blocks on findings and matrix entries
- Confidence decay against governance config
- Tag validation against taxonomy
- Domain owner review cadence
- Freshness checks on all KB files
Usage:
python kb_linter.py # Run all checks
python kb_linter.py --show-decay # Show confidence decay status
python kb_linter.py --check-tags # Validate tags only
python kb_linter.py --check-owners # Check domain owner review cadence
"""
import argparse
import os
import sys
from datetime import date, datetime
from typing import Any, Dict, List, Optional, Tuple
import yaml
# =============================================================================
# Constants
# =============================================================================
PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
GOVERNANCE_PATH = os.path.join(PROJECT_ROOT, "config", "kb-governance.yaml")
OWNERS_PATH = os.path.join(PROJECT_ROOT, "config", "kb-owners.yaml")
TAGS_PATH = os.path.join(PROJECT_ROOT, "config", "kb-tags.yaml")
TRACKER_PATH = os.path.join(PROJECT_ROOT, "kb", "field-findings", "tracker.yaml")
MATRIX_PATH = os.path.join(PROJECT_ROOT, "kb", "compatibility", "adb-feature-matrix.yaml")
_RESET = "\033[0m"
_GREEN = "\033[32m"
_YELLOW = "\033[33m"
_RED = "\033[31m"
def _supports_color() -> bool:
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
def _c(text: str, color: str) -> str:
if not _supports_color():
return text
return f"{color}{text}{_RESET}"
# =============================================================================
# Loaders
# =============================================================================
def _load_yaml(path: str) -> Optional[Dict[str, Any]]:
resolved = os.path.realpath(path)
if not os.path.isfile(resolved):
return None
with open(resolved, "r", encoding="utf-8") as fh:
return yaml.safe_load(fh)
def _load_governance() -> Dict[str, Any]:
data = _load_yaml(GOVERNANCE_PATH)
return data if data else {}
def _load_taxonomy() -> List[str]:
data = _load_yaml(TAGS_PATH)
if not data or "taxonomy" not in data:
return []
all_tags = []
for category_tags in data["taxonomy"].values():
if isinstance(category_tags, list):
all_tags.extend(str(t) for t in category_tags)
return all_tags
def _parse_date(d: Any) -> Optional[date]:
if isinstance(d, date) and not isinstance(d, datetime):
return d
if isinstance(d, datetime):
return d.date()
if isinstance(d, str):
try:
return datetime.strptime(d, "%Y-%m-%d").date()
except ValueError:
return None
return None
def _days_ago(d: Any) -> Optional[int]:
parsed = _parse_date(d)
if parsed is None:
return None
return (date.today() - parsed).days
# =============================================================================
# Check: Contributor blocks
# =============================================================================
def check_contributor_blocks(tracker_path: str = TRACKER_PATH) -> List[str]:
"""Verify all findings have proper contributor blocks."""
issues = []
data = _load_yaml(tracker_path)
if not data or "findings" not in data:
issues.append("Cannot load tracker file.")
return issues
governance = _load_governance()
required = governance.get("contribution", {}).get("required_fields", ["name", "team", "date", "confidence"])
valid_confidence = governance.get("contribution", {}).get("confidence_levels",
["validated", "observed", "reported", "inferred"])
for f in data["findings"]:
fid = f.get("id", "???")
contributor = f.get("contributor")
if not isinstance(contributor, dict):
if f.get("reported_by"):
issues.append(f"{fid}: uses legacy 'reported_by' instead of contributor block")
else:
issues.append(f"{fid}: missing contributor block")
continue
for field in required:
if not contributor.get(field):
issues.append(f"{fid}: contributor missing required field '{field}'")
conf = contributor.get("confidence", "")
if conf and conf not in valid_confidence:
issues.append(f"{fid}: invalid confidence level '{conf}'")
return issues
# =============================================================================
# Check: Confidence decay
# =============================================================================
def check_confidence_decay(tracker_path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
"""Check confidence decay for all findings. Returns list of decay reports."""
governance = _load_governance()
decay_config = governance.get("confidence_decay", {})
data = _load_yaml(tracker_path)
if not data or "findings" not in data:
return []
results = []
for f in data["findings"]:
fid = f.get("id", "???")
contributor = f.get("contributor", {})
confidence = contributor.get("confidence", "validated") if isinstance(contributor, dict) else "validated"
finding_date = contributor.get("date", f.get("date")) if isinstance(contributor, dict) else f.get("date")
age = _days_ago(finding_date)
if age is None:
results.append({"id": fid, "status": "UNKNOWN", "confidence": confidence, "age_days": None})
continue
thresholds = decay_config.get(confidence, {"fresh": 180, "stale": 365, "expired": 730})
if age <= thresholds.get("fresh", 180):
decay_status = "FRESH"
elif age <= thresholds.get("stale", 365):
decay_status = "STALE"
else:
decay_status = "EXPIRED"
results.append({
"id": fid,
"status": decay_status,
"confidence": confidence,
"age_days": age,
"summary": f.get("summary", ""),
})
return results
# =============================================================================
# Check: Tag validation
# =============================================================================
def check_tags(tracker_path: str = TRACKER_PATH) -> List[str]:
"""Validate all tags against the taxonomy."""
taxonomy = _load_taxonomy()
if not taxonomy:
return ["No taxonomy loaded — skipping tag validation."]
data = _load_yaml(tracker_path)
if not data or "findings" not in data:
return ["Cannot load tracker file."]
issues = []
for f in data["findings"]:
fid = f.get("id", "???")
for tag in f.get("tags", []):
if str(tag) not in taxonomy:
issues.append(f"{fid}: unknown tag '{tag}'")
return issues
# =============================================================================
# Check: Domain owners
# =============================================================================
def check_owners() -> List[str]:
"""Check if domain owners need to review their areas."""
owners_data = _load_yaml(OWNERS_PATH)
if not owners_data or "domains" not in owners_data:
return ["No owners config found."]
issues = []
for domain in owners_data["domains"]:
area = domain.get("area", "")
owner = domain.get("owner", {})
if owner.get("name") == "TBD":
issues.append(f"{area}: no owner assigned (TBD)")
return issues
# =============================================================================
# Check: File freshness
# =============================================================================
def check_freshness() -> List[Dict[str, Any]]:
"""Check freshness of all YAML files in kb/."""
governance = _load_governance()
warning_days = governance.get("freshness", {}).get("warning_days", 180)
stale_days = governance.get("freshness", {}).get("stale_days", 365)
kb_root = os.path.join(PROJECT_ROOT, "kb")
results = []
for dirpath, _dirnames, filenames in os.walk(kb_root):
for filename in filenames:
if not filename.endswith((".yaml", ".yml")):
continue
filepath = os.path.join(dirpath, filename)
rel_path = os.path.relpath(filepath, PROJECT_ROOT)
data = _load_yaml(filepath)
if not data:
continue
last_verified = data.get("last_verified") or data.get("last_updated")
if last_verified:
age = _days_ago(last_verified)
if age is not None:
if age > stale_days:
status = "STALE"
elif age > warning_days:
status = "WARNING"
else:
status = "FRESH"
results.append({"file": rel_path, "status": status, "age_days": age})
continue
# Use file modification time as fallback
mtime = os.path.getmtime(filepath)
mdate = datetime.fromtimestamp(mtime).date()
age = (date.today() - mdate).days
if age > stale_days:
status = "STALE"
elif age > warning_days:
status = "WARNING"
else:
status = "FRESH"
results.append({"file": rel_path, "status": status, "age_days": age})
return results
# =============================================================================
# Check: Contribution stats
# =============================================================================
def contribution_stats(tracker_path: str = TRACKER_PATH) -> Dict[str, int]:
"""Count contributions per person across findings."""
data = _load_yaml(tracker_path)
if not data or "findings" not in data:
return {}
counts: Dict[str, int] = {}
for f in data["findings"]:
contributor = f.get("contributor", {})
if isinstance(contributor, dict):
name = contributor.get("name", "Unknown")
else:
name = str(f.get("reported_by", "Unknown"))
counts[name] = counts.get(name, 0) + 1
for c in f.get("confirmations", []):
cname = c.get("name", "Unknown")
counts[cname] = counts.get(cname, 0) + 1
return counts
# =============================================================================
# CLI
# =============================================================================
def cmd_lint(args: argparse.Namespace) -> None:
"""Run all lint checks."""
total_issues = 0
# Contributor blocks
print("Checking contributor blocks...")
contributor_issues = check_contributor_blocks()
if contributor_issues:
for issue in contributor_issues:
print(f" {_c('ISSUE', _YELLOW)}: {issue}")
total_issues += len(contributor_issues)
else:
print(f" {_c('OK', _GREEN)}: All findings have valid contributor blocks.")
# Tags
if args.check_tags or not (args.show_decay or args.check_owners):
print("\nChecking tags against taxonomy...")
tag_issues = check_tags()
if tag_issues:
for issue in tag_issues:
print(f" {_c('ISSUE', _YELLOW)}: {issue}")
total_issues += len(tag_issues)
else:
print(f" {_c('OK', _GREEN)}: All tags are valid.")
# Domain owners
if args.check_owners or not (args.show_decay or args.check_tags):
print("\nChecking domain owners...")
owner_issues = check_owners()
if owner_issues:
for issue in owner_issues:
print(f" {_c('ISSUE', _YELLOW)}: {issue}")
total_issues += len(owner_issues)
else:
print(f" {_c('OK', _GREEN)}: All domains have assigned owners.")
# Freshness
if not (args.show_decay or args.check_tags or args.check_owners):
print("\nChecking file freshness...")
freshness = check_freshness()
stale = [f for f in freshness if f["status"] in ("STALE", "WARNING")]
if stale:
for f in stale:
status_color = _RED if f["status"] == "STALE" else _YELLOW
print(f" {_c(f['status'], status_color)}: {f['file']} ({f['age_days']} days old)")
total_issues += len(stale)
else:
print(f" {_c('OK', _GREEN)}: All files are fresh.")
# Confidence decay
if args.show_decay or not (args.check_tags or args.check_owners):
print("\nConfidence decay status...")
decay = check_confidence_decay()
for d in decay:
if d["status"] == "FRESH":
status_display = _c("FRESH", _GREEN)
elif d["status"] == "STALE":
status_display = _c("STALE", _YELLOW)
total_issues += 1
elif d["status"] == "EXPIRED":
status_display = _c("EXPIRED", _RED)
total_issues += 1
else:
status_display = d["status"]
age_str = f"{d['age_days']} days ago" if d["age_days"] is not None else "unknown age"
summary = d.get("summary", "")[:50]
print(f" {d['id']} {status_display} {summary} ({d['confidence']}, {age_str})")
# Contribution stats
if not (args.show_decay or args.check_tags or args.check_owners):
print("\nContribution stats:")
cstats = contribution_stats()
for name, count in sorted(cstats.items(), key=lambda x: -x[1]):
print(f" {name:<30} {count} contributions")
print(f"\n{'=' * 40}")
if total_issues:
print(f"{_c(f'{total_issues} issue(s) found.', _YELLOW)}")
else:
print(f"{_c('All checks passed.', _GREEN)}")
sys.exit(1 if total_issues > 0 else 0)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="kb_linter",
description="Validate KB files against governance rules.",
)
parser.add_argument("--show-decay", action="store_true", help="Show confidence decay status.")
parser.add_argument("--check-tags", action="store_true", help="Validate tags only.")
parser.add_argument("--check-owners", action="store_true", help="Check domain owner assignments.")
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
cmd_lint(args)
if __name__ == "__main__":
main()

713
tools/oci_bizcase_gen.py Normal file
View File

@@ -0,0 +1,713 @@
#!/usr/bin/env python3
"""
OCI Deal Accelerator — Business Case Deck Generator (.pptx)
Produces an 8-10 slide business case deck using the Oracle FY26 official
PowerPoint template layouts. Target audience: customer CxO / decision-makers.
Usage:
python oci_bizcase_gen.py --spec business-case.yaml --output business-case.pptx
Or import and use programmatically:
from oci_bizcase_gen import BusinessCaseDeckGenerator
gen = BusinessCaseDeckGenerator.from_spec(spec)
gen.save("business-case.pptx")
"""
import yaml
import argparse
from datetime import datetime
from pathlib import Path
from typing import Optional, List, Dict
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE
from oci_pptx_base import Colors, Layouts, OraclePresBase
# ============================================================
# Business Case Deck Generator
# ============================================================
class BusinessCaseDeckGenerator(OraclePresBase):
"""Generate business case decks using Oracle FY26 template layouts."""
# Use Teal accent for business case slides (finance / CxO audience)
TITLE_ACCENT_COLOR = Colors.TEAL
# ================================================================
# Slide Methods
# ================================================================
def add_cover_slide(self, customer: str, subtitle: str = "",
prepared_by: str = "", date: str = ""):
"""Slide 1: Cover using Dark Title_Pillar layout."""
slide = self._add_layout_slide(Layouts.COVER_DARK)
self._set_placeholder(slide, 0, customer) # Title
self._set_placeholder(slide, 33, subtitle or "Business Case for Oracle Cloud Infrastructure")
date_str = date or datetime.now().strftime("%B %Y")
self._set_placeholder(slide, 35, date_str)
if prepared_by:
self._set_placeholder(slide, 34, f"Prepared by: {prepared_by}")
def add_executive_summary_slide(self, statement: str):
"""Slide 2: Executive Summary — controlled typography on blank slide."""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Executive Summary")
# Body — large enough to read, small enough to fit the paragraph
body_box = slide.shapes.add_textbox(Inches(0.8), Inches(1.3), Inches(11.7), Inches(4.5))
tf = body_box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = statement
p.font.size = Pt(18)
p.font.name = self.FONT
p.font.color.rgb = Colors.PRIMARY_TEXT
p.space_after = Pt(6)
def add_business_drivers_slide(self, drivers: list):
"""Slide 3: Business Drivers — numbered cards on blank slide.
drivers: list of up to 3 strings, each a driver statement (may include newline
separating a bold headline from detail text).
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Business Drivers")
card_colors = [Colors.TEAL, Colors.BURNT_ORANGE, Colors.FOREST]
card_y_start = Inches(1.3)
available_h = Inches(5.8)
n = min(len(drivers), 3)
card_h = available_h / n if n else available_h
card_h = min(card_h, Inches(2.1))
for i, driver in enumerate(drivers[:3]):
color = card_colors[i % len(card_colors)]
y = card_y_start + i * card_h
# Left accent bar
accent = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.8), y + Inches(0.1), Inches(0.08), card_h - Inches(0.2))
accent.fill.solid()
accent.fill.fore_color.rgb = color
accent.line.fill.background()
# Number badge
self._add_textbox(
slide, Inches(1.05), y + Inches(0.12), Inches(0.5), Inches(0.45),
text=f"0{i+1}", font_size=18, bold=True, color=color,
)
# Split driver into headline + detail (split on \n if present)
parts = driver.split("\n", 1)
headline = parts[0].strip()
detail = parts[1].strip() if len(parts) > 1 else ""
# Headline
self._add_textbox(
slide, Inches(1.6), y + Inches(0.1), Inches(11.0), Inches(0.5),
text=headline, font_size=15, bold=True, color=Colors.PRIMARY_TEXT,
)
# Detail
if detail:
self._add_textbox(
slide, Inches(1.6), y + Inches(0.6), Inches(11.0), card_h - Inches(0.75),
text=detail, font_size=13, color=Colors.SECONDARY_TEXT,
)
def add_tco_slide(self, tco: dict):
"""Slide 4: TCO Comparison — table on blank slide.
tco: dict from business-case.yaml with current_state and proposed_oci.
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Total Cost of Ownership")
# Subtitle
horizon = tco.get("horizon_years", 3)
self._add_textbox(
slide, Inches(0.8), Inches(1.1), Inches(11), Inches(0.4),
text=f"{horizon}-Year Comparison | Current State vs Oracle Cloud Infrastructure",
font_size=13, color=Colors.TEAL, bold=True,
)
current = tco.get("current_state", {})
proposed = tco.get("proposed_oci", {})
savings = tco.get("savings", {})
rows_data = [
("Infrastructure", current.get("annual_infrastructure", 0), proposed.get("annual_cloud_consumption", 0)),
("Licensing / Support", current.get("annual_licensing", 0), proposed.get("annual_licensing", 0)),
("Operations (People)", current.get("annual_operations", 0), proposed.get("annual_operations", 0)),
("Downtime Cost", current.get("annual_downtime_cost", 0), proposed.get("annual_downtime_cost", 0)),
("Compliance", current.get("annual_compliance_cost", 0), proposed.get("annual_compliance_cost", 0)),
]
# Filter out zero rows
rows_data = [(n, c, o) for n, c, o in rows_data if c or o]
# Add migration one-time
migration = proposed.get("migration_one_time", 0)
num_rows = len(rows_data) + 3 # header + rows + annual total + horizon total
table = self._add_table(
slide, num_rows, 4,
Inches(0.8), Inches(1.6),
Inches(11.7), Inches(0.42 * num_rows),
)
table.columns[0].width = Inches(3.5)
table.columns[1].width = Inches(2.7)
table.columns[2].width = Inches(2.7)
table.columns[3].width = Inches(2.8)
# Header
for j, h in enumerate(["Cost Category", "Current (Annual)", "OCI (Annual)", "Savings"]):
self._style_table_cell(
table.cell(0, j), h, font_size=11, bold=True,
color=Colors.WHITE, bg_color=Colors.TEAL,
alignment=PP_ALIGN.CENTER if j > 0 else PP_ALIGN.LEFT,
)
# Data rows
for i, (name, curr, oci) in enumerate(rows_data):
row = i + 1
bg = Colors.TABLE_ALT_ROW if row % 2 == 0 else None
curr_val = curr if isinstance(curr, (int, float)) else 0
oci_val = oci if isinstance(oci, (int, float)) else 0
save_val = curr_val - oci_val
self._style_table_cell(table.cell(row, 0), name, font_size=10, bg_color=bg)
self._style_table_cell(table.cell(row, 1), f"${curr_val:,.0f}", font_size=10, bg_color=bg, alignment=PP_ALIGN.RIGHT)
self._style_table_cell(table.cell(row, 2), f"${oci_val:,.0f}", font_size=10, bg_color=bg, alignment=PP_ALIGN.RIGHT)
save_color = Colors.SUCCESS if save_val > 0 else Colors.ERROR
self._style_table_cell(table.cell(row, 3), f"${save_val:,.0f}", font_size=10, bold=True, color=save_color, bg_color=bg, alignment=PP_ALIGN.RIGHT)
# Annual total row
total_row = len(rows_data) + 1
ann_current = current.get("total_annual", 0) or sum(r[1] for r in rows_data if isinstance(r[1], (int, float)))
ann_oci = proposed.get("total_annual", 0) or sum(r[2] for r in rows_data if isinstance(r[2], (int, float)))
ann_savings = savings.get("annual", 0) or (ann_current - ann_oci)
self._style_table_cell(table.cell(total_row, 0), "TOTAL ANNUAL", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL)
self._style_table_cell(table.cell(total_row, 1), f"${ann_current:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
self._style_table_cell(table.cell(total_row, 2), f"${ann_oci:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
self._style_table_cell(table.cell(total_row, 3), f"${ann_savings:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
# Horizon total row
h_row = total_row + 1
h_current = current.get("total_over_horizon", 0) or (ann_current * horizon)
h_oci = proposed.get("total_over_horizon", 0) or (ann_oci * horizon + migration)
h_savings = savings.get("over_horizon", 0) or (h_current - h_oci)
pct = savings.get("percentage", 0) or (h_savings / h_current * 100 if h_current else 0)
self._style_table_cell(table.cell(h_row, 0), f"TOTAL {horizon}-YEAR", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG)
self._style_table_cell(table.cell(h_row, 1), f"${h_current:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
self._style_table_cell(table.cell(h_row, 2), f"${h_oci:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
self._style_table_cell(table.cell(h_row, 3), f"${h_savings:,.0f} ({pct:.0f}%)", font_size=11, bold=True, color=Colors.GOLD, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
# Migration note
if migration:
note_y = Inches(1.6) + Inches(0.42 * num_rows) + Inches(0.15)
self._add_textbox(
slide, Inches(0.8), note_y, Inches(11), Inches(0.3),
text=f"* Includes one-time migration investment of ${migration:,.0f} in Year 1",
font_size=9, italic=True, color=Colors.SECONDARY_TEXT,
)
# Assumptions
assumptions = tco.get("assumptions", [])
if assumptions:
a_y = Inches(1.6) + Inches(0.42 * num_rows) + Inches(0.45)
self._add_textbox(
slide, Inches(0.8), a_y, Inches(11), Inches(0.25),
text="Assumptions:", font_size=9, bold=True, color=Colors.SECONDARY_TEXT,
)
for idx, a in enumerate(assumptions[:4]):
self._add_textbox(
slide, Inches(1.0), a_y + Inches(0.25 + idx * 0.22),
Inches(11), Inches(0.22),
text=f"{a}", font_size=8, italic=True, color=Colors.SECONDARY_TEXT,
)
def add_roi_slide(self, roi: dict, headline: str = ""):
"""Slide 5: ROI — centered big number + 3 supporting metrics."""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Return on Investment")
# Big centered ROI number
pct = roi.get("three_year_roi_pct", 0)
metric_text = headline or (f"{pct:.0f}%" if pct else "")
big_box = slide.shapes.add_textbox(Inches(0.8), Inches(1.4), Inches(11.7), Inches(2.4))
big_box.text_frame.word_wrap = False
p = big_box.text_frame.paragraphs[0]
p.text = metric_text
p.font.size = Pt(96)
p.font.bold = True
p.font.name = self.FONT_HEADING
p.font.color.rgb = Colors.TEAL
p.alignment = PP_ALIGN.CENTER
# Label under big number
self._add_textbox(
slide, Inches(0.8), Inches(3.75), Inches(11.7), Inches(0.4),
text="3-Year Return on Investment", font_size=16,
color=Colors.SECONDARY_TEXT, alignment=PP_ALIGN.CENTER,
)
# 3 supporting metric boxes
payback = roi.get("payback_months", 0)
investment = roi.get("total_investment", 0)
benefit = roi.get("annual_net_benefit", 0)
metrics = []
if payback:
metrics.append((f"{payback} months", "Payback Period"))
if investment:
metrics.append((f"${investment:,.0f}", "Total Investment"))
if benefit:
metrics.append((f"${benefit:,.0f}/yr", "Annual Net Benefit"))
box_w = Inches(3.5)
gap = Inches(0.45)
total_w = box_w * len(metrics) + gap * (len(metrics) - 1)
x_start = (Inches(13.33) - total_w) / 2
y_box = Inches(4.4)
for j, (value, label) in enumerate(metrics):
x = x_start + j * (box_w + gap)
# Background
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y_box, box_w, Inches(1.4))
bg.fill.solid()
bg.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
bg.line.color.rgb = Colors.MUTED_TEAL
# Value
self._add_textbox(
slide, x, y_box + Inches(0.15), box_w, Inches(0.7),
text=value, font_size=26, bold=True, color=Colors.TEAL,
alignment=PP_ALIGN.CENTER,
)
# Label
self._add_textbox(
slide, x, y_box + Inches(0.85), box_w, Inches(0.45),
text=label, font_size=11, color=Colors.SECONDARY_TEXT,
alignment=PP_ALIGN.CENTER,
)
def add_value_drivers_slide(self, drivers: list):
"""Slide 6: Value Drivers — 4 categories on blank slide.
drivers: list of {"category": str, "title": str, "description": str, "quantified": str}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Value Drivers")
# 4 value cards in a 2x2 grid
card_colors = [Colors.TEAL, Colors.FOREST, Colors.BURNT_ORANGE, Colors.ORACLE_RED]
positions = [
(Inches(0.8), Inches(1.5)), # top-left
(Inches(6.8), Inches(1.5)), # top-right
(Inches(0.8), Inches(4.3)), # bottom-left
(Inches(6.8), Inches(4.3)), # bottom-right
]
card_w, card_h = Inches(5.5), Inches(2.5)
for i, driver in enumerate(drivers[:4]):
x, y = positions[i]
color = card_colors[i % len(card_colors)]
# Color accent bar
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Inches(0.08), card_h)
bar.fill.solid()
bar.fill.fore_color.rgb = color
bar.line.fill.background()
# Title
title = driver.get("title", driver.get("category", "").replace("_", " ").title())
self._add_textbox(
slide, x + Inches(0.25), y + Inches(0.1),
card_w - Inches(0.3), Inches(0.4),
text=title, font_size=16, bold=True, color=color,
)
# Quantified value (big)
quantified = driver.get("quantified", "")
if quantified:
self._add_textbox(
slide, x + Inches(0.25), y + Inches(0.55),
card_w - Inches(0.3), Inches(0.5),
text=quantified, font_size=22, bold=True, color=Colors.PRIMARY_TEXT,
)
# Description
desc = driver.get("description", "")
if desc:
self._add_textbox(
slide, x + Inches(0.25), y + Inches(1.2),
card_w - Inches(0.3), Inches(1.0),
text=desc, font_size=11, color=Colors.SECONDARY_TEXT,
)
def add_risk_slide(self, migration_risks: list, do_nothing_risks: list):
"""Slide 7: Risk Assessment — two-column table layout."""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Risk Assessment")
col_w = Inches(5.6)
col_gap = Inches(0.5)
left_x = Inches(0.8)
right_x = left_x + col_w + col_gap
header_y = Inches(1.2)
content_start_y = Inches(1.75)
row_h = Inches(0.8) # height per risk block (title + detail)
slide_h = Inches(7.1)
# Stretch rows to fill available slide height
n_rows = max(len(migration_risks[:5]), len(do_nothing_risks[:5]))
if n_rows > 0:
available = slide_h - content_start_y
row_h = min(Inches(1.8), available / n_rows)
# Column header backgrounds
for x, color, label in [
(left_x, Colors.TEAL, "✓ Migration Risks (Mitigated)"),
(right_x, Colors.ERROR, "⚠ Risks of Inaction"),
]:
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, header_y, col_w, Inches(0.45))
bg.fill.solid()
bg.fill.fore_color.rgb = color
bg.line.fill.background()
self._add_textbox(
slide, x + Inches(0.15), header_y + Inches(0.05), col_w - Inches(0.2), Inches(0.38),
text=label, font_size=13, bold=True, color=Colors.WHITE,
)
# Left column: migration risks
for i, risk in enumerate(migration_risks[:5]):
r_text = risk.get("risk", str(risk)) if isinstance(risk, dict) else str(risk)
mitigation = risk.get("mitigation", "") if isinstance(risk, dict) else ""
y = content_start_y + i * row_h
# Alternating row background
if i % 2 == 0:
row_bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left_x, y, col_w, row_h)
row_bg.fill.solid()
row_bg.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
row_bg.line.fill.background()
self._add_textbox(
slide, left_x + Inches(0.15), y + Inches(0.07), col_w - Inches(0.25), Inches(0.4),
text=f"{r_text}", font_size=12, bold=True, color=Colors.PRIMARY_TEXT,
)
if mitigation:
self._add_textbox(
slide, left_x + Inches(0.25), y + Inches(0.45), col_w - Inches(0.35), row_h - Inches(0.48),
text=f"Mitigation: {mitigation}", font_size=11,
italic=True, color=Colors.SECONDARY_TEXT,
)
# Right column: do-nothing risks
for i, risk in enumerate(do_nothing_risks[:5]):
r_text = risk.get("risk", str(risk)) if isinstance(risk, dict) else str(risk)
impact = risk.get("impact", "") if isinstance(risk, dict) else ""
timeline = risk.get("timeline", "") if isinstance(risk, dict) else ""
y = content_start_y + i * row_h
if i % 2 == 0:
row_bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, right_x, y, col_w, row_h)
row_bg.fill.solid()
row_bg.fill.fore_color.rgb = RGBColor(0xFF, 0xF3, 0xF3)
row_bg.line.fill.background()
self._add_textbox(
slide, right_x + Inches(0.15), y + Inches(0.07), col_w - Inches(0.25), Inches(0.4),
text=f"{r_text}", font_size=12, bold=True, color=Colors.ERROR,
)
detail_parts = []
if impact:
detail_parts.append(f"Impact: {impact}")
if timeline:
detail_parts.append(f"By: {timeline}")
if detail_parts:
self._add_textbox(
slide, right_x + Inches(0.25), y + Inches(0.45), col_w - Inches(0.35), row_h - Inches(0.48),
text=" | ".join(detail_parts), font_size=10,
italic=True, color=Colors.SECONDARY_TEXT,
)
def add_roadmap_slide(self, phases: list, total_duration: str = ""):
"""Slide 8: Implementation Roadmap — timeline bars filling the slide."""
slide = self._add_blank_slide()
title = "Implementation Roadmap"
if total_duration:
title += f"{total_duration}"
self._add_title_bar(slide, title)
phase_colors = [Colors.TEAL, Colors.BURNT_ORANGE, Colors.FOREST, Colors.ORACLE_RED]
content_start = Inches(1.25)
available_h = Inches(5.9)
n = min(len(phases), 4)
row_h = available_h / n if n else available_h
bar_left = Inches(3.6)
bar_w = Inches(9.1)
label_w = Inches(2.6)
for i, phase in enumerate(phases[:4]):
color = phase_colors[i % len(phase_colors)]
y = content_start + i * row_h
name = phase.get("name", f"Phase {i+1}")
duration = phase.get("duration", "")
deliverables = phase.get("deliverables", [])
quick_wins = phase.get("quick_wins", [])
# Alternating row background
if i % 2 == 0:
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.8), y, Inches(12.0), row_h - Inches(0.08))
bg.fill.solid()
bg.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
bg.line.fill.background()
# Phase number circle accent
dot = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(0.82), y + (row_h - Inches(0.38)) / 2, Inches(0.38), Inches(0.38))
dot.fill.solid()
dot.fill.fore_color.rgb = color
dot.line.fill.background()
dot.text_frame.paragraphs[0].text = str(i + 1)
dot.text_frame.paragraphs[0].font.size = Pt(11)
dot.text_frame.paragraphs[0].font.bold = True
dot.text_frame.paragraphs[0].font.color.rgb = Colors.WHITE
dot.text_frame.paragraphs[0].font.name = self.FONT
dot.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
# Phase name
self._add_textbox(
slide, Inches(1.35), y + Inches(0.08), label_w, Inches(0.45),
text=name, font_size=14, bold=True, color=color,
)
# Duration bar
bar_h = Inches(0.42)
bar_y = y + (row_h - bar_h) / 2
bar = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, bar_left, bar_y, bar_w * 0.55, bar_h)
bar.fill.solid()
bar.fill.fore_color.rgb = color
bar.line.fill.background()
bar.text_frame.paragraphs[0].text = duration
bar.text_frame.paragraphs[0].font.size = Pt(12)
bar.text_frame.paragraphs[0].font.bold = True
bar.text_frame.paragraphs[0].font.color.rgb = Colors.WHITE
bar.text_frame.paragraphs[0].font.name = self.FONT
bar.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
# Deliverables — listed to the right of the bar
items = quick_wins + deliverables
if items:
items_text = "".join(items[:3])
self._add_textbox(
slide, bar_left + bar_w * 0.57, bar_y - Inches(0.02),
bar_w * 0.43, bar_h + Inches(0.05),
text=items_text, font_size=11, color=Colors.SECONDARY_TEXT,
)
def add_recommendation_slide(self, summary: str, next_steps: list = None):
"""Slide 9: Recommendation — dark blank slide with controlled fonts."""
slide = self._add_blank_slide(dark=True)
# "Recommendation" label in gold
self._add_textbox(
slide, Inches(0.8), Inches(0.45), Inches(11.7), Inches(0.5),
text="Our Recommendation", font_size=13, bold=True,
color=Colors.GOLD, font_name=self.FONT,
)
# Gold accent bar
acc = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.8), Inches(1.0), Inches(11.7), Inches(0.05))
acc.fill.solid()
acc.fill.fore_color.rgb = Colors.GOLD
acc.line.fill.background()
# Main recommendation statement — 20pt, white, readable
rec_box = slide.shapes.add_textbox(Inches(0.8), Inches(1.2), Inches(11.7), Inches(2.2))
tf = rec_box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = summary
p.font.size = Pt(20)
p.font.bold = True
p.font.name = self.FONT_HEADING
p.font.color.rgb = Colors.WHITE
# Next steps section
if next_steps:
self._add_textbox(
slide, Inches(0.8), Inches(3.6), Inches(4.0), Inches(0.4),
text="NEXT STEPS", font_size=11, bold=True,
color=Colors.GOLD,
)
# Divider
div = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.8), Inches(4.05), Inches(11.7), Inches(0.03))
div.fill.solid()
div.fill.fore_color.rgb = RGBColor(0x55, 0x50, 0x4C)
div.line.fill.background()
y = Inches(4.15)
for i, step in enumerate(next_steps[:4]):
action = step.get("action", str(step)) if isinstance(step, dict) else str(step)
owner = step.get("owner", "") if isinstance(step, dict) else ""
deadline = step.get("deadline", "") if isinstance(step, dict) else ""
# Number
self._add_textbox(
slide, Inches(0.8), y, Inches(0.4), Inches(0.5),
text=str(i + 1), font_size=13, bold=True, color=Colors.GOLD,
)
# Action text
action_text = action
meta_parts = []
if owner:
meta_parts.append(owner)
if deadline:
meta_parts.append(deadline)
meta = f" [{' · '.join(meta_parts)}]" if meta_parts else ""
self._add_textbox(
slide, Inches(1.25), y, Inches(11.2), Inches(0.5),
text=action_text + meta, font_size=13, color=Colors.WHITE,
)
y += Inches(0.6)
# ================================================================
# Build from YAML spec
# ================================================================
@classmethod
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "BusinessCaseDeckGenerator":
"""Build a complete business case deck from a YAML specification."""
bc = spec.get("business_case", spec) # Support both wrapped and unwrapped
gen = cls(template=template)
# Slide 1: Cover
gen.add_cover_slide(
customer=bc.get("customer_name", ""),
subtitle="Business Case for Oracle Cloud Infrastructure",
prepared_by=bc.get("prepared_by", ""),
date=bc.get("date", ""),
)
# Slide 2: Executive Summary
exec_summary = bc.get("executive_summary", "")
if exec_summary:
gen.add_executive_summary_slide(exec_summary)
# Slide 3: Business Drivers
drivers_data = bc.get("drivers", {})
if drivers_data:
driver_statements = []
primary = drivers_data.get("primary", "")
urgency = drivers_data.get("urgency", "")
coi = drivers_data.get("cost_of_inaction", {})
if primary:
driver_statements.append(f"Primary Driver: {primary.replace('_', ' ').title()}\n{urgency}" if urgency else primary.replace('_', ' ').title())
if coi.get("financial"):
driver_statements.append(f"Financial Impact of Inaction\n{coi['financial']}")
if coi.get("operational"):
driver_statements.append(f"Operational Impact\n{coi['operational']}")
elif coi.get("strategic"):
driver_statements.append(f"Strategic Risk\n{coi['strategic']}")
if driver_statements:
gen.add_business_drivers_slide(driver_statements)
# Slide 4: TCO Comparison
tco = bc.get("tco", {})
if tco and (tco.get("current_state") or tco.get("proposed_oci")):
gen.add_tco_slide(tco)
# Slide 5: ROI
roi = bc.get("roi", {})
if roi and any(roi.get(k) for k in ["three_year_roi_pct", "payback_months", "total_investment"]):
gen.add_roi_slide(roi)
# Slide 6: Value Drivers
value_drivers = bc.get("value_drivers", [])
if value_drivers:
gen.add_value_drivers_slide(value_drivers)
# Slide 7: Risk Assessment
risks = bc.get("risks", {})
migration_risks = risks.get("migration_risks", [])
do_nothing_risks = risks.get("do_nothing_risks", [])
if migration_risks or do_nothing_risks:
gen.add_risk_slide(migration_risks, do_nothing_risks)
# Slide 8: Roadmap
roadmap = bc.get("roadmap", {})
if roadmap.get("phases"):
gen.add_roadmap_slide(
phases=roadmap["phases"],
total_duration=roadmap.get("total_duration", ""),
)
# Slide 9: Recommendation
rec = bc.get("recommendation", {})
if rec:
summary = rec.get("summary", "")
if not summary and rec.get("commitment_amount"):
summary = f"Recommended: {rec['commitment_amount']} {rec.get('commitment_type', 'UCM')} commitment"
if summary:
gen.add_recommendation_slide(
summary=summary,
next_steps=rec.get("next_steps", []),
)
# Closing slides
prepared_by = bc.get("prepared_by", "")
gen.add_closing_slide(name=prepared_by)
return gen
# ============================================================
# CLI
# ============================================================
def main():
parser = argparse.ArgumentParser(
description="Generate OCI business case slide deck (.pptx)"
)
parser.add_argument(
"--spec", required=True,
help="Path to YAML business case spec file",
)
parser.add_argument(
"--output", default="business-case.pptx",
help="Output .pptx file path",
)
parser.add_argument(
"--template",
help="Path to Oracle FY26 .pptx template (default: templates/Oracle_PPT-template_FY26.pptx)",
)
args = parser.parse_args()
with open(args.spec, 'r') as f:
spec = yaml.safe_load(f)
gen = BusinessCaseDeckGenerator.from_spec(spec, template=args.template)
gen.save(args.output)
print(f"Generated: {args.output}")
print(f" Slides: {gen.slide_count}")
print(f" Template: Oracle FY26")
customer = spec.get("business_case", spec).get("customer_name", "")
if customer:
print(f" Customer: {customer}")
if __name__ == "__main__":
main()

1301
tools/oci_deck_gen.py Normal file

File diff suppressed because it is too large Load Diff

1431
tools/oci_diagram_gen.py Normal file

File diff suppressed because it is too large Load Diff

163
tools/oci_icon_extractor.py Normal file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
OCI Icon Extractor
Reads the OCI Library.xml (draw.io library), decodes each icon's embedded XML,
and saves a JSON mapping file (oci-icons.json) for use by oci_diagram_gen.py.
Usage:
python3 tools/oci_icon_extractor.py
Output:
kb/diagram/oci-icons.json
"""
import json
import re
import base64
import zlib
import urllib.parse
import os
import sys
import xml.etree.ElementTree as ET
from typing import List
# Map from library index to (list of service_type keys)
# The index corresponds to the icon's position in the OCI Library.xml JSON array.
ICON_KEY_MAP = {
3: ["vm", "compute"],
2: ["functions"],
5: ["flex_vm"],
7: ["block_storage"],
10: ["file_storage"],
11: ["object_storage"],
17: ["vcn_icon"],
18: ["load_balancer"],
19: ["flexible_lb"],
30: ["drg", "dynamic_routing_gateway"],
31: ["sgw", "service_gateway"],
32: ["igw", "internet_gateway"],
33: ["natgw", "nat_gateway"],
36: ["dbcs", "db_system"],
37: ["autonomous_db", "adb", "adb_s", "atp", "adw"],
38: ["adb_d"],
43: ["exadata", "exacs"],
45: ["data_safe"],
46: ["nosql"],
47: ["mysql"],
49: ["opensearch"],
51: ["goldengate"],
65: ["streaming", "kafka"],
66: ["service_connector_hub"],
74: ["oke"],
77: ["apex"],
79: ["apigw", "api_gateway"],
82: ["notifications"],
97: ["cloud_guard"],
101: ["network_firewall"],
102: ["waf"],
105: ["vault"],
106: ["bastion"],
114: ["nsg"],
116: ["apm"],
117: ["logging"],
120: ["monitoring"],
123: ["logging_analytics"],
124: ["events"],
125: ["ops_insights"],
127: ["queue", "oci_queue"],
158: ["db_management"],
}
def decode_icon_xml(xml_encoded: str) -> str:
"""Decode a draw.io library icon's XML: base64 -> zlib decompress -> URL decode."""
decoded = base64.b64decode(xml_encoded)
decompressed = zlib.decompress(decoded, -15)
return urllib.parse.unquote(decompressed.decode("utf-8"))
def extract_icon_cells(xml_str: str) -> List[str]:
"""Extract mxCell elements from decoded icon XML, excluding root cells 0 and 1."""
root = ET.fromstring(xml_str)
cells = root.findall(".//mxCell")
result = []
for cell in cells:
cell_id = cell.get("id", "")
if cell_id in ("0", "1"):
continue
result.append(ET.tostring(cell, encoding="unicode"))
return result
def main():
# Resolve paths relative to project root
script_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(script_dir)
library_path = os.path.join(project_root, "kb", "diagram", "OCI Library.xml")
output_path = os.path.join(project_root, "kb", "diagram", "oci-icons.json")
if not os.path.exists(library_path):
print(f"Error: OCI Library.xml not found at {library_path}", file=sys.stderr)
sys.exit(1)
with open(library_path, "r", encoding="utf-8") as f:
content = f.read()
# Parse JSON array from <mxlibrary>...</mxlibrary>
m = re.search(r"<mxlibrary>(.*?)</mxlibrary>", content, re.DOTALL)
if not m:
print("Error: Could not find <mxlibrary> tags in OCI Library.xml", file=sys.stderr)
sys.exit(1)
icons = json.loads(m.group(1))
print(f"Loaded {len(icons)} icons from OCI Library.xml")
# Build the output mapping
icon_data = {}
processed = 0
for idx, keys in ICON_KEY_MAP.items():
if idx >= len(icons):
print(f" Warning: index {idx} out of range (max {len(icons) - 1}), skipping")
continue
icon = icons[idx]
title = icon.get("title", f"Icon_{idx}")
w = icon.get("w", 84)
h = icon.get("h", 109)
# Decode the XML
try:
xml_str = decode_icon_xml(icon["xml"])
cells = extract_icon_cells(xml_str)
except Exception as e:
print(f" Warning: failed to decode icon [{idx}] {title}: {e}")
continue
entry = {
"title": title,
"w": w,
"h": h,
"cells": cells,
}
# Map all keys to this entry
for key in keys:
icon_data[key] = entry
processed += 1
# Write output
with open(output_path, "w", encoding="utf-8") as f:
json.dump(icon_data, f, indent=2, ensure_ascii=False)
unique_icons = processed
total_keys = len(icon_data)
print(f"Extracted {unique_icons} unique icons mapped to {total_keys} service type keys")
print(f"Output: {output_path}")
if __name__ == "__main__":
main()

171
tools/oci_output.py Normal file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
OCI Deal Accelerator — Output Orchestrator
Routes output generation based on the architect's format selection.
Supports: deck (default), deck+drawio, deck+doc, deck+xlsx, deck+pdf,
full, pdf, doc only.
Usage:
python oci_output.py --spec proposal.yaml --format deck --output-dir ./output
python oci_output.py --spec proposal.yaml --format full --output-dir ./output
"""
import yaml
import argparse
import os
import sys
from pathlib import Path
# Output format definitions
FORMATS = {
"deck": ["pptx"],
"deck+drawio": ["pptx", "drawio"],
"deck+doc": ["pptx", "docx"],
"deck+xlsx": ["pptx", "xlsx"],
"deck+pdf": ["pptx", "pdf"],
"full": ["pptx", "drawio", "docx", "xlsx", "pdf"],
"doc": ["docx"],
"pdf": ["pdf"],
}
GENERATORS = {
"pptx": {
"module": "oci_deck_gen",
"class": "OCIDeckGenerator",
"method": "from_spec",
"description": "Slide deck (.pptx)",
},
"drawio": {
"module": "oci_diagram_gen",
"class": "OCIDiagramGenerator",
"method": "from_spec",
"description": "Architecture diagram (.drawio)",
},
"docx": {
"module": "oci_doc_gen",
"class": "OCIDocGenerator",
"method": "from_spec",
"description": "Technical document (.docx)",
"optional": True,
},
"xlsx": {
"module": "oci_cost_gen",
"class": "OCICostGenerator",
"method": "from_spec",
"description": "Cost spreadsheet (.xlsx)",
"optional": True,
},
"pdf": {
"module": "oci_pdf_gen",
"class": "OCIPDFGenerator",
"method": "from_spec",
"description": "Customer-facing PDF (.pdf)",
},
}
def resolve_format(format_str: str) -> list:
"""Resolve a format string to a list of output types."""
normalized = format_str.lower().strip().replace(" ", "")
# Handle common variations
for key in FORMATS:
if normalized == key.replace("+", "").replace(" ", ""):
return FORMATS[key]
if normalized in FORMATS:
return FORMATS[normalized]
# Default to deck
print(f"Warning: Unknown format '{format_str}', defaulting to 'deck'")
return FORMATS["deck"]
def generate_output(spec: dict, output_type: str, output_dir: str,
base_name: str) -> str | None:
"""Generate a single output type. Returns the output path or None."""
gen_config = GENERATORS.get(output_type)
if not gen_config:
print(f" Skip: No generator for .{output_type}")
return None
output_path = os.path.join(output_dir, f"{base_name}.{output_type}")
try:
# Dynamic import
module = __import__(gen_config["module"])
gen_class = getattr(module, gen_config["class"])
factory = getattr(gen_class, gen_config["method"])
gen = factory(spec)
gen.save(output_path)
print(f" Generated: {output_path}")
return output_path
except ImportError:
if gen_config.get("optional"):
print(f" Skip: {gen_config['description']} "
f"(generator not available)")
return None
raise
except Exception as e:
print(f" Error generating {gen_config['description']}: {e}")
return None
def orchestrate(spec_path: str, format_str: str = "deck",
output_dir: str = ".", base_name: str = None) -> list:
"""Main orchestration: load spec, generate all requested outputs."""
with open(spec_path, 'r') as f:
spec = yaml.safe_load(f)
if not base_name:
base_name = Path(spec_path).stem
os.makedirs(output_dir, exist_ok=True)
output_types = resolve_format(format_str)
print(f"Output format: {format_str}")
print(f"Generating: {', '.join(f'.{t}' for t in output_types)}")
print(f"Output directory: {output_dir}")
print()
results = []
for output_type in output_types:
result = generate_output(spec, output_type, output_dir, base_name)
if result:
results.append(result)
print(f"\nDone. Generated {len(results)} file(s).")
return results
def main():
parser = argparse.ArgumentParser(
description="OCI Deal Accelerator — Output Orchestrator"
)
parser.add_argument(
"--spec", required=True,
help="Path to YAML spec file",
)
parser.add_argument(
"--format", default="deck",
choices=list(FORMATS.keys()),
help="Output format selection (default: deck)",
)
parser.add_argument(
"--output-dir", default=".",
help="Output directory (default: current directory)",
)
parser.add_argument(
"--name", default=None,
help="Base name for output files (default: spec filename)",
)
args = parser.parse_args()
# Add tools/ to path so generators can be imported
tools_dir = os.path.dirname(os.path.abspath(__file__))
if tools_dir not in sys.path:
sys.path.insert(0, tools_dir)
orchestrate(args.spec, args.format, args.output_dir, args.name)
if __name__ == "__main__":
main()

1184
tools/oci_pdf_gen.py Normal file

File diff suppressed because it is too large Load Diff

278
tools/oci_pptx_base.py Normal file
View File

@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""
OCI Deal Accelerator — Shared PPTX base (Oracle FY26 template + Redwood design system).
Import: from oci_pptx_base import Colors, Layouts, OraclePresBase
"""
from pathlib import Path
from typing import Optional
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
# ============================================================
# Oracle Redwood Design System Colors
# Source: Oracle Database Icon Library [July 2024] — theme RMIL_Master
# Color scheme: "Oracle Redwood"
# ============================================================
class Colors:
# Primary text — never pure black
DARK_BG = RGBColor(0x31, 0x2D, 0x2A) # #312D2A — dk1
PRIMARY_TEXT = RGBColor(0x31, 0x2D, 0x2A) # #312D2A — dk1
SECONDARY_TEXT = RGBColor(0x6F, 0x69, 0x64) # #6F6964 — Neutral 60
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
WARM_BG = RGBColor(0xFC, 0xFB, 0xFA) # #FCFBFA — lt1, content bg
# Oracle Redwood accent palette
ORACLE_RED = RGBColor(0xC7, 0x46, 0x34) # #C74634 — accent1, primary brand
GOLD = RGBColor(0xFA, 0xCD, 0x62) # #FACD62 — accent2
TEAL = RGBColor(0x2C, 0x59, 0x67) # #2C5967 — hlink, tables/links
SAGE = RGBColor(0x75, 0x9C, 0x6C) # #759C6C — accent6
FOREST = RGBColor(0x2B, 0x62, 0x42) # #2B6242 — accent4
MUTED_TEAL = RGBColor(0x94, 0xAF, 0xAF) # #94AFAF — accent3
BURNT_ORANGE = RGBColor(0xAE, 0x56, 0x2C) # #AE562C — accent5
# OCI service category colors (used in architecture diagrams)
COPPER = RGBColor(0xAA, 0x64, 0x3B) # #AA643B — database
PURPLE = RGBColor(0x80, 0x49, 0x98) # #804998 — integration
# Status
SUCCESS = RGBColor(0x2B, 0x62, 0x42) # #2B6242 — forest green
WARNING = RGBColor(0xAE, 0x56, 0x2C) # #AE562C — burnt orange
ERROR = RGBColor(0xC7, 0x46, 0x34) # #C74634 — Oracle red
# Table
TABLE_ALT_ROW = RGBColor(0xF5, 0xF4, 0xF2) # #F5F4F2 — Neutral 10
TABLE_HEADER_BG = RGBColor(0x2C, 0x59, 0x67) # teal
# ============================================================
# Oracle FY26 Template Layout Indices
# ============================================================
class Layouts:
"""Layout indices from Oracle_PPT-template_FY26.pptx (104 layouts)."""
COVER_DARK = 0 # Dark - Title_Pillar
COVER_LIGHT = 1 # Light - Title_Pillar
DIVIDER_LIGHT = 16 # Light - Divider
DIVIDER_DARK = 17 # Dark - Divider
IMPACT_DARK = 26 # Dark - Impact Statement
IMPACT_LIGHT = 27 # Light - Impact Statement
METRIC_DARK = 32 # Dark - Single metric
METRIC_LIGHT = 33 # Light - Single metric
MULTI_STATEMENT_DARK = 47 # Multi Statement Dark
MULTI_STATEMENT_LIGHT = 48 # Multi Statement Light
TWO_COL_LIGHT = 66 # Light - Title/Subtitle 2 Column
TWO_COL_DARK = 67 # Dark - Title/Subtitle 2 Column
FOUR_ICONS_LIGHT = 82 # Light - 4 Icons, Subhead and text
BLANK_DARK = 84 # Dark - Blank
BLANK_LIGHT = 85 # Light - Blank
SAFE_HARBOR_LIGHT = 96 # Light - Safe harbor - short (if exists)
THANK_YOU_DARK = 34 # Dark - Thank You
THANK_YOU_LIGHT = 35 # Light - Thank You
CLOSING_LIGHT = 101 # Light - Closing Slide (Oracle logo only)
CLOSING_DARK = 102 # Dark - Closing Slide (Oracle logo only)
# ============================================================
# Base Presentation Class
# ============================================================
class OraclePresBase:
"""Shared PPTX infrastructure for OCI Deal Accelerator generators."""
TEMPLATE_PATH = Path(__file__).parent.parent / "templates" / "Oracle_PPT-template_FY26.pptx"
FONT = "Oracle Sans"
FONT_HEADING = "Georgia"
TITLE_ACCENT_COLOR = Colors.TEAL # subclasses can override as class attribute
def __init__(self, template: Optional[str] = None):
tmpl = template or str(self.TEMPLATE_PATH)
if not Path(tmpl).is_file():
raise FileNotFoundError(f"Template not found: {tmpl}")
self.prs = Presentation(tmpl)
self._clear_slides()
def _clear_slides(self):
"""Remove all existing slides from the template (keep layouts/masters)."""
while len(self.prs.slides) > 0:
rId = self.prs.slides._sldIdLst[0].get(
'{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'
)
self.prs.part.drop_rel(rId)
self.prs.slides._sldIdLst.remove(self.prs.slides._sldIdLst[0])
# Clean sections
from lxml import etree # noqa: F401
ns_p14 = 'http://schemas.microsoft.com/office/powerpoint/2010/main'
for sectionLst in self.prs.part._element.findall(f'.//{{{ns_p14}}}sectionLst'):
sectionLst.getparent().remove(sectionLst)
def _add_layout_slide(self, layout_index: int):
"""Add a slide using a specific template layout."""
layout = self.prs.slide_layouts[layout_index]
return self.prs.slides.add_slide(layout)
def _add_blank_slide(self, dark: bool = False):
"""Add a blank slide (light or dark)."""
idx = Layouts.BLANK_DARK if dark else Layouts.BLANK_LIGHT
return self._add_layout_slide(idx)
def _set_placeholder(self, slide, idx: int, text: str):
"""Set text in a placeholder by index. Silently skips if not found."""
for ph in slide.placeholders:
if ph.placeholder_format.idx == idx:
ph.text = text
return ph
return None
def _style_placeholder(self, slide, idx: int, text: str,
font_size: int = None, bold: bool = None,
color: RGBColor = None):
"""Set text and style a placeholder."""
ph = self._set_placeholder(slide, idx, text)
if ph and ph.text_frame.paragraphs:
for p in ph.text_frame.paragraphs:
if font_size:
p.font.size = Pt(font_size)
if bold is not None:
p.font.bold = bold
if color:
p.font.color.rgb = color
return ph
def _add_textbox(self, slide, left, top, width, height,
text="", font_size=12, bold=False, italic=False,
color=None, alignment=PP_ALIGN.LEFT, font_name=None):
"""Add a text box to a slide."""
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = text
p.font.size = Pt(font_size)
p.font.bold = bold
p.font.italic = italic
p.font.name = font_name or self.FONT
p.font.color.rgb = color or Colors.PRIMARY_TEXT
p.alignment = alignment
return txBox
def _add_paragraph(self, text_frame, text="", font_size=12,
bold=False, italic=False, color=None,
alignment=PP_ALIGN.LEFT, space_before=0,
space_after=0, bullet=False):
"""Add a paragraph to an existing text frame."""
p = text_frame.add_paragraph()
p.text = text
p.font.size = Pt(font_size)
p.font.bold = bold
p.font.italic = italic
p.font.name = self.FONT
p.font.color.rgb = color or Colors.PRIMARY_TEXT
p.alignment = alignment
if space_before:
p.space_before = Pt(space_before)
if space_after:
p.space_after = Pt(space_after)
if bullet:
p.level = 0
return p
def _add_rect(self, slide, left, top, width, height,
fill_color=None, border_color=None):
"""Add a rectangle shape."""
shape = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, left, top, width, height
)
if fill_color:
shape.fill.solid()
shape.fill.fore_color.rgb = fill_color
else:
shape.fill.background()
if border_color:
shape.line.color.rgb = border_color
else:
shape.line.fill.background()
return shape
def _add_table(self, slide, rows, cols, left, top, width, height):
"""Add a table to a slide."""
return slide.shapes.add_table(rows, cols, left, top, width, height).table
def _style_table_cell(self, cell, text, font_size=10, bold=False,
color=None, bg_color=None, alignment=PP_ALIGN.LEFT):
"""Style a table cell."""
cell.text = text
for paragraph in cell.text_frame.paragraphs:
paragraph.font.size = Pt(font_size)
paragraph.font.bold = bold
paragraph.font.name = self.FONT
paragraph.font.color.rgb = color or Colors.PRIMARY_TEXT
paragraph.alignment = alignment
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
if bg_color:
cell.fill.solid()
cell.fill.fore_color.rgb = bg_color
def _add_title_bar(self, slide, title_text, margin=None):
"""Add a consistent title bar at top of content slides.
Uses self.TITLE_ACCENT_COLOR for the accent line.
Title is 24pt bold Georgia. margin defaults to Inches(0.8).
"""
if margin is None:
margin = Inches(0.8)
self._add_textbox(
slide, margin, Inches(0.4), Inches(11.7), Inches(0.6),
text=title_text, font_size=24, bold=True,
font_name=self.FONT_HEADING,
)
bar = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, margin, Inches(1.05), Inches(11.7), Inches(0.05)
)
bar.fill.solid()
bar.fill.fore_color.rgb = self.TITLE_ACCENT_COLOR
bar.line.fill.background()
def add_closing_slide(self, name: str = "", title: str = "",
contact: str = "", dark: bool = True):
"""Closing slide: Thank You (dark by default) + Oracle logo closing.
Uses the FY26 Thank You layout (PH0 = "Thank you", PH37 = name/title,
PH36 = contact/URL), followed by the Oracle Closing Slide layout
(logo-only, no placeholders).
Args:
name: Presenter name, e.g. "Diego Cabrera"
title: Presenter title/firm, e.g. "Solutions Architect | Oracle"
contact: Contact line, e.g. email or URL
dark: Use dark variant (default True)
"""
layout_idx = Layouts.THANK_YOU_DARK if dark else Layouts.THANK_YOU_LIGHT
slide = self._add_layout_slide(layout_idx)
# PH0 defaults to "Thank you" from the template — clear if not wanted
# PH37: name + title on one line
if name or title:
parts = [p for p in [name, title] if p]
self._set_placeholder(slide, 37, " | ".join(parts))
# PH36: contact / URL
if contact:
self._set_placeholder(slide, 36, contact)
# Oracle Closing Slide (logo-only)
closing_idx = Layouts.CLOSING_DARK if dark else Layouts.CLOSING_LIGHT
self._add_layout_slide(closing_idx)
def save(self, filepath: str):
"""Save the presentation to a .pptx file."""
self.prs.save(filepath)
@property
def slide_count(self):
return len(self.prs.slides)

View File

@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
Refresh the Architecture Center catalog from Oracle docs.
Usage:
python tools/refresh_arch_catalog.py --url https://docs.oracle.com/en/solutions/{slug}/index.html
python tools/refresh_arch_catalog.py --whats-new
python tools/refresh_arch_catalog.py --validate
This script:
1. Fetches the What's New pages for 2024 and 2025
2. Extracts all reference architecture links
3. For each link, fetches the detail page
4. Extracts title, summary, services, recommendations
5. Writes/updates kb/architecture-center/catalog.yaml
IMPORTANT: This is a semi-automated tool. It generates DRAFT entries
that should be reviewed by a human before committing. The auto-generated
summaries may miss nuance or include irrelevant detail.
Requirements: pip install requests beautifulsoup4 pyyaml
"""
import argparse
import os
import re
import sys
import time
from datetime import datetime
from urllib.parse import urljoin
import requests
import yaml
from bs4 import BeautifulSoup
CATALOG_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"kb", "architecture-center", "catalog.yaml",
)
WHATS_NEW_URLS = [
"https://docs.oracle.com/en/solutions/oracle-architecture-center/whats-new-20251.html",
"https://docs.oracle.com/en/solutions/oracle-architecture-center/whats-new-2024.html",
]
# ---------------------------------------------------------------------------
# Service name detection patterns
# ---------------------------------------------------------------------------
SERVICE_PATTERNS = {
"adb-s": r"Autonomous\s+(Database|Transaction|Data\s+Warehouse)\s+Serverless|ADB-S|ATP\s+Serverless|Autonomous\s+(Database|Transaction|Data\s+Warehouse)(?!.*Dedicated)",
"adb-d": r"Autonomous\s+Database\s+on\s+Dedicated|ADB-D|Dedicated\s+Exadata\s+Infrastructure",
"exacs": r"Exadata\s+(Cloud|Database)\s+Service|ExaCS|ExaDB-D",
"exascale": r"Exascale",
"base-db": r"Base\s+Database|VM\s+DB|DB\s+System|Oracle\s+Database\s+Service",
"oke": r"Kubernetes\s+Engine|OKE|Container\s+Engine\s+for\s+Kubernetes",
"compute": r"Compute\s+(Instance|VM)|Virtual\s+Machine|Bare\s+Metal",
"functions": r"OCI\s+Functions|Functions\s+Service",
"vcn": r"Virtual\s+Cloud\s+Network|VCN",
"load-balancer": r"Load\s+Balanc",
"waf": r"Web\s+Application\s+Firewall|WAF",
"fastconnect": r"FastConnect",
"drg": r"Dynamic\s+Routing\s+Gateway|DRG",
"object-storage": r"Object\s+Storage",
"block-storage": r"Block\s+Volume|Block\s+Storage",
"file-storage": r"File\s+Storage|FSS",
"data-safe": r"Data\s+Safe",
"vault": r"OCI\s+Vault|Key\s+Management|Key\s+Vault",
"cloud-guard": r"Cloud\s+Guard",
"goldengate": r"GoldenGate",
"oic": r"Oracle\s+Integration(?!\s+3)|OIC",
"oic3": r"Oracle\s+Integration\s+3",
"data-integration": r"Data\s+Integration",
"streaming": r"OCI\s+Streaming|Streaming\s+Service",
"monitoring": r"OCI\s+Monitoring|Stack\s+Monitoring",
"logging": r"Logging\s+Analytics",
"bastion": r"Bastion",
"apex": r"Oracle\s+APEX|APEX\s+application",
"mysql": r"MySQL\s+(Database\s+)?Service|MySQL\s+HeatWave",
"nosql": r"NoSQL\s+Database",
"postgresql": r"PostgreSQL",
"opensearch": r"OpenSearch",
"redis": r"OCI\s+Cache|Redis",
"adw": r"Autonomous\s+Data\s+Warehouse|ADW",
"api-gateway": r"API\s+Gateway",
"dns": r"OCI\s+DNS|DNS\s+Service",
"wls": r"WebLogic",
"soa": r"SOA\s+Suite",
"genai": r"Generative\s+AI|GenAI|OCI\s+AI\s+Agent",
"ai-services": r"OCI\s+AI\s+Services|Vision\s+AI|Language\s+AI|Speech\s+AI|Document\s+Understanding",
"data-science": r"Data\s+Science|ML\s+Pipeline",
"data-catalog": r"Data\s+Catalog",
"ops-insights": r"Ops\s+Insights|Operations\s+Insights",
"apm": r"Application\s+Performance\s+Monitoring|APM",
"fsdr": r"Full\s+Stack\s+Disaster\s+Recovery|FSDR",
"ocvs": r"VMware\s+Solution|OCVS",
"secure-desktops": r"Secure\s+Desktops",
"azure": r"Database@Azure|Oracle\s+Database@Azure",
"aws": r"Database@AWS|Oracle\s+Database@AWS",
"google-cloud": r"Database@Google|Oracle\s+Database@Google",
}
TAG_PATTERNS = {
"database": r"database|autonomous|exadata|rac|mysql|nosql|postgresql|base\s+db",
"migration": r"migrat|move\s+to|zero\s+downtime\s+migration|zdm|data\s+pump",
"ha-dr": r"high\s+availability|disaster\s+recovery|data\s+guard|failover|MAA|standby|far\s+sync",
"security": r"secur|encrypt|vault|data\s+safe|cloud\s+guard|compliance|zero.trust|tls|certificate",
"networking": r"network|vcn|subnet|fastconnect|vpn|drg|load\s+balanc|dns|hub.spoke",
"multicloud": r"multicloud|multi-cloud|azure|aws|google\s+cloud|database@",
"integration": r"integrat|oic|streaming|goldengate|data\s+flow|kafka",
"data-platform": r"data\s+lake|data\s+warehouse|analytics|data\s+platform|lakehouse",
"application": r"apex|ords|kubernetes|container|weblogic|tomcat|microservice|e-business|peoplesoft|siebel|jd\s+edwards",
"ai-ml": r"machine\s+learning|ai\s+|generative|vector|select\s+ai|llm|rag|chatbot|agent",
"autonomous": r"autonomous\s+database|adb|auto-scal",
"observability": r"monitor|logging|apm|ops\s+insights|observability|stack\s+monitoring",
"devops": r"ci/cd|devops|github|gitlab|jenkins|pipeline|gitops",
"hpc": r"hpc|gpu|bare\s+metal\s+gpu|high.performance\s+computing",
"iot": r"iot|internet\s+of\s+things",
"healthcare": r"healthcare|dicom|hl7|fhir",
"ebs": r"e-business\s+suite|ebs",
"peoplesoft": r"peoplesoft",
"vmware": r"vmware|ocvs|vsan",
}
def detect_services(text):
"""Auto-detect OCI services mentioned in text."""
found = []
for svc, pattern in SERVICE_PATTERNS.items():
if re.search(pattern, text, re.IGNORECASE):
found.append(svc)
return sorted(set(found))
def detect_tags(text):
"""Auto-detect topic tags from text."""
found = []
for tag, pattern in TAG_PATTERNS.items():
if re.search(pattern, text, re.IGNORECASE):
found.append(tag)
return sorted(set(found))
def fetch_page(url, delay=1.0):
"""Fetch a page with rate limiting."""
time.sleep(delay)
headers = {"User-Agent": "OCI-Deal-Accelerator-CatalogBuilder/1.0"}
resp = requests.get(url, headers=headers, timeout=30)
resp.raise_for_status()
return BeautifulSoup(resp.text, "html.parser")
def extract_entry(url):
"""Extract catalog entry from a reference architecture page."""
try:
soup = fetch_page(url)
title = soup.find("h1")
title_text = title.get_text(strip=True) if title else "Unknown"
# Get all text for service/tag detection
body = soup.get_text(" ", strip=True)
# Extract first 2-3 meaningful sentences as summary
paragraphs = soup.find_all("p")
summary_parts = []
for p in paragraphs[:8]:
text = p.get_text(strip=True)
if len(text) > 50 and "Copyright" not in text and "cookie" not in text.lower():
summary_parts.append(text)
if len(" ".join(summary_parts)) > 300:
break
summary = " ".join(summary_parts)[:500]
# Detect services and tags
services = detect_services(body)
tags = detect_tags(body)
# Detect type
entry_type = "reference-architecture"
if "playbook" in body.lower():
entry_type = "solution-playbook"
elif "built and deployed" in body.lower():
entry_type = "built-deployed"
# Find terraform/github links
terraform = None
for a in soup.find_all("a", href=True):
href = a["href"]
if "github.com" in href.lower() and "oracle" in href.lower():
terraform = href
break
return {
"title": title_text,
"url": url,
"type": entry_type,
"services": services,
"tags": tags,
"summary": summary,
"terraform": terraform,
}
except Exception as e:
print(f" ERROR fetching {url}: {e}", file=sys.stderr)
return None
def extract_whats_new_links(url):
"""Extract all reference architecture links from a What's New page."""
soup = fetch_page(url, delay=0.5)
links = []
for a in soup.find_all("a", href=True):
href = a["href"]
if "/en/solutions/" in href and href not in (
"https://docs.oracle.com/en/solutions/oracle-architecture-center/",
):
# Normalize URL
if href.startswith("/"):
href = urljoin("https://docs.oracle.com", href)
if not href.endswith("/index.html") and not href.endswith("/"):
href = href.rstrip("/") + "/index.html"
title = a.get_text(strip=True)
if title and len(title) > 10:
links.append({"url": href, "title": title})
return links
def load_catalog():
"""Load existing catalog if present."""
if os.path.exists(CATALOG_PATH):
with open(CATALOG_PATH, "r") as f:
data = yaml.safe_load(f)
return data if data else {"entries": []}
return {"entries": []}
def save_catalog(data):
"""Save catalog to YAML."""
os.makedirs(os.path.dirname(CATALOG_PATH), exist_ok=True)
with open(CATALOG_PATH, "w") as f:
f.write("# =============================================================================\n")
f.write("# ORACLE ARCHITECTURE CENTER — REFERENCE CATALOG\n")
f.write("# =============================================================================\n")
f.write("#\n")
f.write("# Index of Oracle Architecture Center content for the Deal Accelerator.\n")
f.write("# Used to match customer architectures with official Oracle reference designs.\n")
f.write("#\n")
f.write(f"# Last refreshed: {datetime.now().strftime('%Y-%m-%d')}\n")
f.write(f"# Entry count: {len(data.get('entries', []))}\n")
f.write("# To refresh: python tools/refresh_arch_catalog.py --whats-new\n")
f.write("# =============================================================================\n\n")
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False, width=120)
print(f"Saved {len(data.get('entries', []))} entries to {CATALOG_PATH}")
def validate_catalog():
"""Validate catalog entries have required fields."""
data = load_catalog()
entries = data.get("entries", [])
errors = 0
required = ["title", "url", "type", "tags", "summary"]
for i, entry in enumerate(entries):
for field in required:
if not entry.get(field):
print(f" Entry {i}: missing '{field}'{entry.get('title', 'UNKNOWN')}")
errors += 1
if entry.get("type") not in ("reference-architecture", "solution-playbook", "built-deployed"):
print(f" Entry {i}: invalid type '{entry.get('type')}'{entry.get('title')}")
errors += 1
print(f"\nValidated {len(entries)} entries, {errors} errors found.")
return errors == 0
def main():
parser = argparse.ArgumentParser(description="Refresh Architecture Center catalog")
parser.add_argument("--url", help="Fetch a single URL and print YAML entry")
parser.add_argument("--whats-new", action="store_true", help="Crawl What's New pages for new entries")
parser.add_argument("--validate", action="store_true", help="Validate existing catalog")
parser.add_argument("--output", default=CATALOG_PATH, help="Output file path")
args = parser.parse_args()
if args.validate:
ok = validate_catalog()
sys.exit(0 if ok else 1)
if args.url:
entry = extract_entry(args.url)
if entry:
print(yaml.dump([entry], default_flow_style=False, allow_unicode=True))
else:
print("Failed to extract entry.", file=sys.stderr)
sys.exit(1)
elif args.whats_new:
catalog = load_catalog()
existing_urls = {e["url"] for e in catalog.get("entries", [])}
new_count = 0
for wn_url in WHATS_NEW_URLS:
print(f"Fetching: {wn_url}")
links = extract_whats_new_links(wn_url)
print(f" Found {len(links)} links")
for link in links:
url = link["url"]
if url in existing_urls:
continue
print(f" Processing: {link['title'][:60]}...")
entry = extract_entry(url)
if entry:
catalog.setdefault("entries", []).append(entry)
existing_urls.add(url)
new_count += 1
catalog["last_refreshed"] = datetime.now().strftime("%Y-%m-%d")
catalog["source"] = "https://docs.oracle.com/en/solutions/oracle-architecture-center/"
catalog["entry_count"] = len(catalog.get("entries", []))
save_catalog(catalog)
print(f"Added {new_count} new entries.")
else:
parser.print_help()
if __name__ == "__main__":
main()