initial version
This commit is contained in:
649
tools/feature_matrix_cli.py
Normal file
649
tools/feature_matrix_cli.py
Normal 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
716
tools/findings_cli.py
Normal 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
504
tools/kb_cli.py
Normal 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
398
tools/kb_linter.py
Normal 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
713
tools/oci_bizcase_gen.py
Normal 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
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
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
163
tools/oci_icon_extractor.py
Normal 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
171
tools/oci_output.py
Normal 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
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
278
tools/oci_pptx_base.py
Normal 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)
|
||||
328
tools/refresh_arch_catalog.py
Normal file
328
tools/refresh_arch_catalog.py
Normal 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()
|
||||
Reference in New Issue
Block a user