Files
oci-deal-accelerator/tools/oci_deck_gen.py
root e57ab57b48 Accept MCP flat payload in deck generator to stop silent-blank decks
generate_deck on the MCP server passes a flat spec (customer_name, title,
workload_type, services, cost_summary, ...) — but from_spec only understood
the proposal-spec YAML shape (metadata/summary/architecture/...). With no
matching keys, only the title and closing layouts rendered, both populated
with empty strings, producing a deck that looked blank.

Detect the flat shape in from_spec and adapt it into the proposal-spec
fields that the existing slide builders already consume. Title, summary
(target state + current-state bullets) and cost slides now render from
whatever the MCP tool sends; the richer proposal-spec path is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:27:44 -03:00

1440 lines
56 KiB
Python

#!/usr/bin/env python3
"""
OCI Deal Accelerator — Slide Deck Generator (.pptx)
Produces a 10-12 slide architecture proposal deck using the Oracle FY26 official
PowerPoint template and the Oracle Redwood design system colors and typography.
Usage:
python oci_deck_gen.py --spec proposal-data.yaml --output proposal.pptx
Or import and use programmatically:
from oci_deck_gen import OCIDeckGenerator
gen = OCIDeckGenerator(customer="Acme Corp", project="DB Migration")
gen.add_summary_slide(...)
gen.save("proposal.pptx")
"""
import yaml
import argparse
from datetime import datetime
from pathlib import Path
from typing import Optional
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from oci_pptx_base import Colors, Layouts, OraclePresBase
# ============================================================
# MCP flat-spec adapter
# ============================================================
def _is_flat_spec(spec: dict) -> bool:
"""MCP payload shape: no 'metadata' key, but has customer_name/id/title."""
if not isinstance(spec, dict):
return False
if "metadata" in spec:
return False
return any(k in spec for k in ("customer_name", "customer_id", "title"))
def _adapt_flat_spec(spec: dict) -> dict:
"""Map the MCP flat payload to the proposal-spec structure from_spec expects."""
arch = spec.get("architecture") if isinstance(spec.get("architecture"), dict) else {}
customer = spec.get("customer_name") or spec.get("customer_id", "")
title = spec.get("title") or arch.get("workload", "") or "Architecture Proposal"
workload = spec.get("workload_type") or arch.get("workload", "")
deployment = spec.get("deployment_model") or arch.get("deployment", "")
region = spec.get("primary_region") or spec.get("region", "")
billing = spec.get("billing_model") or arch.get("license_model", "")
dr_raw = spec.get("disaster_recovery")
if dr_raw is None and "dr" in arch:
dr_raw = "Enabled" if arch.get("dr") else "Not in scope"
capacity = spec.get("capacity") or arch.get("capacity") or {}
target_parts = [p for p in (workload, deployment and f"deployment: {deployment}",
region and f"region: {region}") if p]
target_state = "".join(target_parts)
current_state = []
if isinstance(capacity, dict):
for k, v in capacity.items():
current_state.append(f"{k.replace('_', ' ').title()}: {v}")
if billing:
current_state.append(f"License model: {billing}")
if dr_raw:
current_state.append(f"Disaster recovery: {dr_raw}")
line_items = []
for svc in spec.get("services", []) or []:
if not isinstance(svc, dict):
continue
qty = svc.get("quantity", svc.get("qty"))
notes = svc.get("notes", "")
if qty is not None:
notes = f"Qty: {qty}" + (f"{notes}" if notes else "")
line_items.append({"component": svc.get("name", ""), "monthly_payg": "", "notes": notes})
cost_summary = spec.get("cost_summary") or {}
monthly = cost_summary.get("monthly_estimate")
if monthly is not None:
currency = cost_summary.get("currency", "USD")
annual = cost_summary.get("annual_estimate", monthly * 12)
line_items.append({
"component": "Total (estimated)",
"monthly_payg": f"{currency} {monthly:,.2f}",
"notes": f"Annual: {currency} {annual:,.2f}",
})
adapted = {
"metadata": {"customer": customer, "subtitle": title},
"summary": {
"why": f"{customer}{title}" if customer and title else (customer or title),
"current_state": current_state,
"target_state": target_state,
"timeline": "",
},
}
if line_items:
adapted["cost"] = {
"line_items": line_items,
"assumptions": spec.get("assumptions", []) or [],
"show_byol": False,
}
return adapted
# ============================================================
# Slide Generator
# ============================================================
class OCIDeckGenerator(OraclePresBase):
"""Generate Oracle Redwood-styled architecture proposal slide decks."""
SLIDE_WIDTH = Inches(13.333) # Widescreen 16:9
SLIDE_HEIGHT = Inches(7.5)
MARGIN = Inches(0.5)
# Use Oracle Red accent for proposal deck (differentiates from bizcase teal)
TITLE_ACCENT_COLOR = Colors.ORACLE_RED
def __init__(self, customer: str = "", project: str = "",
architect: str = "", firm: str = "",
template: Optional[str] = None):
super().__init__(template)
self.customer = customer
self.project = project
self.architect = architect
self.firm = firm
# ---- Architecture visual helpers ----
def _add_service_block(self, slide, left, top, width, height,
label, fill_color):
"""Add a colored service block with white text label."""
shape = self._add_rect(
slide, left, top, width, height, fill_color=fill_color,
)
shape.text_frame.word_wrap = True
p = shape.text_frame.paragraphs[0]
p.text = label
p.font.size = Pt(8)
p.font.bold = True
p.font.color.rgb = Colors.WHITE
p.font.name = self.FONT
p.alignment = PP_ALIGN.CENTER
shape.text_frame.paragraphs[0].space_before = Pt(0)
shape.text_frame.paragraphs[0].space_after = Pt(0)
return shape
def _add_container(self, slide, left, top, width, height,
label, border_color, fill_color=None,
dashed=True, label_size=10):
"""Add a container outline (region, VCN, subnet)."""
shape = self._add_rect(
slide, left, top, width, height,
fill_color=fill_color, border_color=border_color,
)
if not fill_color:
shape.fill.background()
shape.line.color.rgb = border_color
shape.line.width = Pt(1.5)
if dashed:
shape.line.dash_style = 2 # MSO_LINE_DASH_STYLE.DASH
# Label at top-left inside container
self._add_textbox(
slide, left + Inches(0.1), top + Inches(0.05),
width - Inches(0.2), Inches(0.3),
text=label, font_size=label_size, bold=True,
color=border_color,
)
return shape
def _add_arrow(self, slide, start_x, start_y, end_x, end_y,
color=None, dashed=False, label=""):
"""Add a connector arrow between two points."""
connector = slide.shapes.add_connector(
1, # MSO_CONNECTOR.STRAIGHT
start_x, start_y, end_x, end_y,
)
connector.line.color.rgb = color or RGBColor(0x70, 0x6E, 0x6F)
connector.line.width = Pt(1.5)
if dashed:
connector.line.dash_style = 2
return connector
# ---- Slide Methods ----
def add_title_slide(self, subtitle: str = ""):
"""Slide 1: Title slide using FY26 Dark Cover layout."""
slide = self._add_layout_slide(Layouts.COVER_DARK)
customer = self.customer or "Architecture Proposal"
date_str = datetime.now().strftime("%B %Y")
sub = subtitle or f"Architecture Proposal — {date_str}"
self._set_placeholder(slide, 0, customer)
self._set_placeholder(slide, 33, sub)
self._set_placeholder(slide, 35, date_str)
if self.architect or self.firm:
info = self.architect
if self.firm:
info += f" | {self.firm}" if self.architect else self.firm
self._set_placeholder(slide, 34, info)
def add_summary_slide(self, why: str, current_state: list,
target_state: str, timeline: str):
"""Slide 2: Engagement Summary."""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Engagement Summary", margin=self.MARGIN)
y = Inches(1.2)
# Why — teal for emphasis
self._add_textbox(
slide, self.MARGIN, y, Inches(11), Inches(0.5),
text=why, font_size=14, color=Colors.TEAL, bold=True,
font_name=self.FONT_HEADING,
)
y += Inches(0.7)
# Current state
self._add_textbox(
slide, self.MARGIN, y, Inches(5), Inches(0.4),
text="Current State", font_size=14, bold=True,
)
y += Inches(0.45)
for item in current_state:
self._add_textbox(
slide, Inches(0.7), y, Inches(10), Inches(0.35),
text=f"{item}", font_size=12,
)
y += Inches(0.35)
y += Inches(0.3)
# Target state
self._add_textbox(
slide, self.MARGIN, y, Inches(5), Inches(0.4),
text="Target State", font_size=14, bold=True,
)
y += Inches(0.45)
self._add_textbox(
slide, Inches(0.7), y, Inches(10), Inches(0.4),
text=target_state, font_size=12, color=Colors.TEAL,
)
y += Inches(0.5)
# Timeline
self._add_textbox(
slide, self.MARGIN, y, Inches(10), Inches(0.4),
text=f"Timeline: {timeline}", font_size=12, bold=True,
)
def add_architecture_slide(self, diagram_path: Optional[str] = None,
description: str = "",
visual: Optional[dict] = None):
"""Slide 3: Architecture Overview with diagram or visual layout.
visual: optional dict with structured architecture data for rendering
as colored blocks. Keys: regions, on_prem, security_footer.
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Architecture Overview", margin=self.MARGIN)
if diagram_path:
try:
slide.shapes.add_picture(
diagram_path,
Inches(0.5), Inches(1.1),
Inches(12.3), Inches(6),
)
except Exception:
self._add_textbox(
slide, Inches(1), Inches(2.5),
Inches(10), Inches(2),
text=f"[Diagram: {diagram_path}]\n\n{description}",
font_size=14, color=Colors.SECONDARY_TEXT,
)
elif visual:
self._render_architecture_visual(slide, visual)
else:
self._add_textbox(
slide, Inches(1), Inches(2),
Inches(11), Inches(4),
text=description or "[Insert architecture diagram — export from .drawio file]",
font_size=14, color=Colors.SECONDARY_TEXT, italic=True,
alignment=PP_ALIGN.CENTER,
)
def _render_architecture_visual(self, slide, visual: dict):
"""Render a visual architecture diagram from structured data."""
regions = visual.get("regions", [])
on_prem = visual.get("on_prem")
security = visual.get("security_footer", "")
# Category colors for service blocks
cat_colors = {
"infrastructure": Colors.TEAL,
"database": Colors.COPPER,
"integration": Colors.PURPLE,
"security": Colors.FOREST,
"dormant": RGBColor(0xDF, 0xDC, 0xD8),
}
y_cursor = Inches(1.15)
for reg_idx, region in enumerate(regions):
is_primary = region.get("primary", reg_idx == 0)
region_name = region.get("name", f"Region {reg_idx+1}")
region_label = region.get("label", "PRIMARY" if is_primary else "DR STANDBY")
# Region dimensions — primary gets more space
if is_primary:
reg_left = Inches(0.4)
reg_width = Inches(8.2)
reg_height = Inches(4.8)
reg_top = y_cursor
else:
reg_left = Inches(8.9)
reg_width = Inches(4.1)
reg_height = Inches(4.8)
reg_top = y_cursor
# Region container — solid border, light fill
self._add_container(
slide, reg_left, reg_top, reg_width, reg_height,
label=f"{region_name} [{region_label}]",
border_color=RGBColor(0x9E, 0x98, 0x92),
fill_color=RGBColor(0xF5, 0xF4, 0xF2),
dashed=False, label_size=10,
)
inner_left = reg_left + Inches(0.15)
inner_top = reg_top + Inches(0.45)
inner_width = reg_width - Inches(0.3)
# VCN container if present
vcn = region.get("vcn")
if vcn:
vcn_name = vcn.get("name", "VCN")
vcn_cidr = vcn.get("cidr", "")
vcn_label = f"{vcn_name} {vcn_cidr}" if vcn_cidr else vcn_name
vcn_height = reg_height - Inches(0.6)
self._add_container(
slide, inner_left, inner_top, inner_width, vcn_height,
label=vcn_label,
border_color=Colors.BURNT_ORANGE,
dashed=True, label_size=9,
)
# Render subnets
subnets = vcn.get("subnets", [])
sub_top = inner_top + Inches(0.4)
sub_left = inner_left + Inches(0.15)
sub_width = inner_width - Inches(0.3)
for sub_idx, subnet in enumerate(subnets):
sub_name = subnet.get("name", f"Subnet {sub_idx+1}")
sub_border = Colors.BURNT_ORANGE
sub_h = Inches(0.95) if is_primary else Inches(0.7)
self._add_container(
slide, sub_left, sub_top, sub_width, sub_h,
label=sub_name,
border_color=sub_border,
fill_color=Colors.WARM_BG,
dashed=True, label_size=8,
)
# Service blocks inside subnet
services = subnet.get("services", [])
svc_left = sub_left + Inches(0.1)
svc_top = sub_top + Inches(0.3)
svc_spacing = Inches(0.08)
# Calculate service block width to fit
if services:
avail_width = sub_width - Inches(0.2)
svc_w = min(
Inches(2.2),
(avail_width - svc_spacing * (len(services) - 1)) / len(services)
)
svc_h = Inches(0.5)
for svc in services:
svc_name = svc.get("name", "Service")
svc_cat = svc.get("category", "infrastructure")
svc_color = cat_colors.get(svc_cat, Colors.TEAL)
self._add_service_block(
slide, svc_left, svc_top, svc_w, svc_h,
label=svc_name, fill_color=svc_color,
)
svc_left += svc_w + svc_spacing
sub_top += sub_h + Inches(0.1)
# Gateways bar at bottom of VCN
gateways = vcn.get("gateways", [])
if gateways:
gw_top = inner_top + vcn_height - Inches(0.55)
gw_left = inner_left + Inches(0.15)
gw_text = " | ".join(gateways)
self._add_textbox(
slide, gw_left, gw_top,
inner_width - Inches(0.3), Inches(0.35),
text=gw_text, font_size=8, bold=True,
color=Colors.TEAL,
alignment=PP_ALIGN.CENTER,
)
else:
# No VCN — render services directly (e.g. DR standby)
services = region.get("services", [])
svc_top = inner_top + Inches(0.3)
for svc in services:
svc_name = svc.get("name", "Service")
svc_cat = svc.get("category", "database")
svc_color = cat_colors.get(svc_cat, Colors.TEAL)
self._add_service_block(
slide, inner_left + Inches(0.3), svc_top,
inner_width - Inches(0.6), Inches(0.6),
label=svc_name, fill_color=svc_color,
)
svc_top += Inches(0.75)
# DR details text
dr_details = region.get("details", "")
if dr_details:
self._add_textbox(
slide, inner_left + Inches(0.1), svc_top + Inches(0.1),
inner_width - Inches(0.2), Inches(0.6),
text=dr_details, font_size=8, italic=True,
color=Colors.SECONDARY_TEXT,
alignment=PP_ALIGN.CENTER,
)
# On-prem block
if on_prem:
op_left = Inches(0.4)
op_top = Inches(6.15)
op_width = Inches(3.5)
self._add_rect(
slide, op_left, op_top, op_width, Inches(0.45),
fill_color=RGBColor(0x70, 0x66, 0x5E),
)
op_shape = slide.shapes[-1]
p = op_shape.text_frame.paragraphs[0]
p.text = on_prem.get("name", "On-Premises")
p.font.size = Pt(9)
p.font.bold = True
p.font.color.rgb = Colors.WHITE
p.font.name = self.FONT
p.alignment = PP_ALIGN.CENTER
# Connection label
conn_label = on_prem.get("connection", "IPSec VPN")
self._add_textbox(
slide, op_left + op_width + Inches(0.15), op_top,
Inches(3), Inches(0.45),
text=f"--- {conn_label} ---",
font_size=8, italic=True, color=Colors.SECONDARY_TEXT,
alignment=PP_ALIGN.LEFT,
)
# Security footer
if security:
self._add_textbox(
slide, Inches(0.4), Inches(6.7),
Inches(12.5), Inches(0.35),
text=security, font_size=8, bold=True,
color=Colors.TEAL,
alignment=PP_ALIGN.LEFT,
)
def add_service_tiering_slide(self, workloads: list):
"""Service Tiering slide — maps workloads to Platinum/Gold/Silver/Bronze.
workloads: list of {"name": str, "tier": str, "uptime": str,
"rto": str, "rpo": str}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Service Tiering", margin=self.MARGIN)
# Subtitle
self._add_textbox(
slide, self.MARGIN, Inches(1.1),
Inches(12), Inches(0.4),
text="Each tier drives: HA/DR topology, backup strategy, isolation model, support level.",
font_size=11, italic=True, color=Colors.SECONDARY_TEXT,
)
rows = len(workloads) + 1
table = self._add_table(
slide, rows, 5,
self.MARGIN, Inches(1.7),
Inches(12), Inches(0.45 * rows),
)
table.columns[0].width = Inches(3.0)
table.columns[1].width = Inches(2.5)
table.columns[2].width = Inches(2.0)
table.columns[3].width = Inches(2.25)
table.columns[4].width = Inches(2.25)
headers = ["Workload", "Tier", "Uptime", "RTO", "RPO"]
for j, h in enumerate(headers):
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,
)
tier_colors = {
"platinum": Colors.ORACLE_RED,
"gold": Colors.BURNT_ORANGE,
"silver": Colors.SECONDARY_TEXT,
"bronze": Colors.MUTED_TEAL,
}
for i, wl in enumerate(workloads):
row_idx = i + 1
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
self._style_table_cell(table.cell(row_idx, 0), wl["name"], font_size=10, bold=True, bg_color=bg)
tier_label = wl.get("tier", "Silver")
tier_color = tier_colors.get(tier_label.lower(), Colors.SECONDARY_TEXT)
self._style_table_cell(
table.cell(row_idx, 1), tier_label.title(),
font_size=10, bold=True, color=tier_color, bg_color=bg,
alignment=PP_ALIGN.CENTER,
)
self._style_table_cell(table.cell(row_idx, 2), wl.get("uptime", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
self._style_table_cell(table.cell(row_idx, 3), wl.get("rto", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
self._style_table_cell(table.cell(row_idx, 4), wl.get("rpo", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
def add_architecture_principles_slide(self, principles: dict):
"""Architecture Principles slide — ECAL Design/Deployment/Service categories.
principles: {"design": [{"id": str, "name": str, "summary": str}],
"deployment": [...], "service": [...]}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Architecture Principles", margin=self.MARGIN)
y = Inches(1.2)
col_x = self.MARGIN
col_width = Inches(4)
for category in ["design", "deployment", "service"]:
items = principles.get(category, [])
if not items:
continue
# Category heading
self._add_textbox(
slide, col_x, y, col_width, Inches(0.35),
text=category.upper(), font_size=13, bold=True,
color=Colors.TEAL,
)
item_y = y + Inches(0.4)
for item in items:
pid = item.get("id", "")
name = item.get("name", "")
summary = item.get("summary", "")
label = f"{pid} {name}" if pid else name
if summary:
label += f"{summary}"
self._add_textbox(
slide, col_x + Inches(0.1), item_y,
col_width - Inches(0.1), Inches(0.4),
text=f"{label}", font_size=11,
)
item_y += Inches(0.4)
col_x += Inches(4.2)
def add_decisions_slide(self, decisions: list):
"""Slide 4: Architecture Decisions table.
decisions: list of {"decision": str, "rationale": str}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Architecture Decisions", margin=self.MARGIN)
rows = len(decisions) + 1
table = self._add_table(
slide, rows, 2,
self.MARGIN, Inches(1.2),
Inches(12), Inches(0.5 * rows),
)
# Set column widths
table.columns[0].width = Inches(4.5)
table.columns[1].width = Inches(7.5)
# Header row
self._style_table_cell(
table.cell(0, 0), "Decision", font_size=11, bold=True,
color=Colors.WHITE, bg_color=Colors.TEAL,
)
self._style_table_cell(
table.cell(0, 1), "Rationale", font_size=11, bold=True,
color=Colors.WHITE, bg_color=Colors.TEAL,
)
# Data rows
for i, dec in enumerate(decisions):
row_idx = i + 1
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
self._style_table_cell(
table.cell(row_idx, 0), dec["decision"],
font_size=11, bold=True, bg_color=bg,
)
self._style_table_cell(
table.cell(row_idx, 1), dec["rationale"],
font_size=10, bg_color=bg,
)
def add_ha_dr_slide(self, tiers: list, description: str = ""):
"""Slide 5: HA/DR table.
tiers: list of {"tier": str, "technology": str, "rto": str, "rpo": str}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "High Availability & Disaster Recovery", margin=self.MARGIN)
if description:
self._add_textbox(
slide, self.MARGIN, Inches(1.2),
Inches(12), Inches(0.5),
text=description, font_size=12,
)
rows = len(tiers) + 1
table = self._add_table(
slide, rows, 4,
self.MARGIN, Inches(1.9),
Inches(12), Inches(0.45 * rows),
)
table.columns[0].width = Inches(2.5)
table.columns[1].width = Inches(4.5)
table.columns[2].width = Inches(2.5)
table.columns[3].width = Inches(2.5)
headers = ["Tier", "Technology", "RTO", "RPO"]
for j, h in enumerate(headers):
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,
)
for i, tier in enumerate(tiers):
row_idx = i + 1
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
self._style_table_cell(table.cell(row_idx, 0), tier["tier"], font_size=10, bold=True, bg_color=bg)
self._style_table_cell(table.cell(row_idx, 1), tier["technology"], font_size=10, bg_color=bg)
self._style_table_cell(table.cell(row_idx, 2), tier["rto"], font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
self._style_table_cell(table.cell(row_idx, 3), tier["rpo"], font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
def add_security_slide(self, controls: dict, compliance: list = None):
"""Slide 6: Security & Compliance.
controls: {"identity": [...], "network": [...], "database": [...], "monitoring": [...]}
compliance: ["PCI-DSS", "SOC2", ...] or None
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Security & Compliance", margin=self.MARGIN)
y = Inches(1.2)
# Compliance badges
if compliance:
x = self.MARGIN
for framework in compliance:
shape = self._add_rect(
slide, x, y, Inches(1.5), Inches(0.4),
fill_color=Colors.SUCCESS,
)
shape.text_frame.paragraphs[0].text = f"{framework}"
shape.text_frame.paragraphs[0].font.size = Pt(10)
shape.text_frame.paragraphs[0].font.bold = True
shape.text_frame.paragraphs[0].font.color.rgb = Colors.WHITE
shape.text_frame.paragraphs[0].font.name = self.FONT
shape.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
x += Inches(1.7)
y += Inches(0.7)
# Controls in columns
col_x = self.MARGIN
col_width = Inches(3)
for area, items in controls.items():
area_title = area.replace("_", " ").title()
self._add_textbox(
slide, col_x, y, col_width, Inches(0.35),
text=area_title, font_size=12, bold=True,
color=Colors.TEAL,
)
item_y = y + Inches(0.4)
for item in items:
self._add_textbox(
slide, col_x + Inches(0.1), item_y,
col_width - Inches(0.1), Inches(0.3),
text=f"{item}", font_size=10,
)
item_y += Inches(0.3)
col_x += Inches(3.2)
def add_environment_catalogue_slide(self, environments: list,
cost_notes=None):
"""Environment Catalogue slide — Prod/Pre-Prod/Dev-Test/DR per workload.
environments: list of {"name"|"environment": str, "sizing": str,
"isolation": str, "cost_pct": int}
cost_notes: optional list of strings or single string
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Environment Catalogue", margin=self.MARGIN)
# Normalize cost_notes to a list
if isinstance(cost_notes, str):
cost_notes = [s.strip() for s in cost_notes.split(".") if s.strip()]
rows = len(environments) + 1
table = self._add_table(
slide, rows, 5,
self.MARGIN, Inches(1.2),
Inches(12), Inches(0.4 * rows),
)
# Detect schema: new (name/sizing/isolation/cost_pct) vs legacy (environment/tier/databases/ocpus/isolation)
sample = environments[0] if environments else {}
use_new_schema = "sizing" in sample or "cost_pct" in sample
if use_new_schema:
table = self._add_table(
slide, rows, 4,
self.MARGIN, Inches(1.2),
Inches(12), Inches(0.4 * rows),
)
table.columns[0].width = Inches(2.5)
table.columns[1].width = Inches(5.0)
table.columns[2].width = Inches(2.5)
table.columns[3].width = Inches(2.0)
headers = ["Environment", "Sizing", "Isolation", "Cost %"]
for j, h in enumerate(headers):
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,
)
for i, env in enumerate(environments):
row_idx = i + 1
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
self._style_table_cell(table.cell(row_idx, 0), env.get("name", env.get("environment", "")), font_size=10, bold=True, bg_color=bg)
self._style_table_cell(table.cell(row_idx, 1), env.get("sizing", ""), font_size=10, bg_color=bg)
self._style_table_cell(table.cell(row_idx, 2), env.get("isolation", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
cost_pct = env.get("cost_pct", "")
self._style_table_cell(table.cell(row_idx, 3), f"{cost_pct}%" if cost_pct != "" else "", font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
else:
table = self._add_table(
slide, rows, 5,
self.MARGIN, Inches(1.2),
Inches(12), Inches(0.4 * rows),
)
table.columns[0].width = Inches(2.2)
table.columns[1].width = Inches(1.8)
table.columns[2].width = Inches(2.5)
table.columns[3].width = Inches(1.5)
table.columns[4].width = Inches(4.0)
headers = ["Environment", "Tier", "Databases", "OCPUs", "Isolation"]
for j, h in enumerate(headers):
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,
)
for i, env in enumerate(environments):
row_idx = i + 1
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
self._style_table_cell(table.cell(row_idx, 0), env.get("environment", ""), font_size=10, bold=True, bg_color=bg)
self._style_table_cell(table.cell(row_idx, 1), env.get("tier", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
self._style_table_cell(table.cell(row_idx, 2), env.get("databases", ""), font_size=10, bg_color=bg)
self._style_table_cell(table.cell(row_idx, 3), env.get("ocpus", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
self._style_table_cell(table.cell(row_idx, 4), env.get("isolation", ""), font_size=10, bg_color=bg)
if cost_notes:
notes_y = Inches(1.2) + Inches(0.4 * rows) + Inches(0.3)
self._add_textbox(
slide, self.MARGIN, notes_y,
Inches(12), Inches(0.35),
text="Cost Optimization", font_size=12, bold=True,
color=Colors.TEAL,
)
note_y = notes_y + Inches(0.35)
for note in cost_notes:
self._add_textbox(
slide, self.MARGIN + Inches(0.1), note_y,
Inches(11.5), Inches(0.35),
text=f"{note}", font_size=11,
)
note_y += Inches(0.35)
def add_cost_slide(self, line_items: list, assumptions: list = None,
show_byol: bool = True):
"""Slide 7: Cost Estimate.
line_items: list of {"component": str, "monthly_payg": str,
"monthly_byol": str (optional), "notes": str}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Cost Estimate", margin=self.MARGIN)
cols = 4 if show_byol else 3
rows = len(line_items) + 1
table = self._add_table(
slide, rows, cols,
self.MARGIN, Inches(1.2),
Inches(12), Inches(0.4 * rows),
)
if show_byol:
table.columns[0].width = Inches(3.5)
table.columns[1].width = Inches(2.5)
table.columns[2].width = Inches(2.5)
table.columns[3].width = Inches(3.5)
headers = ["Component", "Monthly (PAYG)", "Monthly (BYOL)", "Notes"]
else:
table.columns[0].width = Inches(4)
table.columns[1].width = Inches(3)
table.columns[2].width = Inches(5)
headers = ["Component", "Monthly", "Notes"]
for j, h in enumerate(headers):
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,
)
for i, item in enumerate(line_items):
row_idx = i + 1
is_total = "total" in item.get("component", "").lower()
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 and not is_total else None
if is_total:
bg = Colors.TEAL
self._style_table_cell(
table.cell(row_idx, 0), item["component"],
font_size=10, bold=is_total,
color=Colors.WHITE if is_total else None,
bg_color=bg,
)
self._style_table_cell(
table.cell(row_idx, 1), item.get("monthly_payg", ""),
font_size=10, bold=is_total,
color=Colors.WHITE if is_total else None,
bg_color=bg, alignment=PP_ALIGN.RIGHT,
)
if show_byol:
self._style_table_cell(
table.cell(row_idx, 2), item.get("monthly_byol", ""),
font_size=10, bold=is_total,
color=Colors.WHITE if is_total else None,
bg_color=bg, alignment=PP_ALIGN.RIGHT,
)
self._style_table_cell(
table.cell(row_idx, 3), item.get("notes", ""),
font_size=9, bg_color=bg,
color=Colors.WHITE if is_total else Colors.SECONDARY_TEXT,
)
else:
self._style_table_cell(
table.cell(row_idx, 2), item.get("notes", ""),
font_size=9, bg_color=bg,
color=Colors.WHITE if is_total else Colors.SECONDARY_TEXT,
)
# Assumptions
if assumptions:
table_bottom = Inches(1.2) + Inches(0.4 * rows) + Inches(0.3)
self._add_textbox(
slide, self.MARGIN, table_bottom,
Inches(12), Inches(0.35),
text="Assumptions:", font_size=11, bold=True,
color=Colors.SECONDARY_TEXT, italic=True,
)
for idx, assumption in enumerate(assumptions):
self._add_textbox(
slide, Inches(0.7), table_bottom + Inches(0.35 + idx * 0.3),
Inches(11), Inches(0.3),
text=f"{assumption}", font_size=10, italic=True,
color=Colors.SECONDARY_TEXT,
)
def add_cost_comparison_slide(self, rows: list, title: str = "Cost Comparison",
col_headers: list = None):
"""Slide 8: Cost Comparison (optional).
rows: list of {"item": str, "current": str, "oci": str, "savings": str}
col_headers: custom column headers (default: ["Component", "Current", "OCI", "Savings"])
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, title, margin=self.MARGIN)
headers = col_headers or ["Component", "Current", "OCI", "Savings"]
num_rows = len(rows) + 1
table = self._add_table(
slide, num_rows, len(headers),
self.MARGIN, Inches(1.2),
Inches(12), Inches(0.45 * num_rows),
)
col_widths = [Inches(4), Inches(2.5), Inches(2.5), Inches(3)]
for j, w in enumerate(col_widths[:len(headers)]):
table.columns[j].width = w
for j, h in enumerate(headers):
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,
)
for i, row in enumerate(rows):
row_idx = i + 1
is_total = "total" in row.get("item", "").lower()
bg = Colors.TEAL if is_total else (Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None)
txt_color = Colors.WHITE if is_total else None
self._style_table_cell(
table.cell(row_idx, 0), row.get("item", ""),
font_size=10, bold=is_total, color=txt_color, bg_color=bg,
)
self._style_table_cell(
table.cell(row_idx, 1), row.get("current", ""),
font_size=10, bold=is_total, color=txt_color, bg_color=bg,
alignment=PP_ALIGN.RIGHT,
)
self._style_table_cell(
table.cell(row_idx, 2), row.get("oci", ""),
font_size=10, bold=is_total, color=txt_color, bg_color=bg,
alignment=PP_ALIGN.RIGHT,
)
if len(headers) > 3:
savings_color = Colors.SUCCESS if not is_total else txt_color
self._style_table_cell(
table.cell(row_idx, 3), row.get("savings", ""),
font_size=10, bold=True, color=savings_color, bg_color=bg,
alignment=PP_ALIGN.CENTER,
)
def add_migration_slide(self, phases: list, tools: list = None,
downtime: str = ""):
"""Migration Approach slide.
phases: list of {"name": str, "duration"|"weeks": str,
"milestones"|"tasks": [...]}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Migration Approach", margin=self.MARGIN)
phase_colors = [Colors.TEAL, Colors.BURNT_ORANGE, Colors.FOREST, Colors.PURPLE]
n = len(phases)
# Scale spacing to fit all phases: leave room for tools/downtime at bottom
avail = 5.0 # inches available for phases (from y=1.3 to ~6.3)
step = min(1.1, avail / max(n, 1))
y = Inches(1.3)
bar_left = Inches(2.8)
bar_width = Inches(9.2)
for i, phase in enumerate(phases):
color = phase_colors[i % len(phase_colors)]
duration = phase.get("duration", phase.get("weeks", ""))
milestones = phase.get("milestones", phase.get("tasks", []))
# Phase label (left of bar)
self._add_textbox(
slide, self.MARGIN, y,
Inches(2.3), Inches(0.4),
text=phase["name"], font_size=11, bold=True,
)
# Colored phase bar with duration
bar = self._add_rect(
slide, bar_left, y + Inches(0.02),
bar_width * 0.9, Inches(0.36),
fill_color=color,
)
bar.text_frame.paragraphs[0].text = duration
bar.text_frame.paragraphs[0].font.size = Pt(11)
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
# Milestones/tasks below bar (compact)
if milestones:
tasks_text = "".join(str(m) for m in milestones[:4])
self._add_textbox(
slide, bar_left, y + Inches(0.40),
bar_width, Inches(0.30),
text=tasks_text, font_size=9, italic=True,
color=Colors.SECONDARY_TEXT,
)
y += Inches(step)
# Tools
if tools:
y = max(y, Inches(1.3 + avail)) + Inches(0.15)
self._add_textbox(
slide, self.MARGIN, y,
Inches(12), Inches(0.35),
text=f"Migration Tools: {', '.join(tools[:4])}",
font_size=10, bold=True, color=Colors.TEAL,
)
y += Inches(0.35)
# Downtime approach
if downtime:
if not tools:
y = max(y, Inches(1.3 + avail)) + Inches(0.15)
self._add_textbox(
slide, self.MARGIN, y,
Inches(12), Inches(0.35),
text=f"Downtime Approach: {downtime}",
font_size=10, color=Colors.PRIMARY_TEXT,
)
def add_operational_raci_slide(self, raci_items: list,
model: str = "co_managed"):
"""Operational RACI slide — responsibility matrix.
raci_items: list of {"activity": str, "customer": str, "oracle": str}
model: "fully_managed", "co_managed", or "self_managed"
"""
slide = self._add_blank_slide()
model_label = model.replace("_", "-").title()
self._add_title_bar(slide, f"Operational Responsibilities ({model_label})", margin=self.MARGIN)
rows = len(raci_items) + 1
table = self._add_table(
slide, rows, 3,
self.MARGIN, Inches(1.2),
Inches(10), Inches(0.38 * rows),
)
table.columns[0].width = Inches(5.0)
table.columns[1].width = Inches(2.5)
table.columns[2].width = Inches(2.5)
headers = ["Activity", "Customer", "Oracle / Partner"]
for j, h in enumerate(headers):
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,
)
for i, item in enumerate(raci_items):
row_idx = i + 1
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
self._style_table_cell(table.cell(row_idx, 0), item.get("activity", ""), font_size=10, bg_color=bg)
self._style_table_cell(table.cell(row_idx, 1), item.get("customer", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
self._style_table_cell(table.cell(row_idx, 2), item.get("oracle", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
# Legend
legend_y = Inches(1.2) + Inches(0.38 * rows) + Inches(0.2)
self._add_textbox(
slide, self.MARGIN, legend_y,
Inches(10), Inches(0.35),
text="R = Responsible A = Accountable C = Consulted I = Informed",
font_size=11, italic=True, color=Colors.SECONDARY_TEXT,
)
def add_risk_slide(self, risks: list):
"""Slide 10: Risk Register.
risks: list of {"risk": str, "severity": "HIGH"|"MEDIUM"|"LOW", "mitigation": str}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Risk Register", margin=self.MARGIN)
rows = len(risks) + 1
table = self._add_table(
slide, rows, 3,
self.MARGIN, Inches(1.2),
Inches(12), Inches(0.5 * rows),
)
table.columns[0].width = Inches(4.5)
table.columns[1].width = Inches(1.5)
table.columns[2].width = Inches(6)
headers = ["Risk", "Severity", "Mitigation"]
for j, h in enumerate(headers):
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 == 1 else PP_ALIGN.LEFT,
)
severity_colors = {
"HIGH": Colors.ERROR,
"MEDIUM": Colors.WARNING,
"LOW": Colors.SUCCESS,
}
for i, risk in enumerate(risks):
row_idx = i + 1
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
self._style_table_cell(
table.cell(row_idx, 0), risk["risk"],
font_size=10, bg_color=bg,
)
sev = risk.get("severity", "MEDIUM").upper()
self._style_table_cell(
table.cell(row_idx, 1), sev,
font_size=10, bold=True,
color=severity_colors.get(sev, Colors.WARNING),
bg_color=bg, alignment=PP_ALIGN.CENTER,
)
self._style_table_cell(
table.cell(row_idx, 2), risk["mitigation"],
font_size=10, bg_color=bg,
)
def add_scorecard_slide(self, pillars: list, recommendations: list = None):
"""Slide 11: Well-Architected Scorecard.
pillars: list of {"name": str, "status": "PASS"|"PASS_WITH_RECOMMENDATIONS"|"GAPS_IDENTIFIED"|"NOT_APPLICABLE",
"passed": int, "total": int}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Well-Architected Scorecard", margin=self.MARGIN)
status_styles = {
"PASS": {"icon": "", "color": Colors.SUCCESS, "label": "PASS"},
"PASS_WITH_RECOMMENDATIONS": {"icon": "", "color": Colors.WARNING, "label": "PASS WITH RECOMMENDATIONS"},
"GAPS_IDENTIFIED": {"icon": "", "color": Colors.ERROR, "label": "GAPS IDENTIFIED"},
"NOT_APPLICABLE": {"icon": "", "color": Colors.SECONDARY_TEXT, "label": "N/A"},
}
y = Inches(1.4)
for pillar in pillars:
style = status_styles.get(pillar["status"], status_styles["PASS"])
# Status indicator pill
pill = self._add_rect(
slide, self.MARGIN, y,
Inches(0.5), Inches(0.45),
fill_color=style["color"],
)
pill.text_frame.paragraphs[0].text = style["icon"]
pill.text_frame.paragraphs[0].font.size = Pt(14)
pill.text_frame.paragraphs[0].font.color.rgb = Colors.WHITE
pill.text_frame.paragraphs[0].font.name = self.FONT
pill.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
# Pillar name
self._add_textbox(
slide, Inches(1.2), y,
Inches(4), Inches(0.45),
text=pillar["name"], font_size=13, bold=True,
)
# Score
score_text = f"{pillar['passed']}/{pillar['total']}" if pillar['total'] > 0 else ""
self._add_textbox(
slide, Inches(5.5), y,
Inches(1.5), Inches(0.45),
text=score_text, font_size=13,
color=Colors.SECONDARY_TEXT, alignment=PP_ALIGN.CENTER,
)
# Status label
self._add_textbox(
slide, Inches(7), y,
Inches(5), Inches(0.45),
text=style["label"], font_size=11,
color=style["color"], bold=True,
)
y += Inches(0.6)
# Recommendations
if recommendations:
y += Inches(0.3)
self._add_textbox(
slide, self.MARGIN, y,
Inches(12), Inches(0.35),
text="Top Recommendations:", font_size=12, bold=True,
color=Colors.TEAL,
)
y += Inches(0.4)
for rec in recommendations[:4]:
self._add_textbox(
slide, Inches(0.7), y,
Inches(11), Inches(0.3),
text=f"{rec}", font_size=10,
)
y += Inches(0.3)
# Reference
self._add_textbox(
slide, self.MARGIN, Inches(6.5),
Inches(12), Inches(0.35),
text="Validated against Oracle Well-Architected Framework — docs.oracle.com/en/solutions/oci-best-practices/",
font_size=10, italic=True, color=Colors.SECONDARY_TEXT,
)
def add_next_steps_slide(self, steps: list, contact_info: str = ""):
"""Slide 12: Next Steps."""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Next Steps", margin=self.MARGIN)
y = Inches(1.5)
for i, step in enumerate(steps):
# Number circle
circle = slide.shapes.add_shape(
MSO_SHAPE.OVAL,
self.MARGIN, y,
Inches(0.4), Inches(0.4),
)
circle.fill.solid()
circle.fill.fore_color.rgb = Colors.TEAL
circle.line.fill.background()
circle.text_frame.paragraphs[0].text = str(i + 1)
circle.text_frame.paragraphs[0].font.size = Pt(12)
circle.text_frame.paragraphs[0].font.bold = True
circle.text_frame.paragraphs[0].font.color.rgb = Colors.WHITE
circle.text_frame.paragraphs[0].font.name = self.FONT
circle.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
# Step text
self._add_textbox(
slide, Inches(1.1), y,
Inches(10), Inches(0.4),
text=step, font_size=13,
)
y += Inches(0.65)
if contact_info:
self._add_textbox(
slide, self.MARGIN, Inches(6),
Inches(12), Inches(0.4),
text=contact_info, font_size=11,
color=Colors.SECONDARY_TEXT,
)
# ---- Build from YAML spec ----
@classmethod
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "OCIDeckGenerator":
"""Build a complete deck from a YAML specification."""
# The MCP server passes a flat payload shape (customer_name, services,
# cost_summary, ...) that doesn't match the proposal-spec structure this
# method was originally written against. Adapt it so the deck renders
# with actual content instead of only blank title/closing slides.
if _is_flat_spec(spec):
spec = _adapt_flat_spec(spec)
meta = spec.get("metadata", {})
gen = cls(
customer=meta.get("customer", ""),
project=meta.get("project", ""),
architect=meta.get("architect", ""),
firm=meta.get("firm", ""),
template=template,
)
# Slide 1: Title
gen.add_title_slide(subtitle=meta.get("subtitle", ""))
# Slide 2: Summary
if "summary" in spec:
s = spec["summary"]
gen.add_summary_slide(
why=s.get("why", ""),
current_state=s.get("current_state", []),
target_state=s.get("target_state", ""),
timeline=s.get("timeline", ""),
)
# Slide 3: Service Tiering (ECAL)
if "service_tiering" in spec:
gen.add_service_tiering_slide(spec["service_tiering"])
# Slide 4: Architecture Principles (ECAL)
if "architecture_principles" in spec:
gen.add_architecture_principles_slide(spec["architecture_principles"])
# Slide 5: Architecture
if "architecture" in spec:
a = spec["architecture"]
gen.add_architecture_slide(
diagram_path=a.get("diagram_path"),
description=a.get("description", ""),
visual=a.get("visual"),
)
# Slide 6: Decisions
if "decisions" in spec:
gen.add_decisions_slide(spec["decisions"])
# Slide 7: HA/DR
if "ha_dr" in spec:
h = spec["ha_dr"]
gen.add_ha_dr_slide(
tiers=h.get("tiers", []),
description=h.get("description", ""),
)
# Slide 8: Security
if "security" in spec:
s = spec["security"]
gen.add_security_slide(
controls=s.get("controls", {}),
compliance=s.get("compliance", []),
)
# Slide 9: Environment Catalogue (ECAL)
if "environment_catalogue" in spec:
ec = spec["environment_catalogue"]
gen.add_environment_catalogue_slide(
environments=ec.get("environments", []),
cost_notes=ec.get("cost_notes"),
)
# Slide 10: Cost
if "cost" in spec:
c = spec["cost"]
gen.add_cost_slide(
line_items=c.get("line_items", []),
assumptions=c.get("assumptions", []),
show_byol=c.get("show_byol", True),
)
# Slide 11: Cost Comparison (optional)
if "cost_comparison" in spec:
cc = spec["cost_comparison"]
gen.add_cost_comparison_slide(
rows=cc.get("rows", []),
title=cc.get("title", "Cost Comparison"),
col_headers=cc.get("col_headers"),
)
# Slide 12: Migration
if "migration" in spec:
m = spec["migration"]
gen.add_migration_slide(
phases=m.get("phases", []),
tools=m.get("tools", []),
downtime=m.get("downtime", ""),
)
# Slide 13: Operational RACI (ECAL)
if "operational_raci" in spec:
r = spec["operational_raci"]
gen.add_operational_raci_slide(
raci_items=r.get("raci_items", []),
model=r.get("model", "co_managed"),
)
# Slide 14: Risks
if "risks" in spec:
gen.add_risk_slide(spec["risks"])
# Slide 15: WA Scorecard
if "scorecard" in spec:
sc = spec["scorecard"]
gen.add_scorecard_slide(
pillars=sc.get("pillars", []),
recommendations=sc.get("recommendations", []),
)
# Slide 16: Next Steps
if "next_steps" in spec:
ns = spec["next_steps"]
gen.add_next_steps_slide(
steps=ns.get("steps", []),
contact_info=ns.get("contact_info", ""),
)
# Closing slides
gen.add_closing_slide(
name=meta.get("architect", ""),
title=meta.get("firm", ""),
)
return gen
# ============================================================
# CLI
# ============================================================
def main():
parser = argparse.ArgumentParser(
description="Generate OCI architecture proposal slide deck (.pptx)"
)
parser.add_argument(
"--spec", required=True,
help="Path to YAML spec file with proposal data",
)
parser.add_argument(
"--output", default="architecture-proposal.pptx",
help="Output .pptx file path",
)
args = parser.parse_args()
with open(args.spec, 'r') as f:
spec = yaml.safe_load(f)
gen = OCIDeckGenerator.from_spec(spec)
gen.save(args.output)
print(f"Generated: {args.output}")
print(f" Slides: {gen.slide_count}")
print(f" Template: Oracle FY26")
customer = spec.get("metadata", {}).get("customer", "")
if customer:
print(f" Customer: {customer}")
if __name__ == "__main__":
main()