#!/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 import re 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 try: from oci_pptx_base import Colors, Layouts, OraclePresBase except ModuleNotFoundError: from tools.oci_pptx_base import Colors, Layouts, OraclePresBase # ============================================================ # Defensive input helpers # ============================================================ def pick(mapping: dict, *keys, default=""): """Return the first non-empty value for any of the provided keys.""" if not isinstance(mapping, dict): return default for key in keys: if key in mapping: value = mapping.get(key) if value is not None: return value return default def coerce_list(value): """Accept list, string, tuple, or None and normalize to a list.""" if value is None: return [] if isinstance(value, list): return value if isinstance(value, tuple): return list(value) if isinstance(value, str): return [value] if value else [] return [value] def pick_list(mapping: dict, *keys): """Return the first list-like field normalized to a list.""" if not isinstance(mapping, dict): return [] for key in keys: if key in mapping: return coerce_list(mapping.get(key)) return [] # ============================================================ # 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")) _PATTERNS_DIR = Path(__file__).resolve().parent.parent / "kb" / "patterns" def _load_patterns_yaml(filename: str) -> dict: """Load a patterns YAML (supports 2-document layout: header + body).""" path = _PATTERNS_DIR / filename if not path.is_file(): return {} try: with open(path, "r") as f: docs = [d for d in yaml.safe_load_all(f) if isinstance(d, dict)] return docs[-1] if docs else {} except Exception: return {} def _default_architecture_principles() -> dict: """Curated ECAL principles — 3 per category, 'applies_when: always' first.""" data = _load_patterns_yaml("architecture-principles.yaml") principles = data.get("principles", {}) if isinstance(data, dict) else {} result = {} for cat in ("design", "deployment", "service"): items = principles.get(cat, []) or [] always = [p for p in items if p.get("applies_when") == "always"] picks = (always or items)[:3] result[cat] = [ { "id": p.get("id", ""), "name": p.get("name", ""), "summary": p.get("principle", p.get("summary", "")), } for p in picks if isinstance(p, dict) ] return result if any(result.values()) else {} def _principles_kb_index() -> dict: """Index every KB principle by id so spec items with only id get enriched.""" data = _load_patterns_yaml("architecture-principles.yaml") principles = data.get("principles", {}) if isinstance(data, dict) else {} index = {} for cat_items in principles.values(): for p in cat_items or []: if isinstance(p, dict) and p.get("id"): index[str(p["id"])] = { "name": p.get("name", ""), "summary": p.get("principle", p.get("summary", "")), } return index def _default_operational_raci(model: str = "co_managed") -> list: data = _load_patterns_yaml("operational-raci.yaml") models = data.get("models", {}) if isinstance(data, dict) else {} chosen = models.get(model) or models.get("co_managed") or {} raci = chosen.get("raci", []) return [r for r in raci if isinstance(r, dict)] def _derive_environments(deployment: str, dr: str) -> list: """Produce a default environment catalogue from deployment model + DR flag.""" deployment_lc = (deployment or "").lower() dr_enabled = bool(dr) and str(dr).lower() not in {"", "no", "none", "not in scope", "disabled", "false"} envs = [ {"name": "Production", "sizing": "Full capacity per workload profile", "isolation": "Dedicated compartment, private endpoints", "cost_pct": 55}, {"name": "Pre-Production / UAT", "sizing": "50-75% of production", "isolation": "Shared tenancy OK, same region", "cost_pct": 20}, {"name": "Dev/Test", "sizing": "25% of production, auto-stop off-hours", "isolation": "Shared, relaxed controls", "cost_pct": 10}, ] if dr_enabled or "multi" in deployment_lc or "dr" in deployment_lc: envs.append({ "name": "DR (secondary region)", "sizing": "100% of production, async replication", "isolation": "Cross-region, same security baseline", "cost_pct": 15, }) return envs _HA_DR_BY_TIER = { "platinum": { "technology": "RAC + cross-region Active Data Guard (sync)", "rto": "< 1 hour", "rpo": "< 5 minutes", }, "gold": { "technology": "TAC + cross-region Active Data Guard (async)", "rto": "2-4 hours", "rpo": "15 minutes", }, "silver": { "technology": "Single instance + cross-region backup restore", "rto": "4-8 hours", "rpo": "1 hour", }, "bronze": { "technology": "Single instance + Object Storage archive", "rto": "24 hours", "rpo": "24 hours", }, } _UPTIME_BY_TIER = { "platinum": "99.99%", "gold": "99.95%", "silver": "99.9%", "bronze": "99.5%", } def _derive_ha_dr_tiers(workloads: list) -> list: """Produce one HA/DR row per distinct service tier present.""" seen = [] for wl in workloads or []: tier = str(wl.get("tier", "")).lower() if tier in _HA_DR_BY_TIER and tier not in seen: seen.append(tier) return [ {"tier": t.title(), **_HA_DR_BY_TIER[t]} for t in seen ] def _default_security_baseline() -> dict: """Conservative OCI security baseline — customer confirms specifics.""" return { "controls": { "identity": [ "IAM compartments per environment + tag-based policies", "Federated SSO via customer IdP (SAML/OIDC)", "MFA enforced for all human users", ], "network": [ "Private subnets with NSGs; no public IPs on data tier", "Service Gateway for OCI APIs; no egress over public internet", "FastConnect or Site-to-Site VPN for hybrid connectivity", ], "database": [ "TDE at rest (customer-managed keys via OCI Vault)", "Network encryption in transit (TLS 1.2+)", "Data Safe for activity auditing + sensitive data discovery", ], "monitoring": [ "OCI Logging + Service Connector Hub to SIEM", "Cloud Guard detectors enabled (baseline recipe)", "OCI Monitoring alarms on quota, cost, and security events", ], }, "compliance": ["ISO 27001", "SOC 2", "PCI-DSS"], } def _derive_service_tiering(services: list) -> list: """Map a flat services list to service-tiering workload rows. Preserves any tier/uptime/rto/rpo the caller supplied; fills sensible defaults so the slide always has a full table instead of blanks. """ workloads = [] for svc in services or []: if not isinstance(svc, dict): continue name = svc.get("name") or svc.get("workload") or svc.get("sku") or "Service" tier = svc.get("tier") or svc.get("service_tier") or "Gold" workloads.append({ "name": name, "tier": str(tier).title(), "uptime": svc.get("uptime", "99.9%"), "rto": svc.get("rto", "4 hours"), "rpo": svc.get("rpo", "15 minutes"), }) return workloads def _timeline_to_weeks(timeline: str) -> int | None: """Parse a simple duration phrase like '12 weeks' or '6 months'.""" if not timeline: return None text = str(timeline).lower() match = re.search(r"(\d+)\s*(week|weeks|month|months)", text) if not match: return None amount = int(match.group(1)) unit = match.group(2) return amount * 4 if unit.startswith("month") else amount def _proposal_default_migration(timeline: str = "") -> dict: """Generate a conservative Define/Design/Deliver plan from total duration.""" total_weeks = _timeline_to_weeks(timeline) if total_weeks and total_weeks >= 6: define = max(1, round(total_weeks * 0.2)) design = max(1, round(total_weeks * 0.3)) deliver = max(1, total_weeks - define - design) total_duration = f"{total_weeks} weeks" else: define, design, deliver = 2, 4, 6 total_duration = timeline or "12 weeks (to validate)" return { "total_duration": total_duration, "phases": [ { "name": "Define", "duration": f"{define} weeks", "tasks": [ "Validate scope and business drivers", "Baseline current estate and constraints", "Confirm success criteria and migration assumptions", ], }, { "name": "Design", "duration": f"{design} weeks", "tasks": [ "Finalize target architecture and landing zone", "Confirm security, DR, and networking approach", "Validate sizing and migration wave plan", ], }, { "name": "Deliver", "duration": f"{deliver} weeks", "tasks": [ "Execute migration waves and rehearsals", "Run cutover and stabilization", "Complete operations handover", ], }, ], "downtime": "Confirm cutover window with application owners during design.", } def _proposal_default_risks(summary: dict) -> list: """Infer generic-but-grounded proposal risks from sparse summary text.""" text_parts = [ pick(summary, "why"), pick(summary, "target_state"), pick(summary, "timeline"), *coerce_list(summary.get("current_state")), ] text = " ".join(str(p) for p in text_parts if p).lower() risks = [] if any(token in text for token in ("legacy", "on-prem", "on premises", "migration")): risks.append({ "risk": "Current-state migration scope may be larger than initially captured.", "severity": "HIGH", "mitigation": "Complete application and database discovery before locking the wave plan.", }) if any(token in text for token in ("dr", "disaster recovery", "rto", "rpo", "resilien")): risks.append({ "risk": "Resilience objectives need explicit validation against the proposed operating model.", "severity": "MEDIUM", "mitigation": "Confirm RTO/RPO targets and rehearse failover before production cutover.", }) if any(token in text for token in ("cost", "budget", "tco", "byol", "payg")): risks.append({ "risk": "Commercial assumptions may diverge from the customer's actual utilization baseline.", "severity": "MEDIUM", "mitigation": "Validate sizing, discount, and licensing assumptions with finance and platform owners.", }) if any(token in text for token in ("team", "skill", "operations", "dba", "cloud")): risks.append({ "risk": "Day-2 operating responsibilities may be unclear for the delivery team.", "severity": "MEDIUM", "mitigation": "Review the co-managed RACI and confirm ownership before the pilot starts.", }) if not risks: risks.append({ "risk": "Incomplete discovery details can force late design changes.", "severity": "MEDIUM", "mitigation": "Run a discovery checkpoint before committing to procurement or migration dates.", }) return risks[:4] def _proposal_default_next_steps(summary: dict) -> list: timeline = pick(summary, "timeline") steps = [ "Review the proposal with technical and business stakeholders.", "Validate current-state inventory, sizing, and dependency assumptions.", "Confirm the target delivery model and success criteria for the first wave.", ] if timeline: steps.append(f"Reconcile the plan against the stated timeline ({timeline}).") steps.append("Decide whether to proceed with a proof of concept or detailed design workshop.") return steps[:5] def _enrich_partial_proposal_spec(spec: dict) -> dict: """Add safe fallback sections so sparse proposal specs still render usable decks.""" if not isinstance(spec, dict): return spec summary = spec.get("summary") if not isinstance(summary, dict): return spec target_state = pick(summary, "target_state") has_context = bool(target_state or pick(summary, "why") or coerce_list(summary.get("current_state"))) if not has_context: return spec enriched = dict(spec) if "architecture" not in enriched and target_state: enriched["architecture"] = {"description": target_state} if "architecture_principles" not in enriched: principles = _default_architecture_principles() if principles: enriched["architecture_principles"] = principles if "migration" not in enriched: enriched["migration"] = _proposal_default_migration(pick(summary, "timeline")) if not any(k in enriched for k in ("operational_raci", "raci", "operations_raci")): raci_items = _default_operational_raci("co_managed") if raci_items: enriched["operational_raci"] = { "model": "co_managed", "raci_items": raci_items, } if "risks" not in enriched: enriched["risks"] = _proposal_default_risks(summary) if "next_steps" not in enriched: enriched["next_steps"] = {"steps": _proposal_default_next_steps(summary)} return enriched 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 {} tenancy = spec.get("tenancy") if isinstance(spec.get("tenancy"), 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", "") billing = spec.get("billing_model") or arch.get("license_model", "") capacity = spec.get("capacity") or arch.get("capacity") or {} services = spec.get("services", []) or [] # Region discovery — primary + DR can arrive as flat fields, under # architecture.*, or as a tenancy.regions list (items may be plain # strings or {region, role} dicts). primary_region = ( spec.get("primary_region") or spec.get("region") or arch.get("primary_region") or arch.get("region") or "" ) dr_region = ( spec.get("dr_region") or spec.get("secondary_region") or arch.get("dr_region") or arch.get("secondary_region") or "" ) tenancy_regions = tenancy.get("regions") or spec.get("regions") or arch.get("regions") or [] if isinstance(tenancy_regions, list): for entry in tenancy_regions: if isinstance(entry, dict): name = entry.get("name") or entry.get("region") or entry.get("id") or "" role = str(entry.get("role") or entry.get("type") or "").lower() if role in ("primary", "hub", "prod", "production") and not primary_region: primary_region = name elif role in ("dr", "standby", "secondary", "failover") and not dr_region: dr_region = name elif isinstance(entry, str): if not primary_region: primary_region = entry elif not dr_region: dr_region = entry region = primary_region 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" # If the spec names a DR region, treat DR as enabled even when the # disaster_recovery flag is omitted. if not dr_raw and dr_region: dr_raw = f"Enabled — secondary region: {dr_region}" 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 services: 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": "", }, } workloads = _derive_service_tiering(services) if workloads: adapted["service_tiering"] = workloads arch_desc_parts = [p for p in ( workload and f"Workload: {workload}", deployment and f"Deployment: {deployment}", region and f"Primary region: {region}", dr_region and f"DR region: {dr_region}", billing and f"Licensing: {billing}", ) if p] architecture_block: dict = {} # Preserve any caller-supplied visual/diagram_path instead of overwriting. if isinstance(arch.get("visual"), dict): architecture_block["visual"] = arch["visual"] if arch.get("diagram_path"): architecture_block["diagram_path"] = arch["diagram_path"] if arch_desc_parts: architecture_block["description"] = " • ".join(arch_desc_parts) # When we know both regions and no visual was supplied, emit a simple # two-region visual so the DR region renders with its real name. if region and dr_region and "visual" not in architecture_block: architecture_block["visual"] = { "regions": [ {"name": region, "primary": True, "label": "PRIMARY"}, {"name": dr_region, "primary": False, "label": "DR STANDBY"}, ], } if architecture_block: adapted["architecture"] = architecture_block principles = _default_architecture_principles() if principles: adapted["architecture_principles"] = principles ha_dr_tiers = _derive_ha_dr_tiers(workloads) if ha_dr_tiers: adapted["ha_dr"] = { "tiers": ha_dr_tiers, "description": "HA/DR posture per service tier. Confirm RTO/RPO with business stakeholders.", } adapted["security"] = _default_security_baseline() environments = _derive_environments(deployment, dr_raw) if environments: adapted["environment_catalogue"] = {"environments": environments} # Respect user-supplied RACI if present (string model name, list of items, # or dict with model/raci_items); otherwise fall back to co_managed default. user_raci = None for key in ("operational_raci", "raci", "operations_raci"): if spec.get(key) is not None: user_raci = spec[key] break if user_raci is not None: adapted["operational_raci"] = user_raci else: raci_items = _default_operational_raci("co_managed") if raci_items: adapted["operational_raci"] = { "raci_items": raci_items, "model": "co_managed", } adapted["next_steps"] = { "steps": [ "Validate architecture + service tiers with technical stakeholders", "Confirm capacity assumptions against current/expected utilization", "Lock commitment model (PAYG vs UCM) and finalize discount", "Define proof-of-concept / pilot scope and success criteria", "Schedule operational handover and RACI walkthrough", ], } 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 self._native_pptx_jobs = [] # ---- 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, title: str = "Engagement Summary"): """Slide 2: Engagement Summary.""" slide = self._add_blank_slide() self._add_title_bar(slide, title, 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, title: str = "Architecture Overview", footer: str = ""): """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, title, margin=self.MARGIN) native_requested = self._is_native_pptx_visual(visual) content_top = Inches(1.1) if native_requested: if description: self._add_textbox( slide, self.MARGIN, Inches(1.1), Inches(12), Inches(0.4), text=description, font_size=11, italic=True, color=Colors.SECONDARY_TEXT, ) content_top = Inches(1.55) self._native_pptx_jobs.append({ "slide_number": len(self.prs.slides), "visual": visual, "content_box": { "x": int(Inches(0.5)), "y": int(content_top), "cx": int(Inches(12.3)), "cy": int(Inches(5.2 if description else 5.65)), }, }) elif 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: if description: self._add_textbox( slide, self.MARGIN, Inches(1.1), Inches(12), Inches(0.4), text=description, font_size=11, italic=True, color=Colors.SECONDARY_TEXT, ) 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, ) if footer: self._add_textbox( slide, self.MARGIN, Inches(6.85), Inches(12.1), Inches(0.3), text=footer, font_size=9, italic=True, color=Colors.SECONDARY_TEXT, ) @staticmethod def _is_native_pptx_visual(visual: Optional[dict]) -> bool: if not isinstance(visual, dict): return False if visual.get("native_pptx") is True: return True return visual.get("render_mode") == "native_pptx" def save(self, filepath: str, validate: bool = True) -> dict: super().save(filepath) if self._native_pptx_jobs: try: from oci_pptx_diagram_gen import NativePPTXDiagramRenderer except ModuleNotFoundError: from tools.oci_pptx_diagram_gen import NativePPTXDiagramRenderer renderer = NativePPTXDiagramRenderer() renderer.apply_jobs(filepath, self._native_pptx_jobs) report: dict = {"path": filepath} if validate: report["pptx"] = self._validate_pptx(filepath) return report @staticmethod def _validate_pptx(filepath: str) -> dict: """Pre-delivery sanity check on the generated .pptx. Catches the bugs that historically made PowerPoint prompt to "repair" the file: - Missing Content_Types Default for any embedded media extension. - Slide rels pointing to absent media. - Duplicate cNvPr ids. Always non-fatal; prints diagnostics to stderr. """ import sys as _sys import zipfile as _zf from posixpath import normpath as _np try: from lxml import etree as _et except Exception as exc: print(f"[pptx-validator] WARN: lxml not available ({exc})", file=_sys.stderr) return {"status": "skipped", "error": str(exc)} try: with _zf.ZipFile(filepath) as z: names = z.namelist() CT = "http://schemas.openxmlformats.org/package/2006/content-types" PKG = "http://schemas.openxmlformats.org/package/2006/relationships" P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" ct_root = _et.fromstring(z.read("[Content_Types].xml")) defaults = {d.get("Extension","").lower(): d.get("ContentType") for d in ct_root.findall(f"{{{CT}}}Default")} missing_default = [] for n in names: if not n.startswith("ppt/media/"): continue ext = n.rsplit(".", 1)[-1].lower() if ext and ext not in defaults: missing_default.append(ext) missing_default = sorted(set(missing_default)) broken_rels: list[str] = [] for n in names: if "slides/_rels" not in n or not n.endswith(".rels"): continue slide_dir = "/".join(n.split("/")[:-2]) rels = _et.fromstring(z.read(n)) for rel in rels.findall(f"{{{PKG}}}Relationship"): tgt = rel.get("Target") or "" if tgt.startswith("http"): continue resolved = _np(f"{slide_dir}/{tgt}") if not tgt.startswith("/") else tgt.lstrip("/") if resolved not in names: broken_rels.append(f"{n}: rId {rel.get('Id')} → {resolved}") dup_ids = [] for n in names: if not (n.startswith("ppt/slides/slide") and n.endswith(".xml")): continue root = _et.fromstring(z.read(n)) seen = {} for cn in root.findall(f".//{{{P_NS}}}cNvPr"): cid = cn.get("id") or "" if cid: seen[cid] = seen.get(cid, 0) + 1 dup_ids.extend(f"{n}:{c}" for c, k in seen.items() if k > 1) issues = [] if missing_default: issues.append(("MISSING_CONTENT_TYPE", f"Content_Types.xml missing Default for: {missing_default}. " "PowerPoint will prompt to 'repair' the file.")) if broken_rels: issues.append(("BROKEN_REL", f"{len(broken_rels)} broken relationship target(s)")) if dup_ids: issues.append(("DUP_CNVPR", f"{len(dup_ids)} duplicate cNvPr ids")) status = "fail" if issues else "pass" if issues: print(f"[pptx-validator] FAIL on {filepath}: {len(issues)} issue(s)", file=_sys.stderr) for code, msg in issues: print(f" - {code}: {msg}", file=_sys.stderr) else: print(f"[pptx-validator] OK on {filepath}", file=_sys.stderr) return {"status": status, "issues": [{"code": c, "message": m} for c, m in issues]} except Exception as exc: print(f"[pptx-validator] WARN: could not validate ({exc})", file=_sys.stderr) return {"status": "error", "error": str(exc)} def add_bullets_slide(self, title: str, sections: list, subtitle: str = "", footer: str = ""): """Generic sectioned bullets slide for executive content.""" slide = self._add_blank_slide() self._add_title_bar(slide, title, margin=self.MARGIN) y = Inches(1.12) if subtitle: self._add_textbox( slide, self.MARGIN, y, Inches(12.1), Inches(0.35), text=subtitle, font_size=11, italic=True, color=Colors.SECONDARY_TEXT, ) y += Inches(0.42) for section in sections: heading = section.get("heading", "") body = section.get("body", "") bullets = coerce_list(section.get("bullets")) if heading: self._add_textbox( slide, self.MARGIN, y, Inches(12.1), Inches(0.32), text=heading, font_size=13, bold=True, color=Colors.TEAL, ) y += Inches(0.34) if body: self._add_textbox( slide, self.MARGIN + Inches(0.05), y, Inches(11.9), Inches(0.42), text=body, font_size=11, ) y += Inches(0.42) for bullet in bullets: self._add_textbox( slide, Inches(0.7), y, Inches(11.2), Inches(0.32), text=f"• {bullet}", font_size=11, ) y += Inches(0.31) y += Inches(0.12) if y > Inches(6.35): break if footer: self._add_textbox( slide, self.MARGIN, Inches(6.82), Inches(12.1), Inches(0.28), text=footer, font_size=9, italic=True, color=Colors.SECONDARY_TEXT, ) def add_table_slide(self, title: str, columns: list, rows: list, subtitle: str = "", footer: str = ""): """Generic table slide. columns: list of {"header": str, "key": str, "width": float} rows: list of dicts or lists """ slide = self._add_blank_slide() self._add_title_bar(slide, title, margin=self.MARGIN) y = Inches(1.12) if subtitle: self._add_textbox( slide, self.MARGIN, y, Inches(12.1), Inches(0.34), text=subtitle, font_size=11, italic=True, color=Colors.SECONDARY_TEXT, ) y += Inches(0.4) col_defs = [] for idx, column in enumerate(columns): if isinstance(column, dict): col_defs.append({ "header": column.get("header", column.get("key", f"Column {idx + 1}")), "key": column.get("key", f"col_{idx}"), "width": column.get("width"), }) else: col_defs.append({ "header": str(column), "key": f"col_{idx}", "width": None, }) num_rows = len(rows) + 1 table_height = min(5.4, 0.42 * max(num_rows, 2)) table = self._add_table( slide, num_rows, len(col_defs), self.MARGIN, y, Inches(12.1), Inches(table_height), ) explicit_widths = [c.get("width") for c in col_defs] if all(w is None for w in explicit_widths): even_width = 12.1 / max(len(col_defs), 1) explicit_widths = [even_width] * len(col_defs) else: remaining = 12.1 - sum(w for w in explicit_widths if w is not None) auto_count = sum(1 for w in explicit_widths if w is None) fallback = remaining / auto_count if auto_count else 0 explicit_widths = [w if w is not None else fallback for w in explicit_widths] for idx, width in enumerate(explicit_widths): table.columns[idx].width = Inches(width) for idx, column in enumerate(col_defs): self._style_table_cell( table.cell(0, idx), column["header"], font_size=10, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.CENTER, ) for row_idx, row in enumerate(rows, start=1): bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None for col_idx, column in enumerate(col_defs): if isinstance(row, dict): value = row.get(column["key"], "") elif isinstance(row, list): value = row[col_idx] if col_idx < len(row) else "" else: value = str(row) self._style_table_cell( table.cell(row_idx, col_idx), str(value), font_size=9, bg_color=bg, ) if footer: self._add_textbox( slide, self.MARGIN, Inches(6.82), Inches(12.1), Inches(0.28), text=footer, font_size=9, italic=True, color=Colors.SECONDARY_TEXT, ) def add_flow_architecture_slide(self, title: str, columns: list, connections: list = None, subtitle: str = "", notes: list = None, footer: str = ""): """Executive architecture slide with columns, nodes, and flow arrows.""" slide = self._add_blank_slide() self._add_title_bar(slide, title, margin=self.MARGIN) y_top = Inches(1.1) if subtitle: self._add_textbox( slide, self.MARGIN, y_top, Inches(12.1), Inches(0.32), text=subtitle, font_size=11, italic=True, color=Colors.SECONDARY_TEXT, ) y_top += Inches(0.36) notes = coerce_list(notes) notes_h = Inches(0.9 + 0.2 * min(len(notes), 4)) if notes else Inches(0) columns_top = y_top columns_height = Inches(5.55) - notes_h columns_left = self.MARGIN total_width = Inches(12.1) spacing = Inches(0.12) col_width = (total_width - spacing * max(len(columns) - 1, 0)) / max(len(columns), 1) tone_fill = { "primary": RGBColor(0xF5, 0xF4, 0xF2), "secondary": RGBColor(0xFC, 0xFB, 0xFA), "standby": RGBColor(0xF1, 0xEF, 0xEC), "bridge": RGBColor(0xF8, 0xF5, 0xF2), } tone_border = { "primary": Colors.TEAL, "secondary": Colors.BURNT_ORANGE, "standby": Colors.SECONDARY_TEXT, "bridge": Colors.PURPLE, } node_colors = { "database": Colors.COPPER, "bridge": Colors.PURPLE, "integration": Colors.PURPLE, "service": Colors.TEAL, "standby": RGBColor(0x70, 0x66, 0x5E), "external": Colors.SECONDARY_TEXT, } node_centers = {} ratios = [max(float(column.get("width_ratio", 1.0)), 0.4) for column in columns] ratio_total = sum(ratios) or 1.0 left = columns_left for idx, column in enumerate(columns): col_width = ( (total_width - spacing * max(len(columns) - 1, 0)) * (ratios[idx] / ratio_total) ) tone = column.get("tone", "secondary") border = tone_border.get(tone, Colors.TEAL) fill = tone_fill.get(tone, RGBColor(0xF5, 0xF4, 0xF2)) label = column.get("title", f"Column {idx + 1}") subtitle_text = column.get("subtitle", "") full_label = label if not subtitle_text else f"{label} | {subtitle_text}" self._add_container( slide, left, columns_top, col_width, columns_height, label=full_label, border_color=border, fill_color=fill, dashed=False, label_size=10, ) nodes = coerce_list(column.get("nodes")) node_gap = Inches(0.12) node_left = left + Inches(0.15) node_width = col_width - Inches(0.3) node_y = columns_top + Inches(0.45) for node in nodes: node_id = node.get("id") or f"node_{idx}_{len(node_centers)}" node_kind = node.get("kind", "service") node_color = node_colors.get(node_kind, Colors.TEAL) label = node.get("label", "") label_lines = max(1, str(label).count("\n") + 1) node_h = Inches(max(0.62, 0.17 * label_lines + 0.18)) card = self._add_rect( slide, node_left, node_y, node_width, node_h, fill_color=Colors.WHITE, border_color=node_color, ) card.line.width = Pt(1.6) if node_kind == "standby": card.line.dash_style = 2 accent = self._add_rect( slide, node_left, node_y, Inches(0.08), node_h, fill_color=node_color, border_color=node_color, ) accent.line.fill.background() text_box = self._add_textbox( slide, node_left + Inches(0.16), node_y + Inches(0.08), node_width - Inches(0.24), node_h - Inches(0.12), text=label, font_size=10, color=Colors.PRIMARY_TEXT, ) if text_box.text_frame.paragraphs: text_box.text_frame.paragraphs[0].font.bold = True node_centers[node_id] = ( node_left + node_width / 2, node_y + node_h / 2, node_left, node_y, node_width, node_h, ) node_y += node_h + node_gap if node_y + node_h > columns_top + columns_height - Inches(0.2): break left += col_width + spacing for conn in coerce_list(connections): source = node_centers.get(conn.get("from")) target = node_centers.get(conn.get("to")) if not source or not target: continue sx = source[2] + source[4] sy = source[1] tx = target[2] ty = target[1] dashed = str(conn.get("style", "")).lower() in {"dashed", "bridge", "replication"} color = Colors.PURPLE if dashed else Colors.TEAL self._add_arrow(slide, sx, sy, tx, ty, color=color, dashed=dashed) label = conn.get("label", "") if label: label_x = min(max((sx + tx) / 2 - Inches(0.9), self.MARGIN), Inches(11.3)) label_y = min(sy, ty) - Inches(0.14) self._add_textbox( slide, label_x, label_y, Inches(1.8), Inches(0.24), text=label, font_size=8, color=Colors.SECONDARY_TEXT, alignment=PP_ALIGN.CENTER, ) if notes: note_top = columns_top + columns_height + Inches(0.14) self._add_container( slide, self.MARGIN, note_top, Inches(12.1), notes_h, label="Notes", border_color=Colors.BURNT_ORANGE, fill_color=RGBColor(0xFC, 0xFB, 0xFA), dashed=False, label_size=10, ) note_y = note_top + Inches(0.22) for note in notes[:4]: self._add_textbox( slide, Inches(0.75), note_y, Inches(11.2), Inches(0.22), text=f"• {note}", font_size=9, ) note_y += Inches(0.2) if footer: self._add_textbox( slide, self.MARGIN, Inches(6.85), Inches(12.1), Inches(0.25), text=footer, font_size=9, italic=True, color=Colors.SECONDARY_TEXT, ) 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 workload_name = pick(wl, "name", "workload", "title") tier_label = pick(wl, "tier", "service_tier", default="Silver") tier_key = tier_label.lower() tier_color = tier_colors.get(tier_key, Colors.SECONDARY_TEXT) tier_defaults = _HA_DR_BY_TIER.get(tier_key, {}) uptime = wl.get("uptime") or _UPTIME_BY_TIER.get(tier_key, "") rto = wl.get("rto") or tier_defaults.get("rto", "") rpo = wl.get("rpo") or tier_defaults.get("rpo", "") self._style_table_cell(table.cell(row_idx, 0), workload_name, font_size=10, bold=True, bg_color=bg) 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), uptime, font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER) self._style_table_cell(table.cell(row_idx, 3), rto, font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER) self._style_table_cell(table.cell(row_idx, 4), 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) kb_index = _principles_kb_index() y = Inches(1.2) col_x = self.MARGIN col_width = Inches(4) # Per-item height was Inches(0.4) — fits exactly one 11pt line. # ECAL principle summaries routinely wrap to 2-3 lines (e.g. # "D-03 Use-Case Fit — Map workloads into applicable use-cases. # Architectural simplicity and operational success come from # re-use") which then overflowed into the next bullet's slot. # Diego flagged 2026-04-25: "D-03 esta superpuesto a D-02". # Bumped to 0.95" per item — fits 2-3 wrapped lines with # breathing room. With 3 items per category there's ample # vertical room (3 * 0.95" = 2.85" out of ~5.5" working area). ITEM_H = Inches(0.95) 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.45) for item in items: pid = item.get("id", "") name = item.get("name", "") summary = item.get("summary", "") or item.get("principle", "") if pid and (not name or not summary): fallback = kb_index.get(str(pid), {}) name = name or fallback.get("name", "") summary = summary or fallback.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), ITEM_H, text=f"• {label}", font_size=11, ) item_y += ITEM_H 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), pick(dec, "decision", "title"), font_size=11, bold=True, bg_color=bg, ) self._style_table_cell( table.cell(row_idx, 1), pick(dec, "rationale", "reason", "notes"), 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), pick(tier, "tier", "name"), font_size=10, bold=True, bg_color=bg) self._style_table_cell(table.cell(row_idx, 1), pick(tier, "technology", "architecture", "pattern"), font_size=10, bg_color=bg) self._style_table_cell(table.cell(row_idx, 2), pick(tier, "rto"), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER) self._style_table_cell(table.cell(row_idx, 3), pick(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 component = pick(item, "component", "name", "label") notes = pick(item, "notes", "note") monthly_payg = pick(item, "monthly_payg", "monthly") is_total = "total" in str(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), 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), 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), 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), 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 = "", title: str = "Migration Approach"): """Migration Approach slide. phases: list of {"name": str, "duration"|"weeks": str, "milestones"|"tasks": [...]} """ slide = self._add_blank_slide() self._add_title_bar(slide, title, 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 = pick(phase, "duration", "weeks") milestones = pick_list(phase, "milestones", "tasks") # Phase label (left of bar) self._add_textbox( slide, self.MARGIN, y, Inches(2.3), Inches(0.4), text=pick(phase, "name", "title"), 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", customer_label: str = "Customer", oracle_label: str = "Oracle / Partner", owner_order: str = "customer_first", title: str | None = None): """Operational RACI slide — responsibility matrix. raci_items: list of {"activity": str, "customer": str, "oracle": str, "notes": str} model: "fully_managed", "co_managed", or "self_managed" """ slide = self._add_blank_slide() model_label = model.replace("_", "-").title() self._add_title_bar(slide, title or f"Operational Responsibilities ({model_label})", margin=self.MARGIN) include_notes = any(str(item.get("notes", "")).strip() for item in raci_items) headers = ["Activity"] value_keys = ["activity"] if owner_order == "oracle_first": headers.extend([oracle_label, customer_label]) value_keys.extend(["oracle", "customer"]) else: headers.extend([customer_label, oracle_label]) value_keys.extend(["customer", "oracle"]) if include_notes: headers.append("Notes") value_keys.append("notes") rows = len(raci_items) + 1 table = self._add_table( slide, rows, len(headers), self.MARGIN, Inches(1.2), Inches(12.1), Inches(0.38 * rows), ) if include_notes: widths = [4.4, 1.4, 1.4, 4.9] else: widths = [5.0, 2.5, 2.5] for idx, width in enumerate(widths[:len(headers)]): table.columns[idx].width = Inches(width) 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 for col_idx, key in enumerate(value_keys): align = PP_ALIGN.CENTER if key in {"customer", "oracle"} else PP_ALIGN.LEFT self._style_table_cell( table.cell(row_idx, col_idx), item.get(key, ""), font_size=9 if key == "notes" else 10, bg_color=bg, alignment=align, ) # 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), pick(risk, "risk", "title"), 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), pick(risk, "mitigation", "action", "response"), 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: status = pick(pillar, "status", default="PASS") style = status_styles.get(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=pick(pillar, "name", "pillar"), font_size=13, bold=True, ) # Score passed = pick(pillar, "passed", default=0) total = pick(pillar, "total", default=0) score_text = f"{passed}/{total}" if 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) else: spec = _enrich_partial_proposal_spec(spec) meta = spec.get("metadata", {}) gen = cls( customer=pick(meta, "customer", "customer_name"), project=pick(meta, "project", "project_name"), architect=pick(meta, "architect", "prepared_by"), firm=meta.get("firm", ""), template=template, ) # Slide 1: Title gen.add_title_slide(subtitle=pick(meta, "subtitle", "title")) # Slide 2: Summary if "summary" in spec: s = spec["summary"] gen.add_summary_slide( why=pick(s, "why"), current_state=coerce_list(s.get("current_state")), target_state=pick(s, "target_state"), timeline=pick(s, "timeline"), title=pick(s, "title", default="Engagement Summary"), ) # The flag is documented in two places (top-level OR under # `output:`) — earlier specs put it under `output:` to live # alongside the format selector. Honor both locations so a # spec written either way works. output_block = spec.get("output") or {} render_standard_sections = spec.get( "render_standard_sections", output_block.get("render_standard_sections", True), ) def render_custom_slide(item: dict): slide_type = item.get("type") if slide_type == "bullets": gen.add_bullets_slide( title=item.get("title", "Executive Summary"), sections=pick_list(item, "sections"), subtitle=item.get("subtitle", ""), footer=item.get("footer", ""), ) elif slide_type == "table": gen.add_table_slide( title=item.get("title", "Table"), columns=pick_list(item, "columns"), rows=pick_list(item, "rows"), subtitle=item.get("subtitle", ""), footer=item.get("footer", ""), ) elif slide_type == "flow_architecture": gen.add_flow_architecture_slide( title=item.get("title", "Architecture"), columns=pick_list(item, "columns"), connections=pick_list(item, "connections"), subtitle=item.get("subtitle", ""), notes=pick_list(item, "notes"), footer=item.get("footer", ""), ) elif slide_type == "migration": gen.add_migration_slide( phases=pick_list(item, "phases"), tools=pick_list(item, "tools"), downtime=item.get("downtime", ""), title=item.get("title", "Migration Approach"), ) elif slide_type == "environment_catalogue": gen.add_environment_catalogue_slide( environments=pick_list(item, "environments"), cost_notes=item.get("cost_notes"), ) elif slide_type == "raci": labels = item.get("labels", {}) if isinstance(item.get("labels"), dict) else {} gen.add_operational_raci_slide( raci_items=pick_list(item, "raci_items", "items", "activities"), model=item.get("model", "co_managed"), customer_label=labels.get("customer", "Customer"), oracle_label=labels.get("oracle", "Oracle / Partner"), owner_order=item.get("owner_order", "customer_first"), title=item.get("title"), ) elif slide_type == "diagram": gen.add_architecture_slide( diagram_path=item.get("diagram_path"), description=item.get("description", ""), visual=item.get("visual"), title=item.get("title", "Architecture Overview"), footer=item.get("footer", ""), ) if not render_standard_sections: for custom_slide in pick_list(spec, "custom_slides"): if isinstance(custom_slide, dict): render_custom_slide(custom_slide) if "next_steps" in spec: ns = spec["next_steps"] gen.add_next_steps_slide( steps=pick_list(ns, "steps"), contact_info=pick(ns, "contact_info"), ) return gen # 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"] visual = a.get("visual") description = a.get("description", "") primary_region = ( spec.get("primary_region") or spec.get("region") or a.get("primary_region") or a.get("region") or "" ) dr_region = ( spec.get("dr_region") or spec.get("secondary_region") or a.get("dr_region") or a.get("secondary_region") or "" ) # Auto-build a two-region visual when the spec names a DR region # but didn't pre-render the visual structure itself. if dr_region and not visual: visual = { "regions": [ {"name": primary_region or "Primary", "primary": True, "label": "PRIMARY"}, {"name": dr_region, "primary": False, "label": "DR STANDBY"}, ], } elif dr_region and isinstance(visual, dict): regions = visual.get("regions") or [] names = {str(r.get("name", "")).lower() for r in regions if isinstance(r, dict)} if dr_region.lower() not in names: regions.append({"name": dr_region, "primary": False, "label": "DR STANDBY"}) visual["regions"] = regions # Single-region fallback: if we know the primary region but no # visual or diagram was supplied, render one big PRIMARY block # so the slide isn't just a lonely description line. if not visual and not a.get("diagram_path") and primary_region: visual = { "regions": [ {"name": primary_region, "primary": True, "label": "PRIMARY"}, ], } if dr_region and description and dr_region.lower() not in description.lower(): description = f"{description} • DR region: {dr_region}" gen.add_architecture_slide( diagram_path=a.get("diagram_path"), description=description, visual=visual, title=pick(a, "title", default="Architecture Overview"), footer=a.get("footer", ""), ) # 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=pick_list(c, "line_items"), assumptions=pick_list(c, "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=pick_list(m, "phases"), tools=pick_list(m, "tools"), downtime=pick(m, "downtime"), title=pick(m, "title", default="Migration Approach"), ) # Slide 13: Operational RACI (ECAL) raci_payload = None for key in ("operational_raci", "raci", "operations_raci"): if key in spec and spec[key] is not None: raci_payload = spec[key] break if raci_payload is not None: model = "co_managed" raci_items = [] if isinstance(raci_payload, str): model = raci_payload raci_labels = {} owner_order = "customer_first" title = None elif isinstance(raci_payload, list): raci_items = [r for r in raci_payload if isinstance(r, dict)] raci_labels = {} owner_order = "customer_first" title = None elif isinstance(raci_payload, dict): model = raci_payload.get("model") or raci_payload.get("engagement_model") or "co_managed" items = ( raci_payload.get("raci_items") or raci_payload.get("items") or raci_payload.get("activities") or [] ) raci_items = [r for r in items if isinstance(r, dict)] raci_labels = raci_payload.get("labels") if isinstance(raci_payload.get("labels"), dict) else {} owner_order = raci_payload.get("owner_order", "customer_first") title = raci_payload.get("title") if not raci_items: raci_items = _default_operational_raci(model) gen.add_operational_raci_slide( raci_items=raci_items, model=model, customer_label=raci_labels.get("customer", "Customer"), oracle_label=raci_labels.get("oracle", "Oracle / Partner"), owner_order=owner_order, title=title, ) for custom_slide in pick_list(spec, "custom_slides"): if isinstance(custom_slide, dict): render_custom_slide(custom_slide) # 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=pick_list(sc, "pillars"), recommendations=pick_list(sc, "recommendations"), ) # Slide 16: Next Steps if "next_steps" in spec: ns = spec["next_steps"] gen.add_next_steps_slide( steps=pick_list(ns, "steps", "next_steps"), contact_info=pick(ns, "contact_info"), ) 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()