Brand icons (Slack/Jira) in diagrams + BOM comparison deck
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 35s
SKILL.md sync / check-sync (push) Successful in 18s

- brand_icon_catalog: shared resolver for non-OCI vendor assets (Slack, Jira, Jira Service Management) under kb/diagram/brand-icons; official > fallback; never hits the network at render time.
- oci_diagram_gen + oci_pptx_diagram_gen: services and external actors accept `brand_icon:`, unlocking real multicloud / SaaS topologies beyond Oracle stencils.
- build_bom_comparison_deck: customer-facing Oracle FY26 deck that compares N BOM specs with native PowerPoint charts and live SKU pricing.
- README + SKILL.md: document the new capabilities and ship copy/paste demo prompts (native .pptx diagram, Slack→OIC→Jira flow, multicloud Postgres↔ADB-S, discount-aware BOM).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-29 23:03:18 -03:00
parent 7ea17b2f9e
commit c22ba64bdb
5 changed files with 771 additions and 5 deletions

View File

@@ -119,6 +119,15 @@ Present these options as a compact numbered list. Each option has a bold title f
Pick a number, or just describe what you need. Pick a number, or just describe what you need.
``` ```
### Example Direct Requests
If the user asks for examples, or skips the menu and writes a natural-language request, prompts like these map directly to the capabilities above:
- `Generame un diagrama nativo en .pptx, simple y prolijo, de esta arquitectura: usuarios externos -> load balancer público -> 2 VMs de aplicación en subnet privada -> Autonomous Database Serverless. Quiero que quede presentable para mostrar directo en PowerPoint. No me pidas YAML ni setup adicional.`
- `Generate a simple high-level integration diagram for this flow: Slack -> Oracle Integration Cloud (OIC) -> Jira Service Management -> My Oracle Support. Add clear Client, Oracle, and External layers. Use brand icons for Slack and Jira Service Management, and deliver both .drawio and .pptx.`
- `Generate a high-level architecture diagram for this scenario: 1 PostgreSQL in OCI Ashburn, accessed from GCP Virginia through interconnect. PostgreSQL connects internally to 1 Autonomous Database Serverless, which also has 1 refreshable clone in the same region but in a different AD. Deliver both .drawio and .pptx. Keep it simple, executive-friendly, and technically correct.`
- `Gere um BOM em USD para este cenário: PostgreSQL no OCI com 4 OCPU e 500 GB, ADB-S com 200 ECPU e 1 TB BYOL, 1 refreshable clone com o mesmo sizing e FastConnect de 1 Gbps. Aplique 11% de desconto, 12 meses, 24 horas por dia. Quero o arquivo .xlsx e um resumo claro do custo mensal e anual.`
### Behavior Rules ### Behavior Rules
- If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)." - If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)."

View File

@@ -195,7 +195,21 @@
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-final.png)", "Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-final.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/asis-adb-s-local-dr.drawio --out tmp/drawio-renders/asis-final2.png)", "Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/asis-adb-s-local-dr.drawio --out tmp/drawio-renders/asis-final2.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-final2.png)", "Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-final2.png)",
"Bash(.githooks/pre-commit)" "Bash(.githooks/pre-commit)",
"Bash(unzip -l awr_Transactional.zip)",
"Bash(mkdir -p /tmp/awr_meli)",
"Bash(unzip -o \"/mnt/c/Users/Diego/Documents/Meli/ABD-M/April 2026 - Fraude ADB-S to ADB-D/awr_Transactional.zip\")",
"Bash(cp \"/mnt/c/Users/Diego/Documents/Meli/ABD-M/April 2026 - Fraude ADB-S to ADB-D/meli-fraud-adbd-arch.pptx\" /tmp/meli-fraud-adbd-arch.pptx)",
"Bash(unzip -o meli-fraud-adbd-arch.pptx -d meli-pptx-extracted/)",
"Bash(cp -f \"/mnt/c/Users/Diego/Documents/Meli/ABD-M/April 2026 - Fraude ADB-S to ADB-D/meli-fraud-adbd-arch.pptx\" /tmp/meli-fraud-adbd-arch.pptx)",
"Bash(rm -rf /tmp/meli-pptx-extracted)",
"Bash(unzip -oq /tmp/meli-fraud-adbd-arch.pptx -d /tmp/meli-pptx-extracted/)",
"Bash(cp -f \"/mnt/c/Users/Diego/Documents/Meli/ABD-M/April 2026 - Fraude ADB-S to ADB-D/meli-fraud-adbd-arch.pptx\" meli-fraud-adbd-arch.pptx)",
"Bash(rm -rf meli-pptx-extracted)",
"Bash(unzip -oq meli-fraud-adbd-arch.pptx -d meli-pptx-extracted/)",
"Bash(yes)",
"WebFetch(domain:blog.shadabmohammad.com)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png --scale 1 -o \"$\\(pwd\\)/examples/output-aws-mysql-to-oci-heatwave-fsdr/aws-mysql-to-heatwave-fsdr.png\" \"$\\(pwd\\)/examples/output-aws-mysql-to-oci-heatwave-fsdr/aws-mysql-to-heatwave-fsdr.drawio\")"
] ]
} }
} }

View File

@@ -11,14 +11,17 @@ What normally takes an SA days of work (structuring notes, designing architectur
- **ECAL 3.1 native** — engagement RACI, artefact catalog, readiness scoring, and lessons learned per step baked into the workflow - **ECAL 3.1 native** — engagement RACI, artefact catalog, readiness scoring, and lessons learned per step baked into the workflow
- **Field knowledge, beyond the docs** — built-in KB with real gotchas, workarounds, and sizing lessons from production OCI deployments - **Field knowledge, beyond the docs** — built-in KB with real gotchas, workarounds, and sizing lessons from production OCI deployments
- **Honest about trade-offs** — flags OCI limitations and competitive gaps instead of overselling - **Honest about trade-offs** — flags OCI limitations and competitive gaps instead of overselling
- **Multi-cloud aware** — supports hybrid/multi-cloud diagrams (AWS, Azure, GCP icons) and considers options like ADB Multicloud before recommending full migration - **Multi-cloud aware** — supports hybrid/multi-cloud diagrams (AWS, Azure, GCP, plus external brand icons such as Slack and Jira) and considers options like ADB Multicloud before recommending full migration
- **MCP-tolerant inputs** — deck, diagram, BOM, and business-case generators accept flat MCP payloads and common field aliases instead of requiring one rigid schema
- **End-to-end coverage** — from discovery notes to go-live checklist, not just the architecture slide - **End-to-end coverage** — from discovery notes to go-live checklist, not just the architecture slide
## New user? Start here ## New user? Start here
The skill runs as a hosted MCP server. Connect once from your LLM client, sign in with Oracle SSO, and the tools are ready. The skill runs as a hosted MCP server. Connect once from your LLM client, sign in with Oracle SSO, and the tools are ready.
**1. Add the MCP server to your client:** Fastest path: open AI Factory Hub at `https://ai-lad.com/`, search for `OCI Deal Accelerator`, and install it from there. If you prefer manual setup, use the MCP client instructions below.
**1. Add the MCP server to your client (manual setup):**
| Client | How | | Client | How |
|---|---| |---|---|
@@ -45,6 +48,7 @@ The skill runs as a hosted MCP server. Connect once from your LLM client, sign i
### Connection URLs ### Connection URLs
- **AI Factory Hub:** `https://ai-lad.com/`
- **MCP (HTTP streamable):** `https://mcp.tech-lad.com/deal-accelerator/mcp/` - **MCP (HTTP streamable):** `https://mcp.tech-lad.com/deal-accelerator/mcp/`
- **SSE (Windsurf only):** `https://mcp.tech-lad.com/deal-accelerator/sse/` - **SSE (Windsurf only):** `https://mcp.tech-lad.com/deal-accelerator/sse/`
@@ -61,10 +65,12 @@ From unstructured input (meeting notes, emails, Slack threads), the skill genera
| **Business Case** — TCO, ROI, value drivers, risk assessment | .pptx | Define | Ideate | | **Business Case** — TCO, ROI, value drivers, risk assessment | .pptx | Define | Ideate |
| **Joint Engagement Plan** — scope, resources, timeline | YAML | Define | Plan | | **Joint Engagement Plan** — scope, resources, timeline | YAML | Define | Plan |
| **Discovery Questionnaire** — structured IT landscape collection | YAML | Design | Current | | **Discovery Questionnaire** — structured IT landscape collection | YAML | Design | Current |
| **Architecture Diagram**official Oracle visual style, multi-cloud support | .drawio | Design | Future | | **Architecture Diagram**Oracle reference-architecture geometry, multi-cloud + external brand support | .drawio / .pptx | Design | Future |
| **Slide Deck** — 6-15 slides scaled to engagement complexity | .pptx | Design | Confirm | | **Slide Deck** — 6-15 slides scaled to engagement complexity | .pptx | Design | Confirm |
| **Customer PDF** — branded, no internal KB references | .pdf | Design | Confirm | | **Customer PDF** — branded, no internal KB references | .pdf | Design | Confirm |
| **Cost Estimate** — BYOL vs PAYG breakdown with assumptions | YAML | Design | Future | | **Cost Estimate** — BYOL vs PAYG breakdown with assumptions | YAML | Design | Future |
| **Bill of Materials (BOM)** — requested SKUs only, formula-driven totals, discount-aware workbook | .xlsx | Design | Future |
| **AppCA-ready BOM** — AppCA import sheet plus full BOM workbook detail | .xlsx | Design | Future |
| **Well-Architected Scorecard** — 5-pillar automated validation | YAML | Design | Future | | **Well-Architected Scorecard** — 5-pillar automated validation | YAML | Design | Future |
| **Operations Model** — day-2 monitoring, patching, incident response | YAML | Design | Future | | **Operations Model** — day-2 monitoring, patching, incident response | YAML | Design | Future |
| **ECAL Readiness Scorecard** — 60-artefact gap analysis per phase | Text | All | All | | **ECAL Readiness Scorecard** — 60-artefact gap analysis per phase | Text | All | All |
@@ -100,9 +106,40 @@ deck + pdf ← + customer-facing PDF (branded, no internal refs)
pdf ← customer PDF only pdf ← customer PDF only
bizcase ← business case deck for customer approval bizcase ← business case deck for customer approval
full ← everything (pptx + drawio + docx + xlsx + pdf) full ← everything (pptx + drawio + docx + xlsx + pdf)
bom ← bill of materials (.xlsx)
deliver ← handover + go-live checklist + success criteria deliver ← handover + go-live checklist + success criteria
``` ```
Standalone architecture diagrams can be generated as editable `.drawio` files or as native Oracle-style `.pptx` diagrams/slides, depending on the workflow you trigger.
## Example Prompts
These copy/paste prompts are good demo asks for the newest user-visible features, especially when using Codex or another MCP client.
### Native PowerPoint diagram (.pptx)
```text
Generame un diagrama nativo en .pptx, simple y prolijo, de esta arquitectura: usuarios externos -> load balancer público -> 2 VMs de aplicación en subnet privada -> Autonomous Database Serverless. Quiero que quede presentable para mostrar directo en PowerPoint. No me pidas YAML ni setup adicional.
```
### Brand icons + OIC + dual output
```text
Generate a simple high-level integration diagram for this flow: Slack -> Oracle Integration Cloud (OIC) -> Jira Service Management -> My Oracle Support. Add clear Client, Oracle, and External layers. Use brand icons for Slack and Jira Service Management, and deliver both .drawio and .pptx.
```
### Real multicloud topology
```text
Generate a high-level architecture diagram for this scenario: 1 PostgreSQL in OCI Ashburn, accessed from GCP Virginia through interconnect. PostgreSQL connects internally to 1 Autonomous Database Serverless, which also has 1 refreshable clone in the same region but in a different AD. Deliver both .drawio and .pptx. Keep it simple, executive-friendly, and technically correct.
```
### Discount-aware BOM
```text
Gere um BOM em USD para este cenário: PostgreSQL no OCI com 4 OCPU e 500 GB, ADB-S com 200 ECPU e 1 TB BYOL, 1 refreshable clone com o mesmo sizing e FastConnect de 1 Gbps. Aplique 11% de desconto, 12 meses, 24 horas por dia. Quero o arquivo .xlsx e um resumo claro do custo mensal e anual.
```
## Knowledge Base ## Knowledge Base
The KB is the moat — field experience, not documentation regurgitation. The KB is the moat — field experience, not documentation regurgitation.
@@ -159,6 +196,7 @@ kb/services/dbexpert-api-reference.yaml # API endpoints and refresh procedure
Cached locally under `kb/diagram/assets/archcenter-refs/<slug>/`: Cached locally under `kb/diagram/assets/archcenter-refs/<slug>/`:
- `_description.md` — Oracle's architecture rationale (used by both the lookup tool and SKILL.md option 10 "Reference architecture lookup") - `_description.md` — Oracle's architecture rationale (used by both the lookup tool and SKILL.md option 10 "Reference architecture lookup")
- `*.drawio` — official editable source for ~110 references (where Oracle ships a zip) - `*.drawio` — official editable source for ~110 references (where Oracle ships a zip)
- `_template.yaml` — extracted `absolute_layout` scaffold so new diagrams can start from Oracle geometry instead of from scratch
- `*.svg` / `*.png` — raster fallback for the rest - `*.svg` / `*.png` — raster fallback for the rest
During **Phase 2 (DESIGN)**, the skill automatically matches the proposed architecture against the catalog: During **Phase 2 (DESIGN)**, the skill automatically matches the proposed architecture against the catalog:
@@ -183,7 +221,7 @@ python tools/archcenter_description_fetcher.py --limit 200
python tools/archcenter_zip_downloader.py # idempotent; skips slugs whose folder already has a .drawio python tools/archcenter_zip_downloader.py # idempotent; skips slugs whose folder already has a .drawio
``` ```
The cached assets under `kb/diagram/assets/archcenter-refs/` (~83MB) are committed so the skill works offline. Refresh quarterly or when `refresh_arch_catalog.py --whats-new` adds entries — both downloader and description fetcher are idempotent and only fetch what's missing. The cached assets under `kb/diagram/assets/archcenter-refs/` (~83MB) are committed so the skill works offline. Lookup uses a local index for near-instant matches, and can be rebuilt manually when needed. Refresh quarterly or when `refresh_arch_catalog.py --whats-new` adds entries — both downloader and description fetcher are idempotent and only fetch what's missing.
### KB Health & Freshness ### KB Health & Freshness
@@ -362,6 +400,8 @@ If you paste discovery notes directly, the skill skips the menu and goes straigh
## Tools ## Tools
All major generators accept either the canonical nested YAML specs or the flatter MCP payload aliases used by hosted clients.
```bash ```bash
# Slide deck generation (technical proposal) # Slide deck generation (technical proposal)
python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposal.pptx python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposal.pptx
@@ -377,6 +417,9 @@ python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case
# § "Standard diagram-generation procedure (MANDATORY)" for the full # § "Standard diagram-generation procedure (MANDATORY)" for the full
# workflow: ref-arch lookup → pre-generation review → spec authoring # workflow: ref-arch lookup → pre-generation review → spec authoring
# → automatic spec validator → render → visual verification. # → automatic spec validator → render → visual verification.
# Supports OCI fallback aliases for services whose official toolkit icons
# are still missing (for example PostgreSQL/Redis) plus `brand_icon` for
# third-party systems such as Slack and Jira in both drawio and PPTX.
python tools/archcenter_pattern_lookup.py "<topology keywords>" # 1. find canonical Oracle reference python tools/archcenter_pattern_lookup.py "<topology keywords>" # 1. find canonical Oracle reference
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio # 2. render (validators run automatically) python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio # 2. render (validators run automatically)
python tools/oci_pptx_render.py --pptx arch.pptx --output arch.png --width 1600 # 3. rasterize PPTX for visual review python tools/oci_pptx_render.py --pptx arch.pptx --output arch.png --width 1600 # 3. rasterize PPTX for visual review
@@ -389,11 +432,17 @@ python tools/drawio_visual_validator.py arch.drawio
# Output orchestrator (multiple formats at once) # Output orchestrator (multiple formats at once)
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/ python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
python tools/oci_output.py --spec examples/exacs-bom-spec.yaml --format bom --output-dir output/
# BOM / AppCA workbook
python tools/oci_bom_gen.py --spec examples/exacs-bom-spec.yaml --output customer-bom.xlsx
python tools/oci_bom_gen.py --spec examples/exacs-bom-spec.yaml --output customer-appca.xlsx --appca
# Architecture Center catalog # Architecture Center catalog
python tools/refresh_arch_catalog.py --validate python tools/refresh_arch_catalog.py --validate
python tools/refresh_arch_catalog.py --whats-new python tools/refresh_arch_catalog.py --whats-new
python tools/refresh_arch_catalog.py --check-links python tools/refresh_arch_catalog.py --check-links
python tools/archcenter_pattern_lookup.py "adb gcp fastconnect" --rebuild-index
# Feature compatibility # Feature compatibility
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai

View File

@@ -117,6 +117,15 @@ Present these options as a compact numbered list. Each option has a bold title f
Pick a number, or just describe what you need. Pick a number, or just describe what you need.
``` ```
### Example Direct Requests
If the user asks for examples, or skips the menu and writes a natural-language request, prompts like these map directly to the capabilities above:
- `Generame un diagrama nativo en .pptx, simple y prolijo, de esta arquitectura: usuarios externos -> load balancer público -> 2 VMs de aplicación en subnet privada -> Autonomous Database Serverless. Quiero que quede presentable para mostrar directo en PowerPoint. No me pidas YAML ni setup adicional.`
- `Generate a simple high-level integration diagram for this flow: Slack -> Oracle Integration Cloud (OIC) -> Jira Service Management -> My Oracle Support. Add clear Client, Oracle, and External layers. Use brand icons for Slack and Jira Service Management, and deliver both .drawio and .pptx.`
- `Generate a high-level architecture diagram for this scenario: 1 PostgreSQL in OCI Ashburn, accessed from GCP Virginia through interconnect. PostgreSQL connects internally to 1 Autonomous Database Serverless, which also has 1 refreshable clone in the same region but in a different AD. Deliver both .drawio and .pptx. Keep it simple, executive-friendly, and technically correct.`
- `Gere um BOM em USD para este cenário: PostgreSQL no OCI com 4 OCPU e 500 GB, ADB-S com 200 ECPU e 1 TB BYOL, 1 refreshable clone com o mesmo sizing e FastConnect de 1 Gbps. Aplique 11% de desconto, 12 meses, 24 horas por dia. Quero o arquivo .xlsx e um resumo claro do custo mensal e anual.`
### Behavior Rules ### Behavior Rules
- If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)." - If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)."

View File

@@ -0,0 +1,685 @@
#!/usr/bin/env python3
"""
Build a short PowerPoint deck comparing multiple OCI BOM scenarios.
The input spec references one or more BOM YAML specs. The script resolves
monthly and 12-month totals from the live SKU catalog and produces a
customer-ready Oracle FY26 deck with native PowerPoint charts.
"""
import argparse
import sys
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
import yaml
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE, XL_LABEL_POSITION, XL_LEGEND_POSITION
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
sys.path.insert(0, str(Path(__file__).resolve().parent))
try:
from oci_pptx_base import Colors, Layouts, OraclePresBase
except ModuleNotFoundError:
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CATALOG_PATH = PROJECT_ROOT / "kb" / "pricing" / "oci-sku-catalog.yaml"
DB_SERVER_SKU = "B110627"
STORAGE_SERVER_SKU = "B110629"
def _round_money(value: Decimal) -> Decimal:
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _money_text(value: Decimal) -> str:
return f"USD {value:,.2f}"
def _pct_text(value: Decimal) -> str:
sign = "+" if value > 0 else ""
return f"{sign}{value:.1f}%"
def _pct_abs_text(value: Decimal) -> str:
return f"{abs(value):.1f}%"
def _resolve_path(path_str: str) -> Path:
path = Path(path_str)
if path.is_absolute():
return path
return PROJECT_ROOT / path
def _load_catalog(path: Path) -> dict[str, dict]:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
catalog: dict[str, dict] = {}
for category in raw.get("categories", {}).values():
for sku in category.get("skus", []):
catalog[str(sku["sku"])] = sku
return catalog
@dataclass
class ScenarioMetrics:
scenario_id: str
short_label: str
title: str
topology: str
tradeoff: str
accent: str
term_months: int
db_servers: int
storage_servers: int
ecpus: int
monthly_total: Decimal
term_total: Decimal
infra_monthly: Decimal
compute_monthly: Decimal
delta_vs_baseline: Decimal = Decimal("0")
delta_pct_vs_baseline: Decimal = Decimal("0")
def _scenario_color(accent: str) -> RGBColor:
return {
"recommended": Colors.COPPER,
"balanced": Colors.TEAL,
"savings": Colors.FOREST,
}.get(accent, Colors.TEAL)
def _load_bom_metrics(spec_path: Path, meta: dict, catalog: dict[str, dict]) -> ScenarioMetrics:
raw = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
bom = raw.get("bom", raw)
items = bom.get("line_items", [])
monthly_total = Decimal("0")
term_total = Decimal("0")
infra_monthly = Decimal("0")
compute_monthly = Decimal("0")
db_servers = Decimal("0")
storage_servers = Decimal("0")
ecpus = Decimal("0")
months_seen: set[int] = set()
for item in items:
sku = str(item["sku"])
entry = catalog.get(sku)
if not entry:
raise KeyError(f"SKU {sku} not found in catalog while processing {spec_path}")
price = Decimal(str(entry.get("list_price_usd", 0)))
qty = Decimal(str(item.get("qty", 0)))
hours_units = Decimal(str(item.get("hours_units", entry.get("default_hours_units", 730))))
months = int(item.get("months", 12))
discount = Decimal(str(item.get("discount", 0)))
line_monthly = price * qty * hours_units * (Decimal("1") - discount)
line_term = line_monthly * Decimal(str(months))
monthly_total += line_monthly
term_total += line_term
months_seen.add(months)
if sku == DB_SERVER_SKU:
db_servers += qty
infra_monthly += line_monthly
elif sku == STORAGE_SERVER_SKU:
storage_servers += qty
infra_monthly += line_monthly
elif "ECPU" in str(entry.get("metric", "")).upper():
ecpus += qty
compute_monthly += line_monthly
else:
compute_monthly += line_monthly
term_months = max(months_seen) if months_seen else int(meta.get("term_months", 12))
return ScenarioMetrics(
scenario_id=meta["id"],
short_label=meta.get("short_label", meta["id"]),
title=meta["title"],
topology=meta["topology"],
tradeoff=meta["tradeoff"],
accent=meta.get("accent", "balanced"),
term_months=term_months,
db_servers=int(db_servers),
storage_servers=int(storage_servers),
ecpus=int(ecpus),
monthly_total=_round_money(monthly_total),
term_total=_round_money(term_total),
infra_monthly=_round_money(infra_monthly),
compute_monthly=_round_money(compute_monthly),
)
class BOMComparisonDeck(OraclePresBase):
TITLE_ACCENT_COLOR = Colors.TEAL
MARGIN = Inches(0.55)
def __init__(self, spec: dict, scenarios: list[ScenarioMetrics]):
super().__init__()
self.spec = spec
self.deck_meta = spec["deck"]
self.scenarios = scenarios
self.term_months = int(self.deck_meta.get("term_months", 12))
@property
def _compute_is_constant(self) -> bool:
return len({scenario.compute_monthly for scenario in self.scenarios}) == 1
def _add_footer_note(self, slide, text: str) -> None:
box = self._add_textbox(
slide,
self.MARGIN,
Inches(7.0),
Inches(12.0),
Inches(0.25),
text=text,
font_size=9,
italic=True,
color=Colors.SECONDARY_TEXT,
)
box.text_frame.word_wrap = True
def _add_pill(self, slide, left, top, width, text, fill_color: RGBColor) -> None:
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, Inches(0.3))
shape.fill.solid()
shape.fill.fore_color.rgb = fill_color
shape.line.fill.background()
tf = shape.text_frame
tf.clear()
p = tf.paragraphs[0]
p.text = text
p.alignment = PP_ALIGN.CENTER
p.font.name = self.FONT
p.font.size = Pt(10)
p.font.bold = True
p.font.color.rgb = Colors.WHITE
shape.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
def add_cover(self) -> None:
slide = self._add_layout_slide(Layouts.COVER_DARK)
self._set_placeholder(slide, 0, self.deck_meta["title"])
self._set_placeholder(slide, 1, self.deck_meta["subtitle"])
disclaimer = self.deck_meta.get("commercial_disclaimer", {})
cover_tag = disclaimer.get("cover_tag")
if cover_tag:
self._add_pill(slide, Inches(8.7), Inches(0.45), Inches(3.9), cover_tag, Colors.COPPER)
def add_summary_slide(self) -> None:
slide = self._add_blank_slide()
self._add_title_bar(slide, "Scenario Summary", margin=self.MARGIN)
if self._compute_is_constant:
headline = (
"All three scenarios carry the same 830 ECPU allocation. "
"The cost delta comes from infrastructure footprint only."
)
else:
headline = (
"Recommended setup remains the architecture-led option. "
"Combined 6x4 trims infrastructure. Combined 5x3 is the strongest cost-down option."
)
self._add_textbox(
slide,
self.MARGIN,
Inches(1.2),
Inches(12.0),
Inches(0.5),
text=headline,
font_size=15,
bold=True,
color=Colors.PRIMARY_TEXT,
)
card_lefts = [self.MARGIN, Inches(4.45), Inches(8.35)]
card_width = Inches(3.85)
card_top = Inches(1.9)
card_height = Inches(4.55)
baseline = next(s for s in self.scenarios if s.scenario_id == self.deck_meta["baseline_scenario_id"])
for left, scenario in zip(card_lefts, self.scenarios):
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, card_top, card_width, card_height)
card.fill.solid()
card.fill.fore_color.rgb = Colors.WARM_BG
card.line.color.rgb = _scenario_color(scenario.accent)
card.line.width = Pt(1.5)
accent = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, card_top, card_width, Inches(0.18))
accent.fill.solid()
accent.fill.fore_color.rgb = _scenario_color(scenario.accent)
accent.line.fill.background()
self._add_textbox(
slide, left + Inches(0.18), card_top + Inches(0.28), card_width - Inches(0.36), Inches(0.55),
text=scenario.title, font_size=18, bold=True, color=Colors.PRIMARY_TEXT, font_name=self.FONT_HEADING,
)
self._add_textbox(
slide, left + Inches(0.18), card_top + Inches(0.82), card_width - Inches(0.36), Inches(0.7),
text=scenario.topology, font_size=11, color=Colors.SECONDARY_TEXT,
)
tag_text = "Recommended" if scenario.scenario_id == baseline.scenario_id else (
"Lowest cost" if scenario.monthly_total == min(s.monthly_total for s in self.scenarios) else "Alternative"
)
self._add_pill(slide, left + Inches(0.18), card_top + Inches(1.45), Inches(1.35), tag_text, _scenario_color(scenario.accent))
self._add_textbox(
slide, left + Inches(0.18), card_top + Inches(1.92), card_width - Inches(0.36), Inches(0.32),
text="Monthly", font_size=10, bold=True, color=Colors.SECONDARY_TEXT,
)
self._add_textbox(
slide, left + Inches(0.18), card_top + Inches(2.18), card_width - Inches(0.36), Inches(0.42),
text=_money_text(scenario.monthly_total), font_size=20, bold=True, color=_scenario_color(scenario.accent),
)
self._add_textbox(
slide, left + Inches(0.18), card_top + Inches(2.65), card_width - Inches(0.36), Inches(0.3),
text=f"{self.term_months}M total: {_money_text(scenario.term_total)}", font_size=10, color=Colors.SECONDARY_TEXT,
)
if scenario.scenario_id == baseline.scenario_id:
delta_text = "Baseline"
delta_color = Colors.PRIMARY_TEXT
elif scenario.delta_vs_baseline < 0:
delta_text = (
f"Savings vs {baseline.short_label}: "
f"{_money_text(-scenario.delta_vs_baseline)} / mo "
f"({_pct_abs_text(scenario.delta_pct_vs_baseline)} lower)"
)
delta_color = Colors.FOREST
else:
delta_text = (
f"Premium vs {baseline.short_label}: "
f"{_money_text(scenario.delta_vs_baseline)} / mo "
f"({_pct_text(scenario.delta_pct_vs_baseline)})"
)
delta_color = Colors.PRIMARY_TEXT
self._add_textbox(
slide, left + Inches(0.18), card_top + Inches(3.05), card_width - Inches(0.36), Inches(0.45),
text=delta_text, font_size=10, bold=True,
color=delta_color,
)
footprint = (
f"DB servers: {scenario.db_servers}\n"
f"Storage servers: {scenario.storage_servers}\n"
f"Total ECPU: {scenario.ecpus:,}"
)
tf_box = self._add_textbox(
slide, left + Inches(0.18), card_top + Inches(3.55), card_width - Inches(0.36), Inches(0.72),
text=footprint, font_size=10, color=Colors.PRIMARY_TEXT,
)
tf_box.text_frame.word_wrap = True
self._add_textbox(
slide, left + Inches(0.18), card_top + Inches(4.15), card_width - Inches(0.36), Inches(0.32),
text=scenario.tradeoff, font_size=10, italic=True, color=Colors.SECONDARY_TEXT,
)
self._add_footer_note(slide, "Source footprint: recommended and combined options from the reference deck. Pricing based on OCI list price, 730 hours/month, no commercial discount.")
def add_monthly_chart_slide(self) -> None:
slide = self._add_blank_slide()
self._add_title_bar(slide, "Monthly Cost Comparison", margin=self.MARGIN)
data = CategoryChartData()
data.categories = [s.short_label for s in self.scenarios]
data.add_series("Monthly USD", [float(s.monthly_total) for s in self.scenarios])
frame = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED,
self.MARGIN,
Inches(1.35),
Inches(7.55),
Inches(4.9),
data,
)
chart = frame.chart
chart.has_legend = False
chart.value_axis.has_major_gridlines = True
chart.value_axis.tick_labels.number_format = '$0,"K"'
chart.value_axis.format.line.color.rgb = Colors.SECONDARY_TEXT
chart.category_axis.format.line.color.rgb = Colors.SECONDARY_TEXT
plot = chart.plots[0]
plot.vary_by_categories = True
plot.has_data_labels = True
plot.data_labels.number_format = '$#,##0'
plot.data_labels.position = XL_LABEL_POSITION.OUTSIDE_END
series = chart.series[0]
for point, scenario in zip(series.points, self.scenarios):
point.format.fill.solid()
point.format.fill.fore_color.rgb = _scenario_color(scenario.accent)
point.format.line.color.rgb = _scenario_color(scenario.accent)
callout = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(8.2), Inches(1.45), Inches(4.5), Inches(4.6))
callout.fill.solid()
callout.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
callout.line.color.rgb = Colors.TEAL
callout.line.width = Pt(1.2)
baseline = next(s for s in self.scenarios if s.scenario_id == self.deck_meta["baseline_scenario_id"])
cheapest = min(self.scenarios, key=lambda s: s.monthly_total)
bullets = [
f"{baseline.short_label} is still the architecture-led recommendation.",
f"{self.scenarios[1].short_label} saves {_money_text(-self.scenarios[1].delta_vs_baseline)} per month by reducing storage footprint only.",
f"{cheapest.short_label} saves {_money_text(-cheapest.delta_vs_baseline)} per month and is {_pct_abs_text(cheapest.delta_pct_vs_baseline)} below the baseline.",
(
f"{cheapest.short_label} is cheaper because it cuts both infrastructure and compute footprint."
if not self._compute_is_constant
else f"{cheapest.short_label} is cheaper exclusively because it uses the smallest infrastructure footprint."
),
]
self._add_textbox(
slide, Inches(8.42), Inches(1.7), Inches(3.95), Inches(0.35),
text="Key takeaways", font_size=16, bold=True, color=Colors.PRIMARY_TEXT, font_name=self.FONT_HEADING,
)
notes_box = self._add_textbox(slide, Inches(8.35), Inches(2.15), Inches(3.95), Inches(3.4), text="", font_size=11)
tf = notes_box.text_frame
tf.clear()
for idx, bullet in enumerate(bullets):
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
p.text = f"{bullet}"
p.font.name = self.FONT
p.font.size = Pt(11)
p.font.color.rgb = Colors.PRIMARY_TEXT
p.space_after = Pt(8)
self._add_footer_note(slide, "This chart shows monthly run-rate only. The same ranking holds for the 12-month view because all scenarios use the same 730-hour monthly convention and 12-month term.")
def add_composition_slide(self) -> None:
slide = self._add_blank_slide()
self._add_title_bar(slide, "Cost Composition: Infrastructure vs ECPU", margin=self.MARGIN)
data = CategoryChartData()
data.categories = [s.short_label for s in self.scenarios]
data.add_series("Infrastructure", [float(s.infra_monthly) for s in self.scenarios])
data.add_series("ADB Dedicated ECPU BYOL", [float(s.compute_monthly) for s in self.scenarios])
frame = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_STACKED,
self.MARGIN,
Inches(1.35),
Inches(7.55),
Inches(4.8),
data,
)
chart = frame.chart
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
chart.value_axis.has_major_gridlines = True
chart.value_axis.tick_labels.number_format = '$0,"K"'
series_infra = chart.series[0]
series_infra.format.fill.solid()
series_infra.format.fill.fore_color.rgb = Colors.MUTED_TEAL
series_infra.format.line.color.rgb = Colors.MUTED_TEAL
series_compute = chart.series[1]
series_compute.format.fill.solid()
series_compute.format.fill.fore_color.rgb = Colors.COPPER
series_compute.format.line.color.rgb = Colors.COPPER
table = self._add_table(
slide,
len(self.scenarios) + 1,
4,
Inches(8.2),
Inches(1.6),
Inches(4.4),
Inches(2.4),
)
headers = ["Scenario", "Infra / mo", "ECPU / mo", "Infra share"]
widths = [1.2, 1.05, 1.1, 1.0]
for idx, width in enumerate(widths):
table.columns[idx].width = Inches(width)
for col, header in enumerate(headers):
self._style_table_cell(
table.cell(0, col), header,
font_size=10, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL,
alignment=PP_ALIGN.CENTER if col > 0 else PP_ALIGN.LEFT,
)
for row_idx, scenario in enumerate(self.scenarios, start=1):
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
infra_share = (scenario.infra_monthly / scenario.monthly_total * Decimal("100")) if scenario.monthly_total else Decimal("0")
self._style_table_cell(table.cell(row_idx, 0), scenario.short_label, font_size=10, bold=True, bg_color=bg)
self._style_table_cell(table.cell(row_idx, 1), _money_text(scenario.infra_monthly), font_size=9, bg_color=bg, alignment=PP_ALIGN.RIGHT)
self._style_table_cell(table.cell(row_idx, 2), _money_text(scenario.compute_monthly), font_size=9, bg_color=bg, alignment=PP_ALIGN.RIGHT)
self._style_table_cell(table.cell(row_idx, 3), f"{infra_share:.1f}%", font_size=9, bg_color=bg, alignment=PP_ALIGN.RIGHT)
insight = (
"The ECPU line is flat across all three scenarios: 830 total ECPU.\n"
"Only the DB server and storage server counts move the price."
if self._compute_is_constant
else
"Combined 6x4 changes only the infrastructure layer.\n"
"Combined 5x3 is materially cheaper because it also reduces the ECPU footprint."
)
callout = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(8.2), Inches(4.35), Inches(4.4), Inches(1.15))
callout.fill.solid()
callout.fill.fore_color.rgb = Colors.WARM_BG
callout.line.color.rgb = Colors.COPPER
self._add_textbox(
slide, Inches(8.42), Inches(4.58), Inches(4.0), Inches(0.7),
text=insight, font_size=11, bold=True, color=Colors.PRIMARY_TEXT,
)
self._add_footer_note(slide, "Infrastructure is the sum of X11M DB servers plus X11M storage servers. Compute is the ADB Dedicated BYOL ECPU line.")
def add_detail_table_slide(self) -> None:
slide = self._add_blank_slide()
self._add_title_bar(slide, "Detailed BOM Comparison", margin=self.MARGIN)
table = self._add_table(
slide,
len(self.scenarios) + 1,
8,
self.MARGIN,
Inches(1.4),
Inches(12.2),
Inches(3.1),
)
widths = [1.5, 3.0, 0.9, 1.1, 1.1, 1.35, 1.35, 1.9]
for idx, width in enumerate(widths):
table.columns[idx].width = Inches(width)
headers = ["Scenario", "Topology", "DB", "Storage", "ECPU", "Monthly", f"{self.term_months}M total", "Trade-off"]
for col, header in enumerate(headers):
self._style_table_cell(
table.cell(0, col), header,
font_size=10, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL,
alignment=PP_ALIGN.CENTER if col not in (0, 1, 7) else PP_ALIGN.LEFT,
)
for row_idx, scenario in enumerate(self.scenarios, start=1):
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
if scenario.scenario_id == self.deck_meta["baseline_scenario_id"]:
bg = Colors.TEAL
txt = Colors.WHITE
else:
txt = Colors.PRIMARY_TEXT
values = [
scenario.short_label,
scenario.topology,
str(scenario.db_servers),
str(scenario.storage_servers),
f"{scenario.ecpus:,}",
_money_text(scenario.monthly_total),
_money_text(scenario.term_total),
scenario.tradeoff,
]
aligns = [PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.CENTER, PP_ALIGN.CENTER, PP_ALIGN.RIGHT, PP_ALIGN.RIGHT, PP_ALIGN.RIGHT, PP_ALIGN.LEFT]
for col_idx, (value, align) in enumerate(zip(values, aligns)):
self._style_table_cell(
table.cell(row_idx, col_idx), value,
font_size=9, bold=(scenario.scenario_id == self.deck_meta["baseline_scenario_id"]),
color=txt, bg_color=bg, alignment=align,
)
deltas_box = self._add_textbox(slide, self.MARGIN, Inches(5.0), Inches(12.0), Inches(1.0), text="", font_size=10)
tf = deltas_box.text_frame
tf.clear()
baseline = next(s for s in self.scenarios if s.scenario_id == self.deck_meta["baseline_scenario_id"])
summary_lines = [
f"Baseline: {baseline.short_label} at {_money_text(baseline.monthly_total)} per month.",
f"{self.scenarios[1].short_label}: {_money_text(-self.scenarios[1].delta_vs_baseline)} per month lower than baseline.",
f"{self.scenarios[2].short_label}: {_money_text(-self.scenarios[2].delta_vs_baseline)} per month lower than baseline.",
]
for idx, line in enumerate(summary_lines):
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
p.text = line
p.font.name = self.FONT
p.font.size = Pt(10)
p.font.color.rgb = Colors.SECONDARY_TEXT
p.space_after = Pt(5)
self._add_footer_note(slide, "BOM workbook references are saved alongside this deck for traceability and recalculation.")
def add_disclaimer_slide(self) -> None:
disclaimer = self.deck_meta.get("commercial_disclaimer", {})
if not disclaimer:
return
slide = self._add_blank_slide()
self._add_title_bar(slide, disclaimer.get("title", "Commercial Disclaimer"), margin=self.MARGIN)
intro_box = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE,
self.MARGIN,
Inches(1.35),
Inches(12.1),
Inches(2.15),
)
intro_box.fill.solid()
intro_box.fill.fore_color.rgb = Colors.WARM_BG
intro_box.line.color.rgb = Colors.COPPER
intro_box.line.width = Pt(1.2)
formal = disclaimer.get("formal_text", "")
formal_tb = self._add_textbox(
slide,
Inches(0.8),
Inches(1.58),
Inches(11.55),
Inches(1.7),
text="",
font_size=11,
color=Colors.PRIMARY_TEXT,
)
formal_tf = formal_tb.text_frame
formal_tf.clear()
p = formal_tf.paragraphs[0]
p.text = formal
p.font.name = self.FONT
p.font.size = Pt(11)
p.font.color.rgb = Colors.PRIMARY_TEXT
p.alignment = PP_ALIGN.LEFT
p.space_after = Pt(0)
formal_tf.word_wrap = True
note_box = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE,
self.MARGIN,
Inches(3.95),
Inches(12.1),
Inches(2.15),
)
note_box.fill.solid()
note_box.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
note_box.line.color.rgb = Colors.TEAL
note_box.line.width = Pt(1.2)
self._add_textbox(
slide,
Inches(0.8),
Inches(4.18),
Inches(3.2),
Inches(0.35),
text="Practical notes",
font_size=16,
bold=True,
color=Colors.PRIMARY_TEXT,
font_name=self.FONT_HEADING,
)
bullets_tb = self._add_textbox(
slide,
Inches(0.8),
Inches(4.55),
Inches(11.2),
Inches(1.25),
text="",
font_size=11,
color=Colors.PRIMARY_TEXT,
)
bullets_tf = bullets_tb.text_frame
bullets_tf.clear()
for idx, bullet in enumerate(disclaimer.get("bullets", [])):
p = bullets_tf.paragraphs[0] if idx == 0 else bullets_tf.add_paragraph()
p.text = f"{bullet}"
p.font.name = self.FONT
p.font.size = Pt(11)
p.font.color.rgb = Colors.PRIMARY_TEXT
p.space_after = Pt(10)
self._add_footer_note(
slide,
"Reference pricing only. Formal commercials must move through the Oracle quoting process and the applicable agreement path.",
)
def build(self) -> None:
self.add_cover()
self.add_summary_slide()
self.add_monthly_chart_slide()
self.add_composition_slide()
self.add_detail_table_slide()
self.add_disclaimer_slide()
def load_deck_inputs(spec_path: Path) -> tuple[dict, list[ScenarioMetrics]]:
spec = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
deck = spec["deck"]
catalog = _load_catalog(CATALOG_PATH)
scenarios = []
for scenario_meta in deck.get("scenarios", []):
bom_spec = _resolve_path(scenario_meta["bom_spec"])
scenarios.append(_load_bom_metrics(bom_spec, scenario_meta, catalog))
baseline_id = deck["baseline_scenario_id"]
baseline = next(s for s in scenarios if s.scenario_id == baseline_id)
for scenario in scenarios:
scenario.delta_vs_baseline = _round_money(scenario.monthly_total - baseline.monthly_total)
if baseline.monthly_total:
pct = (scenario.monthly_total - baseline.monthly_total) / baseline.monthly_total * Decimal("100")
scenario.delta_pct_vs_baseline = Decimal(str(pct)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
return spec, scenarios
def main() -> int:
parser = argparse.ArgumentParser(description="Build a comparison deck from multiple BOM specs.")
parser.add_argument("--spec", required=True, help="YAML deck spec path")
parser.add_argument("--output", required=True, help="Output .pptx path")
args = parser.parse_args()
spec_path = _resolve_path(args.spec)
output_path = _resolve_path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
spec, scenarios = load_deck_inputs(spec_path)
deck = BOMComparisonDeck(spec, scenarios)
deck.build()
deck.save(str(output_path))
print(f"Deck saved: {output_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())