Compare commits
4 Commits
ee15e1f45e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4552ef7226 | ||
|
|
75203e7196 | ||
|
|
c22ba64bdb | ||
|
|
7ea17b2f9e |
@@ -86,9 +86,9 @@ Present these options as a compact numbered list. Each option has a bold title f
|
||||
DESIGN & PROPOSE
|
||||
─────────────────
|
||||
1. 📋 Full proposal — *notes → architecture + deck + native PPTX diagram + costs*
|
||||
2. 📐 Architecture diagram — *description → .drawio or native PPTX*
|
||||
2. 📐 Architecture diagram — *description/sketch → .excalidraw → .drawio or native PPTX*
|
||||
3. 📊 Slide deck — *architecture → .pptx with native OCI diagram*
|
||||
4. 💰 Cost estimate — *services + sizing → PAYG vs BYOL*
|
||||
4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
|
||||
|
||||
VALIDATE & CHECK
|
||||
─────────────────
|
||||
@@ -112,13 +112,23 @@ Present these options as a compact numbered list. Each option has a bold title f
|
||||
|
||||
SA TOOLS
|
||||
─────────────────
|
||||
13. 📦 BOM generator — *services + quantities → .xlsx Bill of Materials*
|
||||
13. 📦 BOM generator — *formal .xlsx artefact you send to the customer*
|
||||
14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA*
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
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.`
|
||||
- `Sketch this architecture first for Excalidraw: external users -> public load balancer -> private app VMs -> Autonomous Database. Give me a native .excalidraw file so I can review and collaborate before making the formal diagram.`
|
||||
- `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
|
||||
|
||||
- If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)."
|
||||
@@ -127,15 +137,29 @@ Pick a number, or just describe what you need.
|
||||
```
|
||||
1. Describe the architecture you want to diagram (or paste a YAML spec if you have one).
|
||||
2. Which output format(s) do you want? Pick one or more:
|
||||
(a) .drawio — editable technical diagram
|
||||
(b) .pptx — native Oracle-style PowerPoint diagram/slide
|
||||
(c) both
|
||||
(a) sketch — native .excalidraw whiteboard draft for brainstorming
|
||||
(b) .drawio — editable technical diagram
|
||||
(c) .pptx — native Oracle-style PowerPoint diagram/slide
|
||||
(d) sketch + .drawio
|
||||
(e) sketch + .pptx
|
||||
(f) all
|
||||
```
|
||||
|
||||
After the user answers, **follow these steps in order — do NOT skip step 1**:
|
||||
If the user chooses **sketch only**, create a concise sketch spec and run `python tools/oci_excalidraw_gen.py --spec <sketch-spec.yaml> --output <output-dir>/architecture-sketch.excalidraw`. Save the generated `.excalidraw` file and companion `.md` instructions in the engagement output folder. Present the file list and the simple import/share workflow from the companion `.md` as the primary deliverable. Optionally save `architecture-sketch.mmd` as a secondary source when Mermaid is useful, but the native `.excalidraw` file is the draft artifact. Do not run the formal `absolute_layout` renderer unless the user asks to promote the sketch to `.drawio` or `.pptx`.
|
||||
|
||||
If the user chooses **sketch plus formal output**, produce the `.excalidraw` draft first and ask the user to confirm or adjust the intent before formal rendering. If the user explicitly says to generate all outputs without another checkpoint, generate the sketch and continue. If the user later uploads or references an edited `.excalidraw` from Excalidraw.com, treat it as visual discovery/reference input for the formal `.drawio` / `.pptx` flow; do not blindly convert its rough geometry into the final source of truth.
|
||||
|
||||
If the user provides an existing `.excalidraw` file and asks to convert it to `.drawio`, default to **Formal OCI diagram** unless they explicitly ask for a literal/whiteboard copy:
|
||||
- **Hard rule:** do **not** hand-roll draw.io XML with inline Python for this path. Use the repo tools below. If `python` is not available, use `.venv/bin/python` or `python3`; do not fall back to a custom converter.
|
||||
- **Formal OCI diagram (default)** — run `.venv/bin/python tools/excalidraw_to_oci_diagram.py --input <file.excalidraw> --output <output-dir>/<name>-oci.drawio --spec-output <output-dir>/<name>-oci.yaml`. This extracts the approved sketch intent, writes `*-analysis.yaml` and `*-review.md`, generates an `absolute_layout` YAML, then renders a new Oracle-style `.drawio` through `oci_diagram_gen.py` with OCI containers, icons, connector styles, and validators. For complex sketches (multicloud, hub-spoke, migration/cutover, cross-region DR, multiple regions/VCNs), run the same command with `--mode review` first, inspect/refine the generated analysis/spec, then render.
|
||||
- **Literal sketch conversion (secondary)** — run `.venv/bin/python tools/excalidraw_to_drawio.py --input <file.excalidraw> --output <output-dir>/<name>-literal.drawio` only when the user asks to preserve the exact whiteboard look. This is an editable bridge artifact and does **not** infer OCI icons/services.
|
||||
- **Both** — produce the formal OCI `.drawio` first, then the literal bridge if useful for comparison.
|
||||
|
||||
For any `.drawio` or `.pptx` output, **follow these steps in order — do NOT skip step 1**:
|
||||
1. **Reference-architecture lookup.** Run `python tools/archcenter_pattern_lookup.py "<topology keywords>"` against `kb/architecture-center/catalog.yaml` (123 Oracle-curated entries with cached `.drawio` / `_description.md` / `_template.yaml` under `kb/diagram/assets/archcenter-refs/`). Pick the highest-scoring entry whose topology matches.
|
||||
- **The cached `_template.yaml` is your YAML SCAFFOLD** — auto-extracted from the canonical `.drawio` by `tools/archcenter_drawio_to_template.py`. It carries Oracle's container geometry (region/vcn/ad/subnet bboxes), service positions, and edge waypoints in the `absolute_layout:` shape your spec uses. Copy it as the starting point, rename ids, fill in `type:` for services (the extractor leaves `type: TODO_identify` because the canonical icon TYPE is encoded in stencil bytes — pick the right alias from the renderer's TYPE_TO_ICON), and adapt to the customer's components. Lookup result surfaces it as `cached: yaml=<path>`.
|
||||
- **The cached `.drawio` is the visual source of truth** if the template extractor missed something — open it (drawio.exe, the SVG companion, or the XML) for the canonical look.
|
||||
- **Migration/cutover + multicloud templates.** Also check `kb/diagram/templates/catalog.yaml`. Use `renderable_templates` when the user asks for an operational cutover/runbook view (for example ADB-S → ADB-D with OGG forward/reverse replication) instead of a canonical steady-state Architecture Center topology.
|
||||
- **Do NOT use `examples/` as a geometry source.** `examples/` is previous user output, not authoritative. The `_template.yaml` covers everything `examples/` was being abused for.
|
||||
- **Lookup budget: max 2 queries — never loop.** If query 1 returns nothing convincing, run ONE refinement (broader phrasing or different keyword angle), then STOP. Re-querying the catalog 3+ times to find a "better" match is the dominant time-sink in this phase — the catalog has 123 entries indexed once; if two passes can't surface a topology match, none exists. Output of the lookup tool also surfaces a **`⚠ KNOWN GAP`** banner at the top whenever the query touches a documented gap (e.g. OCI-native services accessed FROM GCP via Cross-Cloud Interconnect, or OCI Cache/Redis/PostgreSQL as primary subject). When that banner appears, follow its **Recommended composition** verbatim instead of running more queries — it lists the primitives to combine. When NO gap banner appears AND no top-3 entry matches the topology, present the closest 3 to the user with: *"No exact ref-arch matches. Closest hits are X / Y / Z. I can either base the diagram on the closest of these (geometry transfers, service names don't), or compose from `<list specific slugs / patterns>`. Which?"* — let the user pick the chassis instead of guessing.
|
||||
2. **Pre-generation review.** Confirm the component list with the user (REQUESTED + TECHNICAL DEPENDENCIES per the whitelist).
|
||||
@@ -150,7 +174,16 @@ Pick a number, or just describe what you need.
|
||||
|
||||
Full reference: `docs/skill/output-formats.md` § "Standard diagram-generation procedure (MANDATORY)".
|
||||
- If the user picks **3**, ask: "Describe the architecture or paste the spec. I'll generate the deck with a native OCI PowerPoint diagram when the architecture is structured enough."
|
||||
- If the user picks **4**, ask: "What services and sizing? (e.g., 'ADB-S 8 OCPU + 2 VMs + FastConnect')"
|
||||
- If the user picks **4**, ask: "What services and sizing? (e.g., 'ADB-S 8 OCPU + 2 VMs + FastConnect'). License model — PAYG, BYOL, or both?"
|
||||
Then follow the cost-estimate flow:
|
||||
1. **Catalog first, web never.** `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) (auto-refreshed, authoritative) for the requested SKUs and consult [`docs/bom-cookbook.md`](docs/bom-cookbook.md) for copy-paste recipes (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block, FastConnect, etc.). Use `kb/pricing/compute.yaml` for shape-level lookups. Do NOT reach for WebSearch / WebFetch / `oracle.com` for list prices — the catalog IS the source.
|
||||
2. **Staleness gate.** Read `last_verified` at the top of the catalog. If >60 days stale, warn the user and offer `python tools/refresh_sku_catalog.py --refresh` before quoting. If <=60 days, proceed without web lookups.
|
||||
3. **Catalog miss policy.** If a SKU is genuinely absent after `grep` (not "I didn't find it on first try"), state it explicitly, then run `python tools/refresh_sku_catalog.py --refresh` and re-grep. Only if a fresh catalog still doesn't surface it is `oracle.com/cloud/price-list/` an acceptable fallback — and cite the source URL next to the part number.
|
||||
4. **Source extraction.** When the request references a customer artefact (`.pptx`, BOM, quote), extract the topology / sizing from the artefact first, list each scenario's components, then map to SKUs. Show the mapping (component → SKU) so the SA can audit it.
|
||||
5. **Quote shape.** Per scenario list: SKU + product name + metric + list price USD + quantity + monthly subtotal. End with total $/month and total $/year. Cite the catalog's `last_verified` date in the output.
|
||||
6. When both PAYG and BYOL are requested, present them side-by-side (same SKU rows, two price columns). Save the cost estimate to `examples/<customer>-cost-estimate.yaml` and present the formatted table in the terminal.
|
||||
7. **Disclaimer (mandatory).** Read the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` and append it verbatim — as a footer line under the terminal table, and (if a deck is generated) as a final "Commercial Disclaimer" slide. Do NOT paraphrase or rewrite; the field already carries Oracle's sample-quote boilerplate plus the "reference only / no client-specific discounts" clauses. `tools/oci_bom_gen.py` already reads this field for BOM xlsx output.
|
||||
8. **Deck verification (if a deck is generated).** Open the produced `.pptx` with `python-pptx` and assert: (a) slide count matches spec, (b) each slide's expected title is present, (c) chart types match what the spec asked for (e.g. `COLUMN_CLUSTERED`, `COLUMN_STACKED`), (d) the disclaimer slide is the last one. Print a one-line `deck OK: <N> slides, titles=[...]` summary so the SA can audit at a glance. Rasterizing to PNG via PowerPoint is not always available on Linux/WSL — structural validation is the floor, not optional.
|
||||
- If the user picks **5**, ask: "Describe your architecture or paste the spec. I'll run the 5-pillar review." Then follow the WA review flow:
|
||||
1. Parse input to build a workload profile YAML (flags) and architecture YAML
|
||||
2. If input is a `.pptx` file, extract text content from all slides to infer architecture and workload context
|
||||
@@ -169,13 +202,22 @@ Pick a number, or just describe what you need.
|
||||
1. Parse input into `templates/business-case.yaml` structure
|
||||
2. Identify business drivers and urgency from discovery notes
|
||||
3. Estimate TCO comparison using `kb/pricing/oci-sku-catalog.yaml` (live SKU prices), `kb/pricing/compute.yaml` (shape-level), `kb/field-knowledge/pricing-knowledge.yaml` (billing models, BYOL rules, free tiers), and `kb/sizing/*`
|
||||
4. Calculate ROI and payback period
|
||||
5. Map value drivers (cost, risk, agility, innovation) with quantified evidence
|
||||
6. Assess migration risks from `kb/field-knowledge/gotchas.yaml` and do-nothing risks
|
||||
7. Compare with alternatives using `kb/competitive/*`
|
||||
8. Generate implementation roadmap based on engagement tier
|
||||
9. Produce a 8-10 slide deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), using a native OCI PowerPoint diagram when the architecture is structured enough
|
||||
10. Output: business-case.pptx + business-case.yaml (reusable spec)
|
||||
4. For ADB-S to ADB-D cases, use the reusable `adbs_to_adbd` model:
|
||||
- keep workload/billable ECPU demand separate from dedicated physical capacity (`DB nodes × ECPU per DB node`)
|
||||
- do not assume ADB-D uses 100% physical capacity; show utilization explicitly
|
||||
- keep workload demand comparable between ADB-S and ADB-D unless customer data says otherwise
|
||||
- model ADB-D fixed DB server + storage server infrastructure separately from workload ECPU
|
||||
- keep ADB-S as-is components explicit: primaries, local ADG, cross-region standby, refreshable/read clones, read clones, GoldenGate
|
||||
5. Separate financial views: current/as-is run-rate, target/to-be run-rate, forecasted TCO by horizon, and scenario comparison when applicable. Avoid ambiguous labels such as `OCI Annual`; use scenario labels like `ADB-S As-Is`, `ADB-D Dedicated`, `Incremental Investment`, and `TCO Crossover`.
|
||||
6. Generate companion BOMs for current as-is, to-be steady state, and 24M/36M projected as-is/to-be annual run-rate snapshots. Projected BOMs are not cumulative multi-year totals.
|
||||
7. Model read architecture explicitly: ADB-S may require refreshable/read clones because standby is not the application read path; ADB-D may use Local ADG read-only standby and retire steady-state clones. Reflect this in BOM notes, value drivers, and risk/value slides.
|
||||
8. Add storage economics: ADB-S per-GB storage, ADB-D fixed footprint, customer-provided base break-even when available, recalculated break-even for the proposed footprint, and visible storage offset.
|
||||
9. Treat GoldenGate as `steady_state`, `migration_bridge_only`, or `migration_plus_fallback_months`. Bridge-only GoldenGate is Year-1 / one-time and is excluded from future steady-state BOMs.
|
||||
10. Add a TCO crossover chart when multi-year forecast exists, defaulting to native PowerPoint grouped bars with executive labels (`Near-term`, `Year 1`, `Year 2`, `Year 3`) and delta bullets.
|
||||
11. For risk-reduction cases, generate a Business Value Model instead of generic ROI percentages. Do not invent revenue impact; convert stability to USD only when the customer provides revenue-at-risk, transaction margin, fraud loss impact, or cost per degraded/outage hour.
|
||||
12. Assess migration and inaction risks against the selected scenario only. Remove obsolete risks such as `if 4x3 is selected` after the customer has chosen `5x3`.
|
||||
13. Produce an executive deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), plus business-case YAML and generated BOM artefacts where applicable.
|
||||
14. **Disclaimer + deck verification.** Append the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` as a final "Commercial Disclaimer" slide (verbatim — see option 4 step 7). Then run structural PPTX validation that reads text boxes, table cells, and grouped shapes; check slide count/titles, disclaimer-last, required assumptions, and forbidden obsolete phrases before reporting completion.
|
||||
- If the user picks **9**, ask: "What topic? (e.g., 'DEP', 'TAC', 'maintenance window', 'vector search')"
|
||||
- If the user picks **10**, ask: "What kind of architecture? (e.g., 'ADB + APEX', 'cross-region DR', 'data lakehouse')"
|
||||
- If the user picks **11**, switch to finding intake mode:
|
||||
@@ -221,7 +263,7 @@ Pick a number, or just describe what you need.
|
||||
|
||||
**BOM Output Rules:**
|
||||
- NEVER include "Confidential: Internal ONLY" or any confidentiality marking
|
||||
- ALWAYS include the Oracle Cost Estimator disclaimer at the bottom
|
||||
- ALWAYS include the disclaimer from `kb/pricing/oci-sku-catalog.yaml` `disclaimer:` field at the bottom — `tools/oci_bom_gen.py` reads it automatically (do not duplicate, paraphrase, or maintain a second copy elsewhere)
|
||||
- Only include SKUs the customer actually requested — never dump the full catalog
|
||||
- Show cost proportions so the customer can see where their spend concentrates
|
||||
- Use Excel formulas (not static values) so the customer can adjust quantities
|
||||
@@ -244,7 +286,7 @@ After delivering an output, show elapsed time and offer the natural next step.
|
||||
```
|
||||
✅ Done — [task description] completed in [Xm Ys]
|
||||
|
||||
→ [A] Generate additional outputs (drawio / doc / xlsx)
|
||||
→ [A] Generate additional outputs (sketch / drawio / doc / xlsx)
|
||||
→ [B] Modify the architecture (add/remove/change services)
|
||||
→ [C] Run Well-Architected review on this architecture
|
||||
→ [D] Build a business case from this architecture
|
||||
@@ -447,7 +489,7 @@ Full DELIVER guide: [docs/deliver-phase.md](docs/deliver-phase.md)
|
||||
|
||||
Every skill option that produces output MUST generate **readable, human-consumable output** as the primary deliverable. YAML files are structured backing data — they are never the final output shown to the user. Specifically:
|
||||
|
||||
- **Options 1-4, 8:** Primary output is `.pptx` (slide deck) and/or `.drawio` (diagram). YAML specs are saved alongside but never presented as the deliverable.
|
||||
- **Options 1-4, 8:** Primary output is `.pptx` (slide deck), `.drawio` (diagram), and/or native `.excalidraw` sketch when the user requests draft mode. YAML/Mermaid specs are saved alongside when useful but never presented as the final deliverable.
|
||||
- **Option 5 (WA Review):** Primary output is the **formatted terminal scorecard** (banner + pillar bars + gap tables + recommendations). YAML scorecard saved to `examples/` as backing data.
|
||||
- **Option 12 (ECAL Readiness):** Primary output is the **formatted terminal scorecard** (phase scores + artefact checklist + gap analysis). YAML scorecard saved to `examples/` as backing data.
|
||||
- **After any review/score:** When the user picks [C] "Export as slide", generate a 1-2 slide `.pptx` with the scorecard visualization using `tools/oci_deck_gen.py`.
|
||||
@@ -462,9 +504,9 @@ See [docs/skill/output-formats.md](docs/skill/output-formats.md) for the per-cus
|
||||
|
||||
## Cookbook: Building Tool Payloads
|
||||
|
||||
Before mapping customer requirements to SKUs for `generate_bom`, `generate_bom_appca`, or `generate_cost_estimate`, check [docs/bom-cookbook.md](docs/bom-cookbook.md) — it has copy-paste recipes for common patterns (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block + FastConnect) and explicitly names the gotchas that otherwise burn exploration turns (e.g. ADB-Dedicated shares infrastructure SKUs with ExaCS — there are no separate `adb_dedicated` SKUs in the catalog).
|
||||
Any pricing or SKU work — cost estimate (option 4), BOM (option 13), BOM-AppCA (option 14), business-case TCO (option 8), scenario comparison from a customer artefact (`.pptx`, BOM, quote) — starts in the local catalog, not on the web. Check [docs/bom-cookbook.md](docs/bom-cookbook.md) for copy-paste recipes (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block + FastConnect) and the gotchas that otherwise burn exploration turns (e.g. ADB-Dedicated shares infrastructure SKUs with ExaCS — there are no separate `adb_dedicated` SKUs in the catalog).
|
||||
|
||||
If a requirement does not match a recipe, `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) and consult `kb/services/` before inventing SKUs.
|
||||
If a requirement does not match a recipe, `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) and consult `kb/services/` before inventing SKUs. See "What You Do NOT Do" for the web-lookup rule and `kb/pricing/oci-sku-catalog.yaml`'s `last_verified` for staleness handling.
|
||||
|
||||
---
|
||||
|
||||
@@ -572,6 +614,7 @@ KB lives under `kb/`. See [kb/README.md](kb/README.md) for the directory map, fr
|
||||
- You do NOT replace the architect's judgment. You accelerate it.
|
||||
- You do NOT generate pixel-perfect diagrams. You generate 80% drafts the architect refines.
|
||||
- You do NOT make up pricing. If you don't have current pricing, estimate ranges.
|
||||
- You do NOT use WebSearch / WebFetch / `oracle.com` for OCI list prices or SKU lookups. `kb/pricing/oci-sku-catalog.yaml` is auto-refreshed via `tools/refresh_sku_catalog.py` (Oracle apexapps pricing API) and is the source of truth — including for ECPU/OCPU rates, BYOL deltas, Exadata X11M part numbers, and ADB-D ECPU SKUs. Web fallback is allowed ONLY when (a) the SKU is genuinely absent after `grep` AND a fresh `--refresh` still does not surface it, or (b) the catalog's `last_verified` is >60 days stale and a refresh is not currently possible. In either case, cite the source URL alongside the part number. Reflexively reaching for WebSearch when the catalog already has the answer is the most common cost-estimate failure mode — don't.
|
||||
- You do NOT claim features exist if you're unsure. Check the KB first.
|
||||
- You do NOT do detailed project management. DELIVER artifacts are lightweight handover aids.
|
||||
- You do NOT add services or components the user did not request.
|
||||
|
||||
@@ -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/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(.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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -157,6 +157,32 @@ If a task generates YAML without the corresponding readable output, the task is
|
||||
- **Readable outputs** — YAML is backing data, never the deliverable
|
||||
- **No hallucinated architecture** — NEVER add services/components/regions the user didn't mention. When remediating WA gaps, apply minimum targeted fixes to the existing architecture; don't replace it with a generic "ideal" one. ASK before adding new services.
|
||||
|
||||
## Git Remotes (Gitea = source of truth, GitHub = mirror)
|
||||
|
||||
`origin` is configured with **one fetch URL (Gitea) and two push URLs (Gitea + GitHub)**, so a single `git push origin main` updates both. Gitea is authoritative; GitHub is a public mirror.
|
||||
|
||||
```
|
||||
fetch → git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
|
||||
push → git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
|
||||
push → github.com/Diegoecab/oci-deal-accelerator.git
|
||||
```
|
||||
|
||||
**Recreating this on a new clone** (the dual-push lives in `.git/config`, not tracked):
|
||||
|
||||
```bash
|
||||
git remote set-url --add --push origin https://github.com/Diegoecab/oci-deal-accelerator.git
|
||||
git remote set-url --add --push origin https://git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
|
||||
git remote -v # confirm one fetch + two push URLs
|
||||
```
|
||||
|
||||
The first command substitutes the implicit Gitea push URL with GitHub's; the second re-adds Gitea so origin pushes to BOTH. Counter-intuitive but necessary.
|
||||
|
||||
**Agent guidance**:
|
||||
- After creating a commit on `main`, **always propose `git push origin main`** (not separate pushes per remote) — it covers both. Confirm with the user before pushing (pushing is shared-state).
|
||||
- If a push fails on GitHub but succeeds on Gitea (or vice versa), the dual-push is partial. Re-run after fixing the failing one — Gitea's second push will be a no-op fast-forward.
|
||||
- Never force-push Gitea unless explicitly asked; force-pushing GitHub is acceptable when needed because GitHub is downstream.
|
||||
- If history diverges between Gitea and GitHub (e.g. after a rebase), the recovery pattern is: temporarily strip the GitHub URL (`git remote set-url --delete --push origin <github-url>`), push Gitea fast-forward, add `github` as a standalone remote, force-push, then restore the dual-push.
|
||||
|
||||
## Welcome Flow
|
||||
|
||||
When the user starts a conversation without providing discovery notes or a specific request (e.g., a greeting or empty context), present the welcome message and capability menu defined in the skill's SKILL.md § Welcome Flow. Specifically:
|
||||
|
||||
26
CLAUDE.md
26
CLAUDE.md
@@ -159,6 +159,32 @@ python tools/refresh_arch_catalog.py --validate # validate catalog
|
||||
- **KB is the moat** — field experience, not documentation regurgitation
|
||||
- **ECAL-aligned** — Define → Design → Deliver with iterative checkpoints
|
||||
|
||||
## Git Remotes (Gitea = source of truth, GitHub = mirror)
|
||||
|
||||
`origin` is configured with **one fetch URL (Gitea) and two push URLs (Gitea + GitHub)**, so a single `git push origin main` updates both. Gitea is authoritative; GitHub is a public mirror.
|
||||
|
||||
```
|
||||
fetch → git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
|
||||
push → git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
|
||||
push → github.com/Diegoecab/oci-deal-accelerator.git
|
||||
```
|
||||
|
||||
**Recreating this on a new clone** (the dual-push lives in `.git/config`, not tracked):
|
||||
|
||||
```bash
|
||||
git remote set-url --add --push origin https://github.com/Diegoecab/oci-deal-accelerator.git
|
||||
git remote set-url --add --push origin https://git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
|
||||
git remote -v # confirm one fetch + two push URLs
|
||||
```
|
||||
|
||||
The first command substitutes the implicit Gitea push URL with GitHub's; the second re-adds Gitea so origin pushes to BOTH. Counter-intuitive but necessary.
|
||||
|
||||
**Agent guidance**:
|
||||
- After creating a commit on `main`, **always propose `git push origin main`** (not separate pushes per remote) — it covers both. Confirm with the user before pushing (pushing is shared-state).
|
||||
- If a push fails on GitHub but succeeds on Gitea (or vice versa), the dual-push is partial. Re-run after fixing the failing one — Gitea's second push will be a no-op fast-forward.
|
||||
- Never force-push Gitea unless explicitly asked; force-pushing GitHub is acceptable when needed because GitHub is downstream.
|
||||
- If history diverges between Gitea and GitHub (e.g. after a rebase), the recovery pattern is: temporarily strip the GitHub URL (`git remote set-url --delete --push origin <github-url>`), push Gitea fast-forward, add `github` as a standalone remote, force-push, then restore the dual-push.
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### 1. Think Before Coding
|
||||
|
||||
70
README.md
70
README.md
@@ -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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
## 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.
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
@@ -45,6 +48,7 @@ The skill runs as a hosted MCP server. Connect once from your LLM client, sign i
|
||||
|
||||
### Connection URLs
|
||||
|
||||
- **AI Factory Hub:** `https://ai-lad.com/`
|
||||
- **MCP (HTTP streamable):** `https://mcp.tech-lad.com/deal-accelerator/mcp/`
|
||||
- **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 |
|
||||
| **Joint Engagement Plan** — scope, resources, timeline | YAML | Define | Plan |
|
||||
| **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 |
|
||||
| **Customer PDF** — branded, no internal KB references | .pdf | Design | Confirm |
|
||||
| **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 |
|
||||
| **Operations Model** — day-2 monitoring, patching, incident response | YAML | Design | Future |
|
||||
| **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
|
||||
bizcase ← business case deck for customer approval
|
||||
full ← everything (pptx + drawio + docx + xlsx + pdf)
|
||||
bom ← bill of materials (.xlsx)
|
||||
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
|
||||
|
||||
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>/`:
|
||||
- `_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)
|
||||
- `_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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -301,8 +339,11 @@ Option 8 in the menu generates a business case deck for customer internal approv
|
||||
| Cover | Dark - Title_Pillar | Customer name + subtitle |
|
||||
| Executive Summary | Impact Statement | Bold 1-sentence opportunity |
|
||||
| Business Drivers | Multi Statement | 3 key drivers: Why now |
|
||||
| TCO Comparison | Blank + Table | Current vs OCI (3-5 year) |
|
||||
| ROI Headline | Blank + Metric | Big number (e.g., "2080% ROI") |
|
||||
| TCO Comparison | Blank + Table | Current/as-is run-rate vs target/to-be run-rate |
|
||||
| BOM + Operations Breakdown | Blank + Tables | Cloud services, storage/infra, GoldenGate, operations, one-time bridge |
|
||||
| Forecasted TCO | Blank + Table | 24M/36M annual run-rate snapshots, not cumulative totals |
|
||||
| TCO Crossover | Native shapes | ADB-S vs ADB-D annual TCO and first cheaper period |
|
||||
| Business Value Model | Blank + Table | Financial baseline, architecture benefit, risk-adjusted value, KPIs |
|
||||
| Value Drivers | Blank + Cards | 4 categories: Cost, Risk, Agility, Innovation |
|
||||
| Risk Assessment | Blank + 2-Column | Migration risks vs Do-nothing risks |
|
||||
| Roadmap | Blank + Timeline | Implementation phases |
|
||||
@@ -310,6 +351,12 @@ Option 8 in the menu generates a business case deck for customer internal approv
|
||||
|
||||
Uses the **Oracle FY26 official PowerPoint template** with Redwood design system.
|
||||
|
||||
For ADB-S to ADB-D cases, the generator keeps workload ECPU demand separate
|
||||
from ADB-D physical capacity. Dedicated footprint is modeled as fixed DB server
|
||||
and storage server infrastructure; projected BOMs are annual run-rate snapshots
|
||||
at the horizon. Risk-reduction value is converted to USD only when the customer
|
||||
provides business impact per degraded/outage hour.
|
||||
|
||||
```bash
|
||||
python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case.pptx
|
||||
```
|
||||
@@ -362,6 +409,8 @@ If you paste discovery notes directly, the skill skips the menu and goes straigh
|
||||
|
||||
## Tools
|
||||
|
||||
All major generators accept either the canonical nested YAML specs or the flatter MCP payload aliases used by hosted clients.
|
||||
|
||||
```bash
|
||||
# Slide deck generation (technical proposal)
|
||||
python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposal.pptx
|
||||
@@ -377,6 +426,9 @@ python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case
|
||||
# § "Standard diagram-generation procedure (MANDATORY)" for the full
|
||||
# workflow: ref-arch lookup → pre-generation review → spec authoring
|
||||
# → 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/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
|
||||
@@ -389,11 +441,17 @@ python tools/drawio_visual_validator.py arch.drawio
|
||||
|
||||
# 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/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
|
||||
python tools/refresh_arch_catalog.py --validate
|
||||
python tools/refresh_arch_catalog.py --whats-new
|
||||
python tools/refresh_arch_catalog.py --check-links
|
||||
python tools/archcenter_pattern_lookup.py "adb gcp fastconnect" --rebuild-index
|
||||
|
||||
# Feature compatibility
|
||||
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai
|
||||
|
||||
83
SKILL.md
83
SKILL.md
@@ -84,9 +84,9 @@ Present these options as a compact numbered list. Each option has a bold title f
|
||||
DESIGN & PROPOSE
|
||||
─────────────────
|
||||
1. 📋 Full proposal — *notes → architecture + deck + native PPTX diagram + costs*
|
||||
2. 📐 Architecture diagram — *description → .drawio or native PPTX*
|
||||
2. 📐 Architecture diagram — *description/sketch → .excalidraw → .drawio or native PPTX*
|
||||
3. 📊 Slide deck — *architecture → .pptx with native OCI diagram*
|
||||
4. 💰 Cost estimate — *services + sizing → PAYG vs BYOL*
|
||||
4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
|
||||
|
||||
VALIDATE & CHECK
|
||||
─────────────────
|
||||
@@ -110,13 +110,23 @@ Present these options as a compact numbered list. Each option has a bold title f
|
||||
|
||||
SA TOOLS
|
||||
─────────────────
|
||||
13. 📦 BOM generator — *services + quantities → .xlsx Bill of Materials*
|
||||
13. 📦 BOM generator — *formal .xlsx artefact you send to the customer*
|
||||
14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA*
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
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.`
|
||||
- `Sketch this architecture first for Excalidraw: external users -> public load balancer -> private app VMs -> Autonomous Database. Give me a native .excalidraw file so I can review and collaborate before making the formal diagram.`
|
||||
- `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
|
||||
|
||||
- If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)."
|
||||
@@ -125,15 +135,29 @@ Pick a number, or just describe what you need.
|
||||
```
|
||||
1. Describe the architecture you want to diagram (or paste a YAML spec if you have one).
|
||||
2. Which output format(s) do you want? Pick one or more:
|
||||
(a) .drawio — editable technical diagram
|
||||
(b) .pptx — native Oracle-style PowerPoint diagram/slide
|
||||
(c) both
|
||||
(a) sketch — native .excalidraw whiteboard draft for brainstorming
|
||||
(b) .drawio — editable technical diagram
|
||||
(c) .pptx — native Oracle-style PowerPoint diagram/slide
|
||||
(d) sketch + .drawio
|
||||
(e) sketch + .pptx
|
||||
(f) all
|
||||
```
|
||||
|
||||
After the user answers, **follow these steps in order — do NOT skip step 1**:
|
||||
If the user chooses **sketch only**, create a concise sketch spec and run `python tools/oci_excalidraw_gen.py --spec <sketch-spec.yaml> --output <output-dir>/architecture-sketch.excalidraw`. Save the generated `.excalidraw` file and companion `.md` instructions in the engagement output folder. Present the file list and the simple import/share workflow from the companion `.md` as the primary deliverable. Optionally save `architecture-sketch.mmd` as a secondary source when Mermaid is useful, but the native `.excalidraw` file is the draft artifact. Do not run the formal `absolute_layout` renderer unless the user asks to promote the sketch to `.drawio` or `.pptx`.
|
||||
|
||||
If the user chooses **sketch plus formal output**, produce the `.excalidraw` draft first and ask the user to confirm or adjust the intent before formal rendering. If the user explicitly says to generate all outputs without another checkpoint, generate the sketch and continue. If the user later uploads or references an edited `.excalidraw` from Excalidraw.com, treat it as visual discovery/reference input for the formal `.drawio` / `.pptx` flow; do not blindly convert its rough geometry into the final source of truth.
|
||||
|
||||
If the user provides an existing `.excalidraw` file and asks to convert it to `.drawio`, default to **Formal OCI diagram** unless they explicitly ask for a literal/whiteboard copy:
|
||||
- **Hard rule:** do **not** hand-roll draw.io XML with inline Python for this path. Use the repo tools below. If `python` is not available, use `.venv/bin/python` or `python3`; do not fall back to a custom converter.
|
||||
- **Formal OCI diagram (default)** — run `.venv/bin/python tools/excalidraw_to_oci_diagram.py --input <file.excalidraw> --output <output-dir>/<name>-oci.drawio --spec-output <output-dir>/<name>-oci.yaml`. This extracts the approved sketch intent, writes `*-analysis.yaml` and `*-review.md`, generates an `absolute_layout` YAML, then renders a new Oracle-style `.drawio` through `oci_diagram_gen.py` with OCI containers, icons, connector styles, and validators. For complex sketches (multicloud, hub-spoke, migration/cutover, cross-region DR, multiple regions/VCNs), run the same command with `--mode review` first, inspect/refine the generated analysis/spec, then render.
|
||||
- **Literal sketch conversion (secondary)** — run `.venv/bin/python tools/excalidraw_to_drawio.py --input <file.excalidraw> --output <output-dir>/<name>-literal.drawio` only when the user asks to preserve the exact whiteboard look. This is an editable bridge artifact and does **not** infer OCI icons/services.
|
||||
- **Both** — produce the formal OCI `.drawio` first, then the literal bridge if useful for comparison.
|
||||
|
||||
For any `.drawio` or `.pptx` output, **follow these steps in order — do NOT skip step 1**:
|
||||
1. **Reference-architecture lookup.** Run `python tools/archcenter_pattern_lookup.py "<topology keywords>"` against `kb/architecture-center/catalog.yaml` (123 Oracle-curated entries with cached `.drawio` / `_description.md` / `_template.yaml` under `kb/diagram/assets/archcenter-refs/`). Pick the highest-scoring entry whose topology matches.
|
||||
- **The cached `_template.yaml` is your YAML SCAFFOLD** — auto-extracted from the canonical `.drawio` by `tools/archcenter_drawio_to_template.py`. It carries Oracle's container geometry (region/vcn/ad/subnet bboxes), service positions, and edge waypoints in the `absolute_layout:` shape your spec uses. Copy it as the starting point, rename ids, fill in `type:` for services (the extractor leaves `type: TODO_identify` because the canonical icon TYPE is encoded in stencil bytes — pick the right alias from the renderer's TYPE_TO_ICON), and adapt to the customer's components. Lookup result surfaces it as `cached: yaml=<path>`.
|
||||
- **The cached `.drawio` is the visual source of truth** if the template extractor missed something — open it (drawio.exe, the SVG companion, or the XML) for the canonical look.
|
||||
- **Migration/cutover + multicloud templates.** Also check `kb/diagram/templates/catalog.yaml`. Use `renderable_templates` when the user asks for an operational cutover/runbook view (for example ADB-S → ADB-D with OGG forward/reverse replication) instead of a canonical steady-state Architecture Center topology.
|
||||
- **Do NOT use `examples/` as a geometry source.** `examples/` is previous user output, not authoritative. The `_template.yaml` covers everything `examples/` was being abused for.
|
||||
- **Lookup budget: max 2 queries — never loop.** If query 1 returns nothing convincing, run ONE refinement (broader phrasing or different keyword angle), then STOP. Re-querying the catalog 3+ times to find a "better" match is the dominant time-sink in this phase — the catalog has 123 entries indexed once; if two passes can't surface a topology match, none exists. Output of the lookup tool also surfaces a **`⚠ KNOWN GAP`** banner at the top whenever the query touches a documented gap (e.g. OCI-native services accessed FROM GCP via Cross-Cloud Interconnect, or OCI Cache/Redis/PostgreSQL as primary subject). When that banner appears, follow its **Recommended composition** verbatim instead of running more queries — it lists the primitives to combine. When NO gap banner appears AND no top-3 entry matches the topology, present the closest 3 to the user with: *"No exact ref-arch matches. Closest hits are X / Y / Z. I can either base the diagram on the closest of these (geometry transfers, service names don't), or compose from `<list specific slugs / patterns>`. Which?"* — let the user pick the chassis instead of guessing.
|
||||
2. **Pre-generation review.** Confirm the component list with the user (REQUESTED + TECHNICAL DEPENDENCIES per the whitelist).
|
||||
@@ -148,7 +172,16 @@ Pick a number, or just describe what you need.
|
||||
|
||||
Full reference: `docs/skill/output-formats.md` § "Standard diagram-generation procedure (MANDATORY)".
|
||||
- If the user picks **3**, ask: "Describe the architecture or paste the spec. I'll generate the deck with a native OCI PowerPoint diagram when the architecture is structured enough."
|
||||
- If the user picks **4**, ask: "What services and sizing? (e.g., 'ADB-S 8 OCPU + 2 VMs + FastConnect')"
|
||||
- If the user picks **4**, ask: "What services and sizing? (e.g., 'ADB-S 8 OCPU + 2 VMs + FastConnect'). License model — PAYG, BYOL, or both?"
|
||||
Then follow the cost-estimate flow:
|
||||
1. **Catalog first, web never.** `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) (auto-refreshed, authoritative) for the requested SKUs and consult [`docs/bom-cookbook.md`](docs/bom-cookbook.md) for copy-paste recipes (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block, FastConnect, etc.). Use `kb/pricing/compute.yaml` for shape-level lookups. Do NOT reach for WebSearch / WebFetch / `oracle.com` for list prices — the catalog IS the source.
|
||||
2. **Staleness gate.** Read `last_verified` at the top of the catalog. If >60 days stale, warn the user and offer `python tools/refresh_sku_catalog.py --refresh` before quoting. If <=60 days, proceed without web lookups.
|
||||
3. **Catalog miss policy.** If a SKU is genuinely absent after `grep` (not "I didn't find it on first try"), state it explicitly, then run `python tools/refresh_sku_catalog.py --refresh` and re-grep. Only if a fresh catalog still doesn't surface it is `oracle.com/cloud/price-list/` an acceptable fallback — and cite the source URL next to the part number.
|
||||
4. **Source extraction.** When the request references a customer artefact (`.pptx`, BOM, quote), extract the topology / sizing from the artefact first, list each scenario's components, then map to SKUs. Show the mapping (component → SKU) so the SA can audit it.
|
||||
5. **Quote shape.** Per scenario list: SKU + product name + metric + list price USD + quantity + monthly subtotal. End with total $/month and total $/year. Cite the catalog's `last_verified` date in the output.
|
||||
6. When both PAYG and BYOL are requested, present them side-by-side (same SKU rows, two price columns). Save the cost estimate to `examples/<customer>-cost-estimate.yaml` and present the formatted table in the terminal.
|
||||
7. **Disclaimer (mandatory).** Read the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` and append it verbatim — as a footer line under the terminal table, and (if a deck is generated) as a final "Commercial Disclaimer" slide. Do NOT paraphrase or rewrite; the field already carries Oracle's sample-quote boilerplate plus the "reference only / no client-specific discounts" clauses. `tools/oci_bom_gen.py` already reads this field for BOM xlsx output.
|
||||
8. **Deck verification (if a deck is generated).** Open the produced `.pptx` with `python-pptx` and assert: (a) slide count matches spec, (b) each slide's expected title is present, (c) chart types match what the spec asked for (e.g. `COLUMN_CLUSTERED`, `COLUMN_STACKED`), (d) the disclaimer slide is the last one. Print a one-line `deck OK: <N> slides, titles=[...]` summary so the SA can audit at a glance. Rasterizing to PNG via PowerPoint is not always available on Linux/WSL — structural validation is the floor, not optional.
|
||||
- If the user picks **5**, ask: "Describe your architecture or paste the spec. I'll run the 5-pillar review." Then follow the WA review flow:
|
||||
1. Parse input to build a workload profile YAML (flags) and architecture YAML
|
||||
2. If input is a `.pptx` file, extract text content from all slides to infer architecture and workload context
|
||||
@@ -167,13 +200,22 @@ Pick a number, or just describe what you need.
|
||||
1. Parse input into `templates/business-case.yaml` structure
|
||||
2. Identify business drivers and urgency from discovery notes
|
||||
3. Estimate TCO comparison using `kb/pricing/oci-sku-catalog.yaml` (live SKU prices), `kb/pricing/compute.yaml` (shape-level), `kb/field-knowledge/pricing-knowledge.yaml` (billing models, BYOL rules, free tiers), and `kb/sizing/*`
|
||||
4. Calculate ROI and payback period
|
||||
5. Map value drivers (cost, risk, agility, innovation) with quantified evidence
|
||||
6. Assess migration risks from `kb/field-knowledge/gotchas.yaml` and do-nothing risks
|
||||
7. Compare with alternatives using `kb/competitive/*`
|
||||
8. Generate implementation roadmap based on engagement tier
|
||||
9. Produce a 8-10 slide deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), using a native OCI PowerPoint diagram when the architecture is structured enough
|
||||
10. Output: business-case.pptx + business-case.yaml (reusable spec)
|
||||
4. For ADB-S to ADB-D cases, use the reusable `adbs_to_adbd` model:
|
||||
- keep workload/billable ECPU demand separate from dedicated physical capacity (`DB nodes × ECPU per DB node`)
|
||||
- do not assume ADB-D uses 100% physical capacity; show utilization explicitly
|
||||
- keep workload demand comparable between ADB-S and ADB-D unless customer data says otherwise
|
||||
- model ADB-D fixed DB server + storage server infrastructure separately from workload ECPU
|
||||
- keep ADB-S as-is components explicit: primaries, local ADG, cross-region standby, refreshable/read clones, read clones, GoldenGate
|
||||
5. Separate financial views: current/as-is run-rate, target/to-be run-rate, forecasted TCO by horizon, and scenario comparison when applicable. Avoid ambiguous labels such as `OCI Annual`; use scenario labels like `ADB-S As-Is`, `ADB-D Dedicated`, `Incremental Investment`, and `TCO Crossover`.
|
||||
6. Generate companion BOMs for current as-is, to-be steady state, and 24M/36M projected as-is/to-be annual run-rate snapshots. Projected BOMs are not cumulative multi-year totals.
|
||||
7. Model read architecture explicitly: ADB-S may require refreshable/read clones because standby is not the application read path; ADB-D may use Local ADG read-only standby and retire steady-state clones. Reflect this in BOM notes, value drivers, and risk/value slides.
|
||||
8. Add storage economics: ADB-S per-GB storage, ADB-D fixed footprint, customer-provided base break-even when available, recalculated break-even for the proposed footprint, and visible storage offset.
|
||||
9. Treat GoldenGate as `steady_state`, `migration_bridge_only`, or `migration_plus_fallback_months`. Bridge-only GoldenGate is Year-1 / one-time and is excluded from future steady-state BOMs.
|
||||
10. Add a TCO crossover chart when multi-year forecast exists, defaulting to native PowerPoint grouped bars with executive labels (`Near-term`, `Year 1`, `Year 2`, `Year 3`) and delta bullets.
|
||||
11. For risk-reduction cases, generate a Business Value Model instead of generic ROI percentages. Do not invent revenue impact; convert stability to USD only when the customer provides revenue-at-risk, transaction margin, fraud loss impact, or cost per degraded/outage hour.
|
||||
12. Assess migration and inaction risks against the selected scenario only. Remove obsolete risks such as `if 4x3 is selected` after the customer has chosen `5x3`.
|
||||
13. Produce an executive deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), plus business-case YAML and generated BOM artefacts where applicable.
|
||||
14. **Disclaimer + deck verification.** Append the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` as a final "Commercial Disclaimer" slide (verbatim — see option 4 step 7). Then run structural PPTX validation that reads text boxes, table cells, and grouped shapes; check slide count/titles, disclaimer-last, required assumptions, and forbidden obsolete phrases before reporting completion.
|
||||
- If the user picks **9**, ask: "What topic? (e.g., 'DEP', 'TAC', 'maintenance window', 'vector search')"
|
||||
- If the user picks **10**, ask: "What kind of architecture? (e.g., 'ADB + APEX', 'cross-region DR', 'data lakehouse')"
|
||||
- If the user picks **11**, switch to finding intake mode:
|
||||
@@ -219,7 +261,7 @@ Pick a number, or just describe what you need.
|
||||
|
||||
**BOM Output Rules:**
|
||||
- NEVER include "Confidential: Internal ONLY" or any confidentiality marking
|
||||
- ALWAYS include the Oracle Cost Estimator disclaimer at the bottom
|
||||
- ALWAYS include the disclaimer from `kb/pricing/oci-sku-catalog.yaml` `disclaimer:` field at the bottom — `tools/oci_bom_gen.py` reads it automatically (do not duplicate, paraphrase, or maintain a second copy elsewhere)
|
||||
- Only include SKUs the customer actually requested — never dump the full catalog
|
||||
- Show cost proportions so the customer can see where their spend concentrates
|
||||
- Use Excel formulas (not static values) so the customer can adjust quantities
|
||||
@@ -242,7 +284,7 @@ After delivering an output, show elapsed time and offer the natural next step.
|
||||
```
|
||||
✅ Done — [task description] completed in [Xm Ys]
|
||||
|
||||
→ [A] Generate additional outputs (drawio / doc / xlsx)
|
||||
→ [A] Generate additional outputs (sketch / drawio / doc / xlsx)
|
||||
→ [B] Modify the architecture (add/remove/change services)
|
||||
→ [C] Run Well-Architected review on this architecture
|
||||
→ [D] Build a business case from this architecture
|
||||
@@ -445,7 +487,7 @@ Full DELIVER guide: [docs/deliver-phase.md](docs/deliver-phase.md)
|
||||
|
||||
Every skill option that produces output MUST generate **readable, human-consumable output** as the primary deliverable. YAML files are structured backing data — they are never the final output shown to the user. Specifically:
|
||||
|
||||
- **Options 1-4, 8:** Primary output is `.pptx` (slide deck) and/or `.drawio` (diagram). YAML specs are saved alongside but never presented as the deliverable.
|
||||
- **Options 1-4, 8:** Primary output is `.pptx` (slide deck), `.drawio` (diagram), and/or native `.excalidraw` sketch when the user requests draft mode. YAML/Mermaid specs are saved alongside when useful but never presented as the final deliverable.
|
||||
- **Option 5 (WA Review):** Primary output is the **formatted terminal scorecard** (banner + pillar bars + gap tables + recommendations). YAML scorecard saved to `examples/` as backing data.
|
||||
- **Option 12 (ECAL Readiness):** Primary output is the **formatted terminal scorecard** (phase scores + artefact checklist + gap analysis). YAML scorecard saved to `examples/` as backing data.
|
||||
- **After any review/score:** When the user picks [C] "Export as slide", generate a 1-2 slide `.pptx` with the scorecard visualization using `tools/oci_deck_gen.py`.
|
||||
@@ -460,9 +502,9 @@ See [docs/skill/output-formats.md](docs/skill/output-formats.md) for the per-cus
|
||||
|
||||
## Cookbook: Building Tool Payloads
|
||||
|
||||
Before mapping customer requirements to SKUs for `generate_bom`, `generate_bom_appca`, or `generate_cost_estimate`, check [docs/bom-cookbook.md](docs/bom-cookbook.md) — it has copy-paste recipes for common patterns (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block + FastConnect) and explicitly names the gotchas that otherwise burn exploration turns (e.g. ADB-Dedicated shares infrastructure SKUs with ExaCS — there are no separate `adb_dedicated` SKUs in the catalog).
|
||||
Any pricing or SKU work — cost estimate (option 4), BOM (option 13), BOM-AppCA (option 14), business-case TCO (option 8), scenario comparison from a customer artefact (`.pptx`, BOM, quote) — starts in the local catalog, not on the web. Check [docs/bom-cookbook.md](docs/bom-cookbook.md) for copy-paste recipes (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block + FastConnect) and the gotchas that otherwise burn exploration turns (e.g. ADB-Dedicated shares infrastructure SKUs with ExaCS — there are no separate `adb_dedicated` SKUs in the catalog).
|
||||
|
||||
If a requirement does not match a recipe, `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) and consult `kb/services/` before inventing SKUs.
|
||||
If a requirement does not match a recipe, `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) and consult `kb/services/` before inventing SKUs. See "What You Do NOT Do" for the web-lookup rule and `kb/pricing/oci-sku-catalog.yaml`'s `last_verified` for staleness handling.
|
||||
|
||||
---
|
||||
|
||||
@@ -570,6 +612,7 @@ KB lives under `kb/`. See [kb/README.md](kb/README.md) for the directory map, fr
|
||||
- You do NOT replace the architect's judgment. You accelerate it.
|
||||
- You do NOT generate pixel-perfect diagrams. You generate 80% drafts the architect refines.
|
||||
- You do NOT make up pricing. If you don't have current pricing, estimate ranges.
|
||||
- You do NOT use WebSearch / WebFetch / `oracle.com` for OCI list prices or SKU lookups. `kb/pricing/oci-sku-catalog.yaml` is auto-refreshed via `tools/refresh_sku_catalog.py` (Oracle apexapps pricing API) and is the source of truth — including for ECPU/OCPU rates, BYOL deltas, Exadata X11M part numbers, and ADB-D ECPU SKUs. Web fallback is allowed ONLY when (a) the SKU is genuinely absent after `grep` AND a fresh `--refresh` still does not surface it, or (b) the catalog's `last_verified` is >60 days stale and a refresh is not currently possible. In either case, cite the source URL alongside the part number. Reflexively reaching for WebSearch when the catalog already has the answer is the most common cost-estimate failure mode — don't.
|
||||
- You do NOT claim features exist if you're unsure. Check the KB first.
|
||||
- You do NOT do detailed project management. DELIVER artifacts are lightweight handover aids.
|
||||
- You do NOT add services or components the user did not request.
|
||||
|
||||
63
docs/business-case-flow.md
Normal file
63
docs/business-case-flow.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Business Case Flow
|
||||
|
||||
Option 8 produces an executive business-case deck plus reusable YAML backing
|
||||
data. For ADB-S to ADB-D cases, use `business_case.adbs_to_adbd`.
|
||||
|
||||
## ADB-S to ADB-D Rules
|
||||
|
||||
- Keep workload/billable ECPU demand comparable between ADB-S and ADB-D unless
|
||||
the customer provides a different target demand.
|
||||
- Dedicated physical capacity is a separate fixed footprint:
|
||||
`DB nodes x ECPU per DB node`.
|
||||
- Never model ADB-D at 100% utilization by default. Show utilization explicitly.
|
||||
- ADB-D fixed infrastructure includes DB servers and storage servers; workload
|
||||
ECPU demand remains a separate line.
|
||||
- Projected BOMs are annual run-rate snapshots at the horizon, not cumulative
|
||||
multi-year totals.
|
||||
- GoldenGate can be `steady_state`, `migration_bridge_only`, or
|
||||
`migration_plus_fallback_months`. Bridge-only GoldenGate is excluded from
|
||||
future steady-state BOMs.
|
||||
|
||||
## Read Architecture
|
||||
|
||||
ADB-S and ADB-D can carry the same workload demand while using different read
|
||||
architectures. ADB-S may require refreshable/read clones when standby is not the
|
||||
application read path. ADB-D may use Local ADG read-only standby and retire
|
||||
steady-state clones. The deck should call this out in BOM notes, value drivers,
|
||||
and risk/value slides.
|
||||
|
||||
## Storage Economics
|
||||
|
||||
Storage economics are shown separately from aggregate TCO:
|
||||
|
||||
```text
|
||||
break-even TB = fixed dedicated infra monthly cost / ADB-S storage USD per GB-month / 1024
|
||||
```
|
||||
|
||||
Apply discounts uniformly when comparing scenarios. If customer material
|
||||
provides a baseline break-even, show it beside the recalculated break-even for
|
||||
the proposed footprint.
|
||||
|
||||
## Business Value
|
||||
|
||||
Risk-reduction cases use a Business Value Model instead of generic ROI
|
||||
percentages. Do not invent revenue impact. Convert stability to USD only when
|
||||
the customer provides revenue-at-risk, transaction margin, fraud loss impact, or
|
||||
cost per degraded/outage hour.
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
avoided degraded/outage hours x business impact per hour
|
||||
```
|
||||
|
||||
Track success metrics such as CPU utilization, read latency, clone lag retired,
|
||||
ADG read lag, GoldenGate/apply lag during migration, incidents avoided, and
|
||||
degraded hours avoided.
|
||||
|
||||
## Validation
|
||||
|
||||
Business-case deck validation reads text boxes, table cells, and grouped shapes.
|
||||
It checks expected titles, disclaimer-last, required assumptions, and forbidden
|
||||
obsolete phrases such as `OCI Annual`, stale scenario risks, and unclear
|
||||
language like `FTE-year`.
|
||||
89
examples/adbs-to-adbd-business-case-synthetic.yaml
Normal file
89
examples/adbs-to-adbd-business-case-synthetic.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
business_case:
|
||||
customer_name: "Synthetic Commerce Co."
|
||||
cover_subtitle: "ADB-S to ADB-D Migration Business Case"
|
||||
prepared_by: "Diego Cabrera"
|
||||
prepared_by_role: "Solutions Architect"
|
||||
date: "2026-05-08"
|
||||
executive_summary: >
|
||||
Synthetic Commerce Co. is evaluating a move from ADB-S to ADB-D for a
|
||||
fraud analytics workload where predictable performance, read-path cleanup,
|
||||
and storage economics are more important than a pure near-term cost takeout.
|
||||
drivers:
|
||||
primary: "Stabilize fraud analytics performance while keeping the business case auditable."
|
||||
urgency: "Validate capacity, TCO crossover, and migration bridge assumptions before procurement."
|
||||
adbs_to_adbd:
|
||||
license_model: BYOL
|
||||
discount_pct: 0.11
|
||||
ecpu_per_db_node: 760
|
||||
current:
|
||||
label: "ADB-S As-Is"
|
||||
workload_ecpu: 2400
|
||||
storage_tb: 96
|
||||
read_architecture: "Refreshable/read clones serve application reads; standby is not the application read path."
|
||||
target:
|
||||
label: "ADB-D Dedicated"
|
||||
workload_ecpu: 2400
|
||||
storage_tb: 96
|
||||
db_nodes: 5
|
||||
storage_servers: 3
|
||||
read_architecture: "Local ADG read-only standby serves application reads; steady-state clones retired."
|
||||
forecasts:
|
||||
- period: "24M"
|
||||
display_label: "Year 2"
|
||||
as_is:
|
||||
workload_ecpu: 3150
|
||||
storage_tb: 128
|
||||
to_be:
|
||||
workload_ecpu: 3150
|
||||
storage_tb: 128
|
||||
db_nodes: 6
|
||||
storage_servers: 3
|
||||
- period: "36M"
|
||||
display_label: "Year 3"
|
||||
as_is:
|
||||
workload_ecpu: 3900
|
||||
storage_tb: 166
|
||||
to_be:
|
||||
workload_ecpu: 3900
|
||||
storage_tb: 166
|
||||
db_nodes: 8
|
||||
storage_servers: 3
|
||||
goldengate:
|
||||
mode: migration_bridge_only
|
||||
ocpus: 4
|
||||
bridge_months: 3
|
||||
bridge_one_time_cost: 0
|
||||
storage_economics:
|
||||
base_break_even_tb: 91
|
||||
business_impact:
|
||||
business_impact_per_hour: 0
|
||||
crossover_chart:
|
||||
y_axis_min: 1.5
|
||||
y_axis_max: 3.5
|
||||
y_axis_major_unit: 0.5
|
||||
roadmap:
|
||||
total_duration: "12 weeks"
|
||||
phases:
|
||||
- name: "Define"
|
||||
duration: "2 weeks"
|
||||
deliverables: ["Baseline current BOM", "Confirm success metrics"]
|
||||
- name: "Design"
|
||||
duration: "4 weeks"
|
||||
deliverables: ["Dedicated capacity plan", "Read-path validation"]
|
||||
- name: "Deliver"
|
||||
duration: "6 weeks"
|
||||
deliverables: ["Migration bridge", "Cutover rehearsal"]
|
||||
recommendation:
|
||||
summary: "Proceed with detailed design for the ADB-D Dedicated target, validating the utilization, storage break-even, and GoldenGate bridge assumptions before final commercial approval."
|
||||
next_steps:
|
||||
- action: "Confirm workload ECPU demand and storage forecast"
|
||||
owner: "Customer"
|
||||
deadline: "Before design sign-off"
|
||||
- action: "Validate ADB-D DB node and storage server reservation"
|
||||
owner: "Oracle"
|
||||
deadline: "Before procurement"
|
||||
deck_validation:
|
||||
forbidden_phrases:
|
||||
- "OCI Annual"
|
||||
- "FTE-year"
|
||||
- "if 4x3 is selected"
|
||||
@@ -10,7 +10,8 @@ disclaimer: 'This sample quote is provided solely for evaluation purposes and is
|
||||
for the services specified. If you would like to purchase the services specified in this sample quote, please request that
|
||||
Oracle issue you a formal quote (which may include an OMA or a CSA if you do not already have an appropriate agreement in
|
||||
place with Oracle) for your acceptance and execution. Your formal quote will be effective only upon Oracle''s acceptance
|
||||
of the formal quote (and the OMA or CSA, if required).
|
||||
of the formal quote (and the OMA or CSA, if required). This is reference material, not a commercial commitment, and any
|
||||
client-specific discounts negotiated with Oracle are not reflected in the prices shown.
|
||||
|
||||
'
|
||||
categories:
|
||||
|
||||
@@ -7,6 +7,8 @@ business_case:
|
||||
# --- Header ---
|
||||
customer_name: ""
|
||||
prepared_by: ""
|
||||
prepared_by_role: "" # e.g., "Solutions Architect"
|
||||
cover_subtitle: "" # e.g., "ADB-S to ADB-D Migration Business Case"
|
||||
date: ""
|
||||
version: "1.0"
|
||||
status: draft # draft | review | approved
|
||||
@@ -36,6 +38,66 @@ business_case:
|
||||
annual_spend: 0 # Current total annual IT spend for in-scope workloads
|
||||
team_size: 0 # FTEs dedicated to operations
|
||||
|
||||
# --- Reusable ADB-S to ADB-D model (optional) ---
|
||||
# Use this for dedicated migration business cases. The model keeps workload
|
||||
# demand comparable while showing dedicated physical capacity separately.
|
||||
adbs_to_adbd:
|
||||
license_model: BYOL # BYOL | PAYG
|
||||
discount_pct: 0 # 0.11 or 11 are both accepted
|
||||
ecpu_per_db_node: 760
|
||||
current:
|
||||
label: "ADB-S As-Is"
|
||||
workload_ecpu: 0 # billable/planning ECPU demand
|
||||
storage_tb: 0
|
||||
read_architecture: "refreshable/read clones for application reads"
|
||||
components: [] # optional extra BOM lines with sku/qty/label/note
|
||||
target:
|
||||
label: "ADB-D Dedicated"
|
||||
workload_ecpu: 0 # keep equal to current for fair comparison unless customer states otherwise
|
||||
storage_tb: 0
|
||||
db_nodes: 0 # physical footprint
|
||||
storage_servers: 0 # fixed infra/storage footprint
|
||||
read_architecture: "Local ADG read-only standby for application reads"
|
||||
forecasts:
|
||||
- period: "24M"
|
||||
display_label: "Year 2"
|
||||
as_is:
|
||||
workload_ecpu: 0
|
||||
storage_tb: 0
|
||||
to_be:
|
||||
workload_ecpu: 0
|
||||
storage_tb: 0
|
||||
db_nodes: 0
|
||||
storage_servers: 0
|
||||
- period: "36M"
|
||||
display_label: "Year 3"
|
||||
as_is:
|
||||
workload_ecpu: 0
|
||||
storage_tb: 0
|
||||
to_be:
|
||||
workload_ecpu: 0
|
||||
storage_tb: 0
|
||||
db_nodes: 0
|
||||
storage_servers: 0
|
||||
goldengate:
|
||||
mode: migration_bridge_only # steady_state | migration_bridge_only | migration_plus_fallback_months
|
||||
ocpus: 0
|
||||
bridge_months: 0
|
||||
storage_economics:
|
||||
adbs_storage_usd_per_gb_month: 0 # optional override; otherwise SKU catalog B95706
|
||||
base_break_even_tb: "" # optional customer-provided baseline
|
||||
business_impact:
|
||||
# Stability is converted to USD only when customer provides one of these.
|
||||
business_impact_per_hour: 0
|
||||
revenue_at_risk_per_hour: 0
|
||||
transaction_margin_per_hour: 0
|
||||
fraud_loss_impact_per_hour: 0
|
||||
cost_per_outage_hour: 0
|
||||
crossover_chart:
|
||||
y_axis_min: null
|
||||
y_axis_max: null
|
||||
y_axis_major_unit: 0.5
|
||||
|
||||
# --- TCO Comparison ---
|
||||
tco:
|
||||
horizon_years: 3 # 3 or 5
|
||||
|
||||
@@ -100,3 +100,43 @@ def test_from_spec_enriches_sparse_business_case_from_executive_summary():
|
||||
assert "Implementation Roadmap" in text
|
||||
assert "Our Recommendation" in text
|
||||
assert "12 weeks" in text
|
||||
|
||||
|
||||
def test_from_spec_enriches_adbs_to_adbd_business_case_without_meli_hardcoding():
|
||||
spec = {
|
||||
"customer_name": "Synthetic Retail",
|
||||
"cover_subtitle": "ADB-S to ADB-D Migration Business Case",
|
||||
"prepared_by": "Diego Cabrera",
|
||||
"prepared_by_role": "Solutions Architect",
|
||||
"executive_summary": "Move a fraud analytics workload from ADB-S to ADB-D for stability.",
|
||||
"adbs_to_adbd": {
|
||||
"license_model": "BYOL",
|
||||
"discount_pct": 0.11,
|
||||
"ecpu_per_db_node": 760,
|
||||
"current": {"label": "ADB-S As-Is", "workload_ecpu": 1000, "storage_tb": 80},
|
||||
"target": {"label": "ADB-D Dedicated", "workload_ecpu": 1000, "storage_tb": 80, "db_nodes": 2, "storage_servers": 3},
|
||||
"forecasts": [
|
||||
{
|
||||
"period": "24M",
|
||||
"display_label": "Year 2",
|
||||
"as_is": {"workload_ecpu": 1300, "storage_tb": 120},
|
||||
"to_be": {"workload_ecpu": 1300, "storage_tb": 120, "db_nodes": 3, "storage_servers": 3},
|
||||
}
|
||||
],
|
||||
"goldengate": {"mode": "migration_bridge_only", "ocpus": 2, "bridge_months": 3},
|
||||
},
|
||||
}
|
||||
|
||||
gen = BusinessCaseDeckGenerator.from_spec(spec)
|
||||
text = _collect_slide_text(gen)
|
||||
|
||||
assert "Prepared by: Diego Cabrera, Solutions Architect" in text
|
||||
assert "ADB-S As-Is" in text
|
||||
assert "ADB-D Dedicated" in text
|
||||
assert "BOM + Operations Cost Breakdown" in text
|
||||
assert "TCO Crossover" in text
|
||||
assert "Business Value Model" in text
|
||||
assert "Workload ECPU demand" in text
|
||||
assert "ECPU capacity" in text
|
||||
assert "No invented revenue" in text
|
||||
assert "MELI" not in text
|
||||
|
||||
123
tests/test_oci_business_case_model.py
Normal file
123
tests/test_oci_business_case_model.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
TOOLS_DIR = PROJECT_ROOT / "tools"
|
||||
|
||||
if str(TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
from oci_business_case_model import ( # noqa: E402
|
||||
SKU_ADBD_DB_SERVER,
|
||||
SKU_ADBD_STORAGE_SERVER,
|
||||
SKU_ADBS_STORAGE_ATP,
|
||||
build_bom_specs,
|
||||
build_crossover_chart,
|
||||
build_storage_economics,
|
||||
build_tco_projection,
|
||||
capacity_model,
|
||||
first_crossover_period,
|
||||
storage_break_even_tb,
|
||||
)
|
||||
|
||||
|
||||
def _scenario():
|
||||
return {
|
||||
"customer_name": "Synthetic Retail",
|
||||
"prepared_by": "Diego Cabrera",
|
||||
"license_model": "BYOL",
|
||||
"discount_pct": 0.10,
|
||||
"ecpu_per_db_node": 760,
|
||||
"current": {
|
||||
"label": "ADB-S As-Is",
|
||||
"workload_ecpu": 1000,
|
||||
"storage_tb": 80,
|
||||
},
|
||||
"target": {
|
||||
"label": "ADB-D Dedicated",
|
||||
"workload_ecpu": 1000,
|
||||
"storage_tb": 80,
|
||||
"db_nodes": 2,
|
||||
"storage_servers": 3,
|
||||
},
|
||||
"forecasts": [
|
||||
{
|
||||
"period": "24M",
|
||||
"display_label": "Year 2",
|
||||
"as_is": {"workload_ecpu": 1300, "storage_tb": 120},
|
||||
"to_be": {"workload_ecpu": 1300, "storage_tb": 120, "db_nodes": 3, "storage_servers": 3},
|
||||
},
|
||||
{
|
||||
"period": "36M",
|
||||
"display_label": "Year 3",
|
||||
"as_is": {"workload_ecpu": 1600, "storage_tb": 160},
|
||||
"to_be": {"workload_ecpu": 1600, "storage_tb": 160, "db_nodes": 4, "storage_servers": 3},
|
||||
},
|
||||
],
|
||||
"goldengate": {"mode": "migration_bridge_only", "ocpus": 2, "bridge_months": 3},
|
||||
}
|
||||
|
||||
|
||||
def test_capacity_model_separates_workload_demand_from_physical_capacity():
|
||||
cap = capacity_model({"workload_ecpu": 3705, "db_nodes": 7}, 760)
|
||||
|
||||
assert cap.physical_capacity_ecpu == 5320
|
||||
assert round(cap.utilization_pct) == 70
|
||||
assert "3,705 ECPU used over 5,320 ECPU capacity" in cap.label
|
||||
|
||||
|
||||
def test_storage_break_even_calculation_applies_uniform_discount():
|
||||
tb = storage_break_even_tb(14500, 0.1156, 0.10)
|
||||
|
||||
assert round(tb) == round(14500 / (0.1156 * 0.90) / 1024)
|
||||
|
||||
|
||||
def test_build_bom_specs_generates_current_and_projected_run_rate_boms():
|
||||
specs = build_bom_specs(_scenario())
|
||||
|
||||
assert "current_as_is" in specs
|
||||
assert "to_be_steady_state" in specs
|
||||
assert "as_is_projected_24m" in specs
|
||||
assert "to_be_projected_36m" in specs
|
||||
assert "migration_bridge_year1" in specs
|
||||
|
||||
current_skus = {item["sku"] for item in specs["current_as_is"]["bom"]["line_items"]}
|
||||
target_skus = {item["sku"] for item in specs["to_be_steady_state"]["bom"]["line_items"]}
|
||||
future_target_skus = {item["sku"] for item in specs["to_be_projected_36m"]["bom"]["line_items"]}
|
||||
bridge_skus = {item["sku"] for item in specs["migration_bridge_year1"]["bom"]["line_items"]}
|
||||
|
||||
assert SKU_ADBS_STORAGE_ATP in current_skus
|
||||
assert SKU_ADBD_DB_SERVER in target_skus
|
||||
assert SKU_ADBD_STORAGE_SERVER in target_skus
|
||||
assert future_target_skus == target_skus
|
||||
assert "B92993" in bridge_skus
|
||||
assert "B92993" not in future_target_skus
|
||||
assert any("annual run-rate snapshots" in note for note in specs["to_be_projected_36m"]["bom"]["notes"])
|
||||
|
||||
|
||||
def test_forecast_projection_and_crossover_chart_are_native_shape_ready():
|
||||
projection = build_tco_projection(_scenario())
|
||||
chart = build_crossover_chart(projection, _scenario())
|
||||
|
||||
assert projection[0]["label"] == "Near-term"
|
||||
assert chart["categories"] == ["Near-term", "Year 2", "Year 3"]
|
||||
assert chart["note"].startswith("Bars are native PowerPoint shapes")
|
||||
assert chart["bullets"][0].startswith("Near-term: ADB-D")
|
||||
|
||||
|
||||
def test_storage_economics_exposes_break_even_and_offset():
|
||||
economics = build_storage_economics(_scenario())
|
||||
|
||||
assert economics["break_even_tb"] > 0
|
||||
assert economics["storage_offset_monthly"] > 0
|
||||
assert any(card["label"] == "Storage offset" for card in economics["cards"])
|
||||
|
||||
|
||||
def test_first_crossover_period_detects_first_cheaper_target():
|
||||
rows = [
|
||||
{"label": "Near-term", "as_is_annual_tco": 100, "to_be_annual_tco": 110},
|
||||
{"label": "Year 1", "as_is_annual_tco": 120, "to_be_annual_tco": 115},
|
||||
]
|
||||
|
||||
assert first_crossover_period(rows) == "Year 1"
|
||||
60
tests/test_pptx_validator.py
Normal file
60
tests/test_pptx_validator.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
TOOLS_DIR = PROJECT_ROOT / "tools"
|
||||
|
||||
if str(TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
from pptx_validator import validate_pptx # noqa: E402
|
||||
|
||||
|
||||
def test_validate_pptx_reads_text_boxes_tables_and_flags_forbidden(tmp_path):
|
||||
pptx = tmp_path / "validation-fixture.pptx"
|
||||
prs = Presentation()
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
slide.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(4), Inches(0.4)).text = "TCO Crossover"
|
||||
table = slide.shapes.add_table(1, 1, Inches(0.5), Inches(1.0), Inches(4), Inches(0.5)).table
|
||||
table.cell(0, 0).text = "Storage break-even"
|
||||
slide.shapes.add_textbox(Inches(0.5), Inches(1.7), Inches(4), Inches(0.4)).text = "OCI Annual"
|
||||
last = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
last.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(4), Inches(0.4)).text = "Commercial Disclaimer"
|
||||
prs.save(pptx)
|
||||
|
||||
report = validate_pptx(
|
||||
pptx,
|
||||
expected_titles=["TCO Crossover"],
|
||||
required_phrases=["Storage break-even"],
|
||||
forbidden_phrases=["OCI Annual"],
|
||||
disclaimer_last=True,
|
||||
)
|
||||
|
||||
assert report["status"] == "fail"
|
||||
assert any(issue["code"] == "FORBIDDEN_PHRASE" for issue in report["issues"])
|
||||
|
||||
|
||||
def test_validate_pptx_passes_required_and_disclaimer_last(tmp_path):
|
||||
pptx = tmp_path / "validation-pass.pptx"
|
||||
prs = Presentation()
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
slide.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(7), Inches(0.4)).text = (
|
||||
"BYOL/PAYG model Discount GoldenGate bridge duration "
|
||||
"Workload ECPU demand ECPU capacity Storage break-even Crossover"
|
||||
)
|
||||
last = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
last.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(4), Inches(0.4)).text = "Commercial Disclaimer"
|
||||
prs.save(pptx)
|
||||
|
||||
report = validate_pptx(
|
||||
pptx,
|
||||
required_phrases=["BYOL/PAYG model", "Storage break-even"],
|
||||
forbidden_phrases=["FTE-year"],
|
||||
disclaimer_last=True,
|
||||
)
|
||||
|
||||
assert report["status"] == "pass"
|
||||
685
tools/build_bom_comparison_deck.py
Normal file
685
tools/build_bom_comparison_deck.py
Normal 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())
|
||||
@@ -24,12 +24,24 @@ from typing import Optional, List, Dict
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
|
||||
from pptx.chart.data import ChartData
|
||||
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
|
||||
|
||||
try:
|
||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
from oci_business_case_model import (
|
||||
enrich_adbs_to_adbd_business_case,
|
||||
write_bom_outputs,
|
||||
)
|
||||
from pptx_validator import validate_pptx
|
||||
except ModuleNotFoundError:
|
||||
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
from tools.oci_business_case_model import (
|
||||
enrich_adbs_to_adbd_business_case,
|
||||
write_bom_outputs,
|
||||
)
|
||||
from tools.pptx_validator import validate_pptx
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -256,7 +268,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
# ================================================================
|
||||
|
||||
def add_cover_slide(self, customer: str, subtitle: str = "",
|
||||
prepared_by: str = "", date: str = ""):
|
||||
prepared_by: str = "", date: str = "",
|
||||
prepared_by_role: str = ""):
|
||||
"""Slide 1: Cover using Dark Title_Pillar layout."""
|
||||
slide = self._add_layout_slide(Layouts.COVER_DARK)
|
||||
self._set_placeholder(slide, 0, customer) # Title
|
||||
@@ -264,7 +277,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
date_str = date or datetime.now().strftime("%B %Y")
|
||||
self._set_placeholder(slide, 35, date_str)
|
||||
if prepared_by:
|
||||
self._set_placeholder(slide, 34, f"Prepared by: {prepared_by}")
|
||||
byline = f"{prepared_by}, {prepared_by_role}" if prepared_by_role else prepared_by
|
||||
self._set_placeholder(slide, 34, f"Prepared by: {byline}")
|
||||
|
||||
def add_executive_summary_slide(self, statement: str):
|
||||
"""Slide 2: Executive Summary — controlled typography on blank slide."""
|
||||
@@ -346,7 +360,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
horizon = tco.get("horizon_years", 3)
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(1.1), Inches(11), Inches(0.4),
|
||||
text=f"{horizon}-Year Comparison | Current State vs Oracle Cloud Infrastructure",
|
||||
text=tco.get("comparison_label", f"{horizon}-Year Comparison | Current State vs Oracle Cloud Infrastructure"),
|
||||
font_size=13, color=Colors.TEAL, bold=True,
|
||||
)
|
||||
|
||||
@@ -354,8 +368,34 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
proposed = tco.get("proposed_oci", {})
|
||||
savings = tco.get("savings", {})
|
||||
|
||||
ann_current = current.get("total_annual", 0)
|
||||
ann_oci = proposed.get("total_annual", 0)
|
||||
ann_savings = savings.get("annual", 0) or (ann_current - ann_oci)
|
||||
monthly_current = ann_current / 12 if ann_current else 0
|
||||
monthly_oci = ann_oci / 12 if ann_oci else 0
|
||||
|
||||
kpi_labels = tco.get("kpi_labels", {}) if isinstance(tco.get("kpi_labels", {}), dict) else {}
|
||||
kpis = [
|
||||
(f"${monthly_current:,.0f}/mo", kpi_labels.get("current", "Current state")),
|
||||
(f"${monthly_oci:,.0f}/mo", kpi_labels.get("proposed", "Proposed OCI")),
|
||||
(f"${abs(ann_savings) / 12:,.0f}/mo", kpi_labels.get("delta", "Monthly delta")),
|
||||
]
|
||||
for idx, (value, label) in enumerate(kpis):
|
||||
x = Inches(0.8 + idx * 4.0)
|
||||
box = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, Inches(1.55), Inches(3.65), Inches(1.0))
|
||||
box.fill.solid()
|
||||
box.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
box.line.color.rgb = Colors.MUTED_TEAL
|
||||
self._add_textbox(slide, x + Inches(0.15), Inches(1.68), Inches(3.35), Inches(0.4),
|
||||
text=value, font_size=20, bold=True, color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
self._add_textbox(slide, x + Inches(0.15), Inches(2.08), Inches(3.35), Inches(0.28),
|
||||
text=label, font_size=10, color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
|
||||
row_labels = tco.get("row_labels", {}) if isinstance(tco.get("row_labels", {}), dict) else {}
|
||||
rows_data = [
|
||||
("Infrastructure", current.get("annual_infrastructure", 0), proposed.get("annual_cloud_consumption", 0)),
|
||||
(row_labels.get("infrastructure", "Infrastructure"), current.get("annual_infrastructure", 0), proposed.get("annual_cloud_consumption", 0)),
|
||||
("Licensing / Support", current.get("annual_licensing", 0), proposed.get("annual_licensing", 0)),
|
||||
("Operations (People)", current.get("annual_operations", 0), proposed.get("annual_operations", 0)),
|
||||
("Downtime Cost", current.get("annual_downtime_cost", 0), proposed.get("annual_downtime_cost", 0)),
|
||||
@@ -368,9 +408,10 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
migration = proposed.get("migration_one_time", 0)
|
||||
|
||||
num_rows = len(rows_data) + 3 # header + rows + annual total + horizon total
|
||||
table_top = Inches(3.0)
|
||||
table = self._add_table(
|
||||
slide, num_rows, 4,
|
||||
Inches(0.8), Inches(1.6),
|
||||
Inches(0.8), table_top,
|
||||
Inches(11.7), Inches(0.42 * num_rows),
|
||||
)
|
||||
table.columns[0].width = Inches(3.5)
|
||||
@@ -379,7 +420,14 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
table.columns[3].width = Inches(2.8)
|
||||
|
||||
# Header
|
||||
for j, h in enumerate(["Cost Category", "Current (Annual)", "OCI (Annual)", "Savings"]):
|
||||
column_labels = tco.get("column_labels", {}) if isinstance(tco.get("column_labels", {}), dict) else {}
|
||||
headers = [
|
||||
"Cost Category",
|
||||
column_labels.get("current", "Current (Annual)"),
|
||||
column_labels.get("proposed", "OCI (Annual)"),
|
||||
column_labels.get("delta", "Savings"),
|
||||
]
|
||||
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,
|
||||
@@ -401,9 +449,9 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
# Annual total row
|
||||
total_row = len(rows_data) + 1
|
||||
ann_current = current.get("total_annual", 0) or sum(r[1] for r in rows_data if isinstance(r[1], (int, float)))
|
||||
ann_oci = proposed.get("total_annual", 0) or sum(r[2] for r in rows_data if isinstance(r[2], (int, float)))
|
||||
ann_savings = savings.get("annual", 0) or (ann_current - ann_oci)
|
||||
ann_current = ann_current or sum(r[1] for r in rows_data if isinstance(r[1], (int, float)))
|
||||
ann_oci = ann_oci or sum(r[2] for r in rows_data if isinstance(r[2], (int, float)))
|
||||
ann_savings = ann_savings or (ann_current - ann_oci)
|
||||
self._style_table_cell(table.cell(total_row, 0), "TOTAL ANNUAL", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL)
|
||||
self._style_table_cell(table.cell(total_row, 1), f"${ann_current:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(total_row, 2), f"${ann_oci:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
|
||||
@@ -422,7 +470,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
# Migration note
|
||||
if migration:
|
||||
note_y = Inches(1.6) + Inches(0.42 * num_rows) + Inches(0.15)
|
||||
note_y = table_top + Inches(0.42 * num_rows) + Inches(0.15)
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), note_y, Inches(11), Inches(0.3),
|
||||
text=f"* Includes one-time migration investment of ${migration:,.0f} in Year 1",
|
||||
@@ -432,16 +480,16 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
# Assumptions
|
||||
assumptions = tco.get("assumptions", [])
|
||||
if assumptions:
|
||||
a_y = Inches(1.6) + Inches(0.42 * num_rows) + Inches(0.45)
|
||||
a_y = table_top + Inches(0.42 * num_rows) + Inches(0.45)
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), a_y, Inches(11), Inches(0.25),
|
||||
text="Assumptions:", font_size=9, bold=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
for idx, a in enumerate(assumptions[:4]):
|
||||
for idx, a in enumerate(assumptions[:7]):
|
||||
self._add_textbox(
|
||||
slide, Inches(1.0), a_y + Inches(0.25 + idx * 0.22),
|
||||
Inches(11), Inches(0.22),
|
||||
text=f"• {a}", font_size=8, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
text=f"• {a}", font_size=7, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_roi_slide(self, roi: dict, headline: str = ""):
|
||||
@@ -450,6 +498,56 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
self._add_title_bar(slide, "Return on Investment")
|
||||
|
||||
cards = roi.get("cards", [])
|
||||
if cards:
|
||||
metric_text = headline or pick(roi, "headline", default="ROI logic")
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(1.15), Inches(11.7), Inches(0.62),
|
||||
text=metric_text, font_size=34, bold=True, color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
summary = pick(roi, "label", "summary")
|
||||
if summary:
|
||||
self._add_textbox(
|
||||
slide, Inches(1.0), Inches(1.82), Inches(11.3), Inches(0.5),
|
||||
text=summary, font_size=13, color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
card_w = Inches(3.65)
|
||||
card_h = Inches(2.25)
|
||||
gap = Inches(0.35)
|
||||
x0 = Inches(0.9)
|
||||
y0 = Inches(2.75)
|
||||
colors = [Colors.TEAL, Colors.BURNT_ORANGE, Colors.FOREST]
|
||||
for idx, card in enumerate(cards[:3]):
|
||||
x = x0 + idx * (card_w + gap)
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y0, card_w, card_h)
|
||||
bg.fill.solid()
|
||||
bg.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
bg.line.color.rgb = colors[idx % len(colors)]
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.18), y0 + Inches(0.18), card_w - Inches(0.36), Inches(0.28),
|
||||
text=card.get("title", ""), font_size=11, bold=True, color=colors[idx % len(colors)],
|
||||
)
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.18), y0 + Inches(0.58), card_w - Inches(0.36), Inches(0.48),
|
||||
text=card.get("metric", ""), font_size=18, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.18), y0 + Inches(1.14), card_w - Inches(0.36), Inches(0.88),
|
||||
text=card.get("detail", ""), font_size=8, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
note = pick(roi, "note")
|
||||
if note:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.9), Inches(5.45), Inches(11.5), Inches(0.65),
|
||||
text=note, font_size=10, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
return
|
||||
|
||||
# Big centered ROI number
|
||||
pct = roi.get("three_year_roi_pct", 0)
|
||||
metric_text = headline or (f"{pct:.0f}%" if pct else "—")
|
||||
@@ -466,7 +564,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
# Label under big number
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(3.75), Inches(11.7), Inches(0.4),
|
||||
text="3-Year Return on Investment", font_size=16,
|
||||
text=roi.get("label", "3-Year Return on Investment"), font_size=16,
|
||||
color=Colors.SECONDARY_TEXT, alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
@@ -509,11 +607,349 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
def add_tco_projection_slide(
|
||||
self,
|
||||
projection: list,
|
||||
title: str = "3-Year TCO Projection",
|
||||
storage_economics: dict | None = None,
|
||||
):
|
||||
"""Slide: multi-year TCO forecast with CPU and storage assumptions."""
|
||||
if not projection:
|
||||
return
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, title)
|
||||
|
||||
headers = ["Period", "CPU Demand", "Storage", "ADB-S As-Is", "ADB-D To-Be", "Delta", "Note"]
|
||||
rows = projection[:4]
|
||||
table = self._add_table(
|
||||
slide, len(rows) + 1, len(headers),
|
||||
Inches(0.55), Inches(1.35),
|
||||
Inches(12.25), Inches(0.55 * (len(rows) + 1)),
|
||||
)
|
||||
widths = [1.05, 1.65, 1.8, 1.65, 1.65, 1.35, 3.1]
|
||||
for idx, width in enumerate(widths):
|
||||
table.columns[idx].width = Inches(width)
|
||||
|
||||
for j, header in enumerate(headers):
|
||||
self._style_table_cell(
|
||||
table.cell(0, j), header, font_size=9, bold=True,
|
||||
color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER if j else PP_ALIGN.LEFT,
|
||||
)
|
||||
|
||||
for i, row in enumerate(rows, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if i % 2 == 0 else None
|
||||
values = [
|
||||
row.get("period", ""),
|
||||
row.get("cpu", ""),
|
||||
row.get("storage", ""),
|
||||
row.get("as_is", ""),
|
||||
row.get("to_be", ""),
|
||||
row.get("delta", ""),
|
||||
row.get("note", ""),
|
||||
]
|
||||
for j, value in enumerate(values):
|
||||
self._style_table_cell(
|
||||
table.cell(i, j), value, font_size=8, bg_color=bg,
|
||||
alignment=PP_ALIGN.RIGHT if j in (3, 4, 5) else PP_ALIGN.LEFT,
|
||||
color=Colors.ERROR if j == 5 and str(value).startswith("+") else Colors.PRIMARY_TEXT,
|
||||
)
|
||||
|
||||
footnote = rows[0].get("footnote", "") if isinstance(rows[0], dict) else ""
|
||||
if footnote:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.65), Inches(6.25), Inches(12.0), Inches(0.45),
|
||||
text=footnote, font_size=8, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
if isinstance(storage_economics, dict) and storage_economics:
|
||||
y = Inches(4.35)
|
||||
box = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(0.75), y, Inches(11.85), Inches(1.55))
|
||||
box.fill.solid()
|
||||
box.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
box.line.color.rgb = Colors.MUTED_TEAL
|
||||
|
||||
self._add_textbox(
|
||||
slide, Inches(0.95), y + Inches(0.12), Inches(11.45), Inches(0.26),
|
||||
text=pick(storage_economics, "headline", default="Storage economics"),
|
||||
font_size=10, bold=True, color=Colors.TEAL,
|
||||
)
|
||||
|
||||
cards = storage_economics.get("cards", [])
|
||||
if cards:
|
||||
card_w = Inches(3.55)
|
||||
for idx, card in enumerate(cards[:3]):
|
||||
x = Inches(0.95) + idx * Inches(3.8)
|
||||
self._add_textbox(
|
||||
slide, x, y + Inches(0.45), card_w, Inches(0.35),
|
||||
text=card.get("value", ""), font_size=15, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
self._add_textbox(
|
||||
slide, x, y + Inches(0.82), card_w, Inches(0.34),
|
||||
text=card.get("label", ""), font_size=8, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
detail = card.get("detail", "")
|
||||
if detail:
|
||||
self._add_textbox(
|
||||
slide, x, y + Inches(1.13), card_w, Inches(0.28),
|
||||
text=detail, font_size=7, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_tco_crossover_slide(self, chart_spec: dict):
|
||||
"""Slide: show when ADB-D becomes cheaper than ADB-S."""
|
||||
if not isinstance(chart_spec, dict):
|
||||
return
|
||||
|
||||
categories = chart_spec.get("categories", [])
|
||||
as_is = chart_spec.get("as_is", [])
|
||||
to_be = chart_spec.get("to_be", [])
|
||||
if not categories or not as_is or not to_be:
|
||||
return
|
||||
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, pick(chart_spec, "title", default="TCO Crossover"))
|
||||
|
||||
subtitle = pick(chart_spec, "subtitle")
|
||||
if subtitle:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.75), Inches(1.05), Inches(12.0), Inches(0.35),
|
||||
text=subtitle, font_size=13, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
# Draw the chart with native shapes so it remains visible in lightweight
|
||||
# renderers that do not paint embedded Office chart objects.
|
||||
y_min = float(chart_spec.get("y_axis_min", min(as_is + to_be) * 0.9))
|
||||
y_max = float(chart_spec.get("y_axis_max", max(as_is + to_be) * 1.1))
|
||||
y_step = float(chart_spec.get("y_axis_major_unit", 0.5))
|
||||
plot_x, plot_y = Inches(0.95), Inches(1.8)
|
||||
plot_w, plot_h = Inches(7.75), Inches(4.2)
|
||||
axis_color = Colors.SECONDARY_TEXT
|
||||
grid_color = RGBColor(220, 218, 214)
|
||||
series_colors = [Colors.BURNT_ORANGE, Colors.TEAL]
|
||||
|
||||
def x_pos(idx: int) -> int:
|
||||
if len(categories) == 1:
|
||||
return int(plot_x + plot_w / 2)
|
||||
return int(plot_x + idx * (plot_w / (len(categories) - 1)))
|
||||
|
||||
def y_pos(value: float) -> int:
|
||||
ratio = (float(value) - y_min) / (y_max - y_min)
|
||||
return int(plot_y + plot_h - ratio * plot_h)
|
||||
|
||||
# Grid and y labels.
|
||||
tick = y_min
|
||||
while tick <= y_max + 0.001:
|
||||
y = y_pos(tick)
|
||||
grid = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, plot_x, y, plot_x + plot_w, y)
|
||||
grid.line.color.rgb = grid_color
|
||||
grid.line.width = Pt(0.6)
|
||||
self._add_textbox(
|
||||
slide, Inches(0.52), y - Inches(0.1), Inches(0.35), Inches(0.2),
|
||||
text=f"${tick:.1f}M", font_size=7, color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.RIGHT,
|
||||
)
|
||||
tick += y_step
|
||||
|
||||
# Axes.
|
||||
x_axis = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, plot_x, plot_y + plot_h, plot_x + plot_w, plot_y + plot_h)
|
||||
x_axis.line.color.rgb = axis_color
|
||||
y_axis = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, plot_x, plot_y, plot_x, plot_y + plot_h)
|
||||
y_axis.line.color.rgb = axis_color
|
||||
|
||||
baseline_y = y_pos(y_min)
|
||||
group_w = plot_w / len(categories)
|
||||
bar_w = min(Inches(0.28), group_w * 0.28)
|
||||
gap_w = Inches(0.06)
|
||||
|
||||
for idx, category in enumerate(categories):
|
||||
center = int(plot_x + group_w * (idx + 0.5))
|
||||
values = [as_is[idx], to_be[idx]]
|
||||
for s_idx, value in enumerate(values):
|
||||
color = series_colors[s_idx]
|
||||
x = center - bar_w - gap_w / 2 if s_idx == 0 else center + gap_w / 2
|
||||
y = y_pos(value)
|
||||
h = baseline_y - y
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, int(x), int(y), int(bar_w), int(h))
|
||||
bar.fill.solid()
|
||||
bar.fill.fore_color.rgb = color
|
||||
bar.line.fill.background()
|
||||
self._add_textbox(
|
||||
slide, int(x) - Inches(0.16), y - Inches(0.26), bar_w + Inches(0.32), Inches(0.2),
|
||||
text=f"${value:.2f}M", font_size=7, color=color,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
for idx, category in enumerate(categories):
|
||||
center = int(plot_x + group_w * (idx + 0.5))
|
||||
self._add_textbox(
|
||||
slide, center - Inches(0.45), plot_y + plot_h + Inches(0.1),
|
||||
Inches(0.9), Inches(0.25), text=str(category), font_size=8,
|
||||
color=Colors.SECONDARY_TEXT, alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
# Legend.
|
||||
legend_y = plot_y + plot_h + Inches(0.55)
|
||||
for idx, (label, color) in enumerate([
|
||||
(pick(chart_spec, "as_is_label", default="ADB-S"), series_colors[0]),
|
||||
(pick(chart_spec, "to_be_label", default="ADB-D"), series_colors[1]),
|
||||
]):
|
||||
x = plot_x + Inches(idx * 2.2)
|
||||
swatch = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, legend_y, Inches(0.18), Inches(0.08))
|
||||
swatch.fill.solid()
|
||||
swatch.fill.fore_color.rgb = color
|
||||
swatch.line.fill.background()
|
||||
self._add_textbox(slide, x + Inches(0.25), legend_y - Inches(0.06), Inches(1.6), Inches(0.22), text=label, font_size=8, color=Colors.SECONDARY_TEXT)
|
||||
|
||||
callout = pick(chart_spec, "callout", default="")
|
||||
if callout:
|
||||
box = slide.shapes.add_shape(
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
Inches(9.15), Inches(1.7), Inches(3.35), Inches(1.25),
|
||||
)
|
||||
box.fill.solid()
|
||||
box.fill.fore_color.rgb = Colors.TEAL
|
||||
box.line.fill.background()
|
||||
self._add_textbox(
|
||||
slide, Inches(9.35), Inches(1.92), Inches(2.95), Inches(0.78),
|
||||
text=callout, font_size=17, bold=True, color=Colors.WHITE,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
bullets = chart_spec.get("bullets", [])
|
||||
y = Inches(3.25)
|
||||
for idx, bullet in enumerate(bullets[:4]):
|
||||
self._add_textbox(
|
||||
slide, Inches(9.2), y + idx * Inches(0.55), Inches(3.25), Inches(0.42),
|
||||
text=f"- {bullet}", font_size=9, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
note = pick(chart_spec, "note")
|
||||
if note:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.85), Inches(6.35), Inches(11.8), Inches(0.35),
|
||||
text=note, font_size=8, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_cost_breakdown_slide(self, breakdown: dict):
|
||||
"""Slide: explain how cloud services and operations costs are built."""
|
||||
if not isinstance(breakdown, dict):
|
||||
return
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, pick(breakdown, "title", default="BOM + Operations Cost Breakdown"))
|
||||
|
||||
scenarios = breakdown.get("scenarios", [])
|
||||
if not scenarios:
|
||||
return
|
||||
|
||||
col_w = Inches(5.8)
|
||||
gap = Inches(0.55)
|
||||
x_positions = [Inches(0.75), Inches(0.75) + col_w + gap]
|
||||
colors = [Colors.TEAL, Colors.BURNT_ORANGE]
|
||||
|
||||
for idx, scenario in enumerate(scenarios[:2]):
|
||||
x = x_positions[idx]
|
||||
y = Inches(1.3)
|
||||
color = colors[idx % len(colors)]
|
||||
header = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, col_w, Inches(0.45))
|
||||
header.fill.solid()
|
||||
header.fill.fore_color.rgb = color
|
||||
header.line.fill.background()
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.15), y + Inches(0.06), col_w - Inches(0.3), Inches(0.32),
|
||||
text=pick(scenario, "name", default=f"Scenario {idx+1}"),
|
||||
font_size=12, bold=True, color=Colors.WHITE,
|
||||
)
|
||||
|
||||
rows = scenario.get("lines", [])
|
||||
table = self._add_table(
|
||||
slide, len(rows) + 2, 3,
|
||||
x, y + Inches(0.6),
|
||||
col_w, Inches(0.32 * (len(rows) + 2)),
|
||||
)
|
||||
table.columns[0].width = Inches(3.0)
|
||||
table.columns[1].width = Inches(1.35)
|
||||
table.columns[2].width = Inches(1.45)
|
||||
for j, h in enumerate(["Cost Item", "Monthly", "Annual"]):
|
||||
self._style_table_cell(
|
||||
table.cell(0, j), h, font_size=8, bold=True,
|
||||
color=Colors.WHITE, bg_color=color,
|
||||
alignment=PP_ALIGN.RIGHT if j else PP_ALIGN.LEFT,
|
||||
)
|
||||
for r_idx, row in enumerate(rows, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if r_idx % 2 == 0 else None
|
||||
self._style_table_cell(table.cell(r_idx, 0), row.get("item", ""), font_size=7, bg_color=bg)
|
||||
self._style_table_cell(table.cell(r_idx, 1), row.get("monthly", ""), font_size=7, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(r_idx, 2), row.get("annual", ""), font_size=7, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
total = scenario.get("total", {})
|
||||
last = len(rows) + 1
|
||||
self._style_table_cell(table.cell(last, 0), "TOTAL", font_size=8, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG)
|
||||
self._style_table_cell(table.cell(last, 1), total.get("monthly", ""), font_size=8, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(last, 2), total.get("annual", ""), font_size=8, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
|
||||
|
||||
notes = breakdown.get("notes", [])
|
||||
if notes:
|
||||
y = Inches(6.0)
|
||||
self._add_textbox(slide, Inches(0.8), y, Inches(11.8), Inches(0.25), text="Assumptions:", font_size=8, bold=True, color=Colors.SECONDARY_TEXT)
|
||||
for idx, note in enumerate(notes[:4]):
|
||||
self._add_textbox(
|
||||
slide, Inches(1.0), y + Inches(0.23 + idx * 0.2), Inches(11.6), Inches(0.2),
|
||||
text=f"- {note}", font_size=7, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_business_value_slide(self, value_case: dict):
|
||||
"""Slide: risk-adjusted value / avoided downtime model."""
|
||||
if not isinstance(value_case, dict):
|
||||
return
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, pick(value_case, "title", default="Risk-Adjusted Business Value"))
|
||||
|
||||
headline = pick(value_case, "headline")
|
||||
if headline:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(1.15), Inches(11.8), Inches(0.5),
|
||||
text=headline, font_size=16, bold=True, color=Colors.TEAL,
|
||||
)
|
||||
|
||||
rows = value_case.get("rows", [])
|
||||
if rows:
|
||||
headers = ["Value Area", "How to Measure", "Business Case Treatment"]
|
||||
table = self._add_table(slide, len(rows) + 1, 3, Inches(0.75), Inches(1.85), Inches(12.0), Inches(0.75 * (len(rows) + 1)))
|
||||
table.columns[0].width = Inches(2.6)
|
||||
table.columns[1].width = Inches(4.3)
|
||||
table.columns[2].width = Inches(5.1)
|
||||
for j, h in enumerate(headers):
|
||||
self._style_table_cell(table.cell(0, j), h, font_size=9, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL)
|
||||
for i, row in enumerate(rows, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if i % 2 == 0 else None
|
||||
self._style_table_cell(table.cell(i, 0), row.get("area", ""), font_size=8, bg_color=bg)
|
||||
self._style_table_cell(table.cell(i, 1), row.get("measure", ""), font_size=8, bg_color=bg)
|
||||
self._style_table_cell(table.cell(i, 2), row.get("treatment", ""), font_size=8, bg_color=bg)
|
||||
|
||||
break_even = value_case.get("break_even", [])
|
||||
if break_even:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(5.25), Inches(11.8), Inches(0.3),
|
||||
text="Break-even avoided outage impact", font_size=10, bold=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
headers = ["Horizon", "Incremental TCO", "If avoiding 4h/year", "If avoiding 8h/year"]
|
||||
table = self._add_table(slide, len(break_even) + 1, 4, Inches(0.8), Inches(5.6), Inches(11.7), Inches(0.38 * (len(break_even) + 1)))
|
||||
for j, h in enumerate(headers):
|
||||
self._style_table_cell(table.cell(0, j), h, font_size=8, bold=True, color=Colors.WHITE, bg_color=Colors.BURNT_ORANGE)
|
||||
for i, row in enumerate(break_even, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if i % 2 == 0 else None
|
||||
values = [row.get("horizon", ""), row.get("investment", ""), row.get("four_hours", ""), row.get("eight_hours", "")]
|
||||
for j, value in enumerate(values):
|
||||
self._style_table_cell(table.cell(i, j), value, font_size=8, bg_color=bg, alignment=PP_ALIGN.RIGHT if j else PP_ALIGN.LEFT)
|
||||
|
||||
def add_value_drivers_slide(self, drivers: list):
|
||||
"""Slide 6: Value Drivers — 4 categories on blank slide.
|
||||
|
||||
drivers: list of {"category": str, "title": str, "description": str, "quantified": str}
|
||||
"""
|
||||
drivers = [
|
||||
{"title": driver, "description": ""} if isinstance(driver, str) else driver
|
||||
for driver in drivers
|
||||
]
|
||||
slide = self._add_blank_slide()
|
||||
|
||||
self._add_title_bar(slide, "Value Drivers")
|
||||
@@ -587,8 +1023,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
# Column header backgrounds
|
||||
for x, color, label in [
|
||||
(left_x, Colors.TEAL, "✓ Migration Risks (Mitigated)"),
|
||||
(right_x, Colors.ERROR, "⚠ Risks of Inaction"),
|
||||
(left_x, Colors.TEAL, "Migration Risks (Mitigated)"),
|
||||
(right_x, Colors.ERROR, "Risks of Inaction"),
|
||||
]:
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, header_y, col_w, Inches(0.45))
|
||||
bg.fill.solid()
|
||||
@@ -614,7 +1050,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
self._add_textbox(
|
||||
slide, left_x + Inches(0.15), y + Inches(0.07), col_w - Inches(0.25), Inches(0.4),
|
||||
text=f"• {r_text}", font_size=12, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
text=f"- {r_text}", font_size=12, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
if mitigation:
|
||||
self._add_textbox(
|
||||
@@ -638,7 +1074,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
self._add_textbox(
|
||||
slide, right_x + Inches(0.15), y + Inches(0.07), col_w - Inches(0.25), Inches(0.4),
|
||||
text=f"• {r_text}", font_size=12, bold=True, color=Colors.ERROR,
|
||||
text=f"- {r_text}", font_size=12, bold=True, color=Colors.ERROR,
|
||||
)
|
||||
detail_parts = []
|
||||
if impact:
|
||||
@@ -794,6 +1230,21 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
)
|
||||
y += Inches(0.6)
|
||||
|
||||
def add_disclaimer_slide(self, disclaimer: str):
|
||||
"""Final slide: commercial disclaimer for price-bearing business cases."""
|
||||
if not disclaimer:
|
||||
return
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, "Commercial Disclaimer")
|
||||
body = slide.shapes.add_textbox(Inches(0.8), Inches(1.25), Inches(11.7), Inches(4.9))
|
||||
tf = body.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.text = disclaimer
|
||||
p.font.size = Pt(12)
|
||||
p.font.name = self.FONT
|
||||
p.font.color.rgb = Colors.PRIMARY_TEXT
|
||||
|
||||
# ================================================================
|
||||
# Build from YAML spec
|
||||
# ================================================================
|
||||
@@ -801,6 +1252,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
@classmethod
|
||||
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "BusinessCaseDeckGenerator":
|
||||
"""Build a complete business case deck from a YAML specification."""
|
||||
spec = enrich_adbs_to_adbd_business_case(spec)
|
||||
spec = _enrich_sparse_business_case(spec)
|
||||
bc = spec.get("business_case", spec) # Support both wrapped and unwrapped
|
||||
gen = cls(template=template)
|
||||
@@ -808,8 +1260,14 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
# Slide 1: Cover
|
||||
gen.add_cover_slide(
|
||||
customer=pick(bc, "customer_name", "customer"),
|
||||
subtitle="Business Case for Oracle Cloud Infrastructure",
|
||||
subtitle=pick(
|
||||
bc,
|
||||
"cover_subtitle",
|
||||
"subtitle",
|
||||
default="ADB-S to ADB-D Migration Business Case",
|
||||
),
|
||||
prepared_by=pick(bc, "prepared_by", "author"),
|
||||
prepared_by_role=pick(bc, "prepared_by_role", "role", "title"),
|
||||
date=pick(bc, "date", "generated_on"),
|
||||
)
|
||||
|
||||
@@ -879,10 +1337,30 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
if tco and (tco.get("current_state") or tco.get("proposed_oci")):
|
||||
gen.add_tco_slide(tco)
|
||||
|
||||
breakdown = tco.get("breakdown", {})
|
||||
if breakdown:
|
||||
gen.add_cost_breakdown_slide(breakdown)
|
||||
|
||||
projection = pick_list(tco, "projection", "forecast")
|
||||
if projection:
|
||||
gen.add_tco_projection_slide(
|
||||
projection,
|
||||
title=pick(tco, "projection_title", default="3-Year TCO Projection"),
|
||||
storage_economics=tco.get("storage_economics"),
|
||||
)
|
||||
|
||||
crossover_chart = tco.get("crossover_chart", {})
|
||||
if crossover_chart:
|
||||
gen.add_tco_crossover_slide(crossover_chart)
|
||||
|
||||
value_case = tco.get("business_value", {})
|
||||
if value_case:
|
||||
gen.add_business_value_slide(value_case)
|
||||
|
||||
# Slide 5: ROI
|
||||
roi = bc.get("roi", {})
|
||||
if roi and any(roi.get(k) for k in ["three_year_roi_pct", "payback_months", "total_investment"]):
|
||||
gen.add_roi_slide(roi)
|
||||
if roi and (roi.get("cards") or any(roi.get(k) for k in ["three_year_roi_pct", "payback_months", "total_investment"])):
|
||||
gen.add_roi_slide(roi, headline=pick(roi, "headline"))
|
||||
|
||||
# Slide 6: Value Drivers
|
||||
value_drivers = bc.get("value_drivers", [])
|
||||
@@ -916,6 +1394,10 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
next_steps=pick_list(rec, "next_steps"),
|
||||
)
|
||||
|
||||
disclaimer = pick(bc, "commercial_disclaimer", "disclaimer")
|
||||
if disclaimer:
|
||||
gen.add_disclaimer_slide(disclaimer)
|
||||
|
||||
return gen
|
||||
|
||||
|
||||
@@ -939,11 +1421,30 @@ def main():
|
||||
"--template",
|
||||
help="Path to Oracle FY26 .pptx template (default: templates/Oracle_PPT-template_FY26.pptx)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-boms",
|
||||
action="store_true",
|
||||
help="Do not generate companion BOM YAML/XLSX files for ADB-S to ADB-D cases.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-validate",
|
||||
action="store_true",
|
||||
help="Skip structural PPTX validation.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.spec, 'r') as f:
|
||||
spec = yaml.safe_load(f)
|
||||
|
||||
bc = spec.get("business_case", spec)
|
||||
if not pick(bc, "commercial_disclaimer", "disclaimer"):
|
||||
catalog_path = Path(__file__).resolve().parent.parent / "kb" / "pricing" / "oci-sku-catalog.yaml"
|
||||
if catalog_path.exists():
|
||||
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) or {}
|
||||
disclaimer = catalog.get("disclaimer", "")
|
||||
if disclaimer:
|
||||
bc["commercial_disclaimer"] = disclaimer
|
||||
|
||||
gen = BusinessCaseDeckGenerator.from_spec(spec, template=args.template)
|
||||
gen.save(args.output)
|
||||
|
||||
@@ -954,6 +1455,53 @@ def main():
|
||||
if customer:
|
||||
print(f" Customer: {customer}")
|
||||
|
||||
adbd_config = bc.get("adbs_to_adbd") or bc.get("adb_s_to_adb_d")
|
||||
if isinstance(adbd_config, dict) and not args.no_boms:
|
||||
bom_dir = Path(args.output).with_suffix("").parent / f"{Path(args.output).stem}-boms"
|
||||
adbd_config = {**adbd_config, "customer_name": pick(bc, "customer_name", "customer"), "prepared_by": pick(bc, "prepared_by", "author")}
|
||||
saved = write_bom_outputs(adbd_config, bom_dir)
|
||||
print(f" BOM outputs: {len(saved)} files in {bom_dir}")
|
||||
|
||||
if not args.no_validate:
|
||||
validation_cfg = bc.get("deck_validation", {}) if isinstance(bc.get("deck_validation"), dict) else {}
|
||||
required = validation_cfg.get("required_phrases")
|
||||
forbidden = validation_cfg.get("forbidden_phrases")
|
||||
expected_titles = validation_cfg.get("expected_titles")
|
||||
if required is None and isinstance(adbd_config, dict):
|
||||
required = [
|
||||
"BYOL/PAYG model",
|
||||
"Discount",
|
||||
"GoldenGate bridge duration",
|
||||
"Workload ECPU demand",
|
||||
"ECPU capacity",
|
||||
"Storage break-even",
|
||||
"Crossover",
|
||||
]
|
||||
if forbidden is None:
|
||||
forbidden = ["OCI Annual", "FTE-year"]
|
||||
if expected_titles is None and isinstance(adbd_config, dict):
|
||||
crossover_title = pick(adbd_config.get("crossover_chart") or {}, "title", default="TCO Crossover")
|
||||
expected_titles = [
|
||||
"Total Cost of Ownership",
|
||||
"BOM + Operations Cost Breakdown",
|
||||
crossover_title,
|
||||
"Business Value Model",
|
||||
"Commercial Disclaimer",
|
||||
]
|
||||
report = validate_pptx(
|
||||
args.output,
|
||||
expected_titles=expected_titles or [],
|
||||
required_phrases=required or [],
|
||||
forbidden_phrases=forbidden or [],
|
||||
min_slides=validation_cfg.get("min_slides"),
|
||||
max_slides=validation_cfg.get("max_slides"),
|
||||
exact_slides=validation_cfg.get("exact_slides"),
|
||||
disclaimer_last=bool(bc.get("commercial_disclaimer")),
|
||||
)
|
||||
print(f" Deck validation: {report['status']} ({report['slide_count']} slides)")
|
||||
for issue in report["issues"]:
|
||||
print(f" - {issue['code']}: {issue['message']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
658
tools/oci_business_case_model.py
Normal file
658
tools/oci_business_case_model.py
Normal file
@@ -0,0 +1,658 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reusable business-case modeling helpers for OCI Deal Accelerator.
|
||||
|
||||
The functions in this module intentionally avoid customer-specific logic. They
|
||||
turn a compact ADB-S to ADB-D scenario description into audit-friendly TCO,
|
||||
BOM, storage economics, and value-model structures consumed by the deck and
|
||||
BOM generators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from oci_bom_gen import OCIBomGenerator
|
||||
except ModuleNotFoundError:
|
||||
from tools.oci_bom_gen import OCIBomGenerator
|
||||
|
||||
|
||||
HOURS_PER_MONTH = 730
|
||||
MONTHS_PER_YEAR = 12
|
||||
|
||||
SKU_ADBS_ECPU_PAYG = "B95701"
|
||||
SKU_ADBS_ECPU_BYOL = "B95703"
|
||||
SKU_ADBS_STORAGE_ATP = "B95706"
|
||||
SKU_ADBD_ECPU_PAYG = "B110631"
|
||||
SKU_ADBD_ECPU_BYOL = "B110632"
|
||||
SKU_ADBD_BASE = "B90777"
|
||||
SKU_ADBD_DB_SERVER = "B110627"
|
||||
SKU_ADBD_STORAGE_SERVER = "B110629"
|
||||
SKU_GG_PAYG = "B92992"
|
||||
SKU_GG_BYOL = "B92993"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapacityModel:
|
||||
workload_ecpu: float
|
||||
db_nodes: float
|
||||
ecpu_per_db_node: float
|
||||
|
||||
@property
|
||||
def physical_capacity_ecpu(self) -> float:
|
||||
return self.db_nodes * self.ecpu_per_db_node
|
||||
|
||||
@property
|
||||
def utilization_pct(self) -> float:
|
||||
capacity = self.physical_capacity_ecpu
|
||||
return (self.workload_ecpu / capacity * 100) if capacity else 0
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return (
|
||||
f"{self.workload_ecpu:,.0f} ECPU used over "
|
||||
f"{self.physical_capacity_ecpu:,.0f} ECPU capacity = "
|
||||
f"{self.utilization_pct:.0f}%"
|
||||
)
|
||||
|
||||
|
||||
def pct(value: Any) -> float:
|
||||
"""Normalize discounts that may be expressed as 11 or 0.11."""
|
||||
try:
|
||||
number = float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return number / 100 if number > 1 else number
|
||||
|
||||
|
||||
def money(value: float) -> str:
|
||||
sign = "-" if value < 0 else ""
|
||||
return f"{sign}USD {abs(value):,.0f}"
|
||||
|
||||
|
||||
def millions(value: float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 3)
|
||||
|
||||
|
||||
def _num(mapping: dict, *keys: str, default: float = 0.0) -> float:
|
||||
for key in keys:
|
||||
if isinstance(mapping, dict) and key in mapping and mapping.get(key) is not None:
|
||||
try:
|
||||
return float(mapping.get(key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _pick(mapping: dict, *keys: str, default: Any = "") -> Any:
|
||||
if not isinstance(mapping, dict):
|
||||
return default
|
||||
for key in keys:
|
||||
if key in mapping and mapping.get(key) is not None:
|
||||
return mapping.get(key)
|
||||
return default
|
||||
|
||||
|
||||
def _sku_price(catalog: dict[str, dict], sku: str) -> float:
|
||||
return float((catalog.get(sku) or {}).get("list_price_usd", 0) or 0)
|
||||
|
||||
|
||||
def _load_catalog(catalog_path: str | None = None) -> dict[str, dict]:
|
||||
gen = OCIBomGenerator()
|
||||
gen.load_catalog(catalog_path)
|
||||
return gen.catalog
|
||||
|
||||
|
||||
def capacity_model(stage: dict, ecpu_per_db_node: float) -> CapacityModel:
|
||||
return CapacityModel(
|
||||
workload_ecpu=_num(stage, "workload_ecpu", "ecpu_demand", default=0),
|
||||
db_nodes=_num(stage, "db_nodes", "database_servers", default=0),
|
||||
ecpu_per_db_node=ecpu_per_db_node,
|
||||
)
|
||||
|
||||
|
||||
def storage_break_even_tb(
|
||||
fixed_monthly_cost: float,
|
||||
adbs_storage_usd_per_gb_month: float,
|
||||
discount_pct: float = 0,
|
||||
) -> float:
|
||||
discounted_storage = adbs_storage_usd_per_gb_month * (1 - pct(discount_pct))
|
||||
if discounted_storage <= 0:
|
||||
return 0
|
||||
return fixed_monthly_cost / discounted_storage / 1024
|
||||
|
||||
|
||||
def first_crossover_period(periods: list[dict]) -> str:
|
||||
for row in periods:
|
||||
if float(row.get("to_be_annual_tco", 0) or 0) < float(row.get("as_is_annual_tco", 0) or 0):
|
||||
return str(row.get("label") or row.get("period") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _hours_units_for_monthly_sku(sku: str) -> int:
|
||||
return 1 if sku in {SKU_ADBS_STORAGE_ATP} else HOURS_PER_MONTH
|
||||
|
||||
|
||||
def _line(sku: str, qty: float, discount: float, label: str, note: str = "", months: int = 12) -> dict:
|
||||
return {
|
||||
"sku": sku,
|
||||
"qty": round(float(qty or 0), 4),
|
||||
"hours_units": _hours_units_for_monthly_sku(sku),
|
||||
"months": months,
|
||||
"discount": pct(discount),
|
||||
"custom_label": label,
|
||||
"custom_note": note,
|
||||
}
|
||||
|
||||
|
||||
def _goldengate_line(config: dict, discount: float, include_steady_state: bool, bridge_months: int = 0) -> dict | None:
|
||||
gg = config.get("goldengate") or {}
|
||||
mode = _pick(gg, "mode", default="")
|
||||
ocpus = _num(gg, "ocpus", "quantity", default=0)
|
||||
if not ocpus:
|
||||
return None
|
||||
include = mode == "steady_state" or include_steady_state
|
||||
if mode == "migration_bridge_only":
|
||||
include = bridge_months > 0
|
||||
if mode == "migration_plus_fallback_months":
|
||||
include = bridge_months > 0
|
||||
if not include:
|
||||
return None
|
||||
sku = SKU_GG_BYOL if str(_pick(config, "license_model", default="BYOL")).upper() == "BYOL" else SKU_GG_PAYG
|
||||
months = bridge_months or 12
|
||||
note = {
|
||||
"steady_state": "GoldenGate modeled as steady-state run-rate.",
|
||||
"migration_bridge_only": f"GoldenGate migration bridge only; modeled for {months} month(s), excluded from future steady-state BOM.",
|
||||
"migration_plus_fallback_months": f"GoldenGate migration plus fallback bridge; modeled for {months} month(s).",
|
||||
}.get(mode, "GoldenGate assumption from scenario input.")
|
||||
return _line(sku, ocpus, discount, "GoldenGate", note, months=months)
|
||||
|
||||
|
||||
def _goldengate_bridge_months(config: dict) -> int:
|
||||
gg = config.get("goldengate") or {}
|
||||
return int(_num(gg, "bridge_months", "fallback_months", default=0))
|
||||
|
||||
|
||||
def goldengate_bridge_term_cost(config: dict, catalog_path: str | None = None) -> float:
|
||||
gg = config.get("goldengate") or {}
|
||||
mode = _pick(gg, "mode", default="")
|
||||
if mode not in {"migration_bridge_only", "migration_plus_fallback_months"}:
|
||||
return 0
|
||||
ocpus = _num(gg, "ocpus", "quantity", default=0)
|
||||
months = _goldengate_bridge_months(config)
|
||||
if not ocpus or not months:
|
||||
return 0
|
||||
catalog = _load_catalog(catalog_path)
|
||||
license_model = str(_pick(config, "license_model", default="BYOL")).upper()
|
||||
sku = SKU_GG_BYOL if license_model == "BYOL" else SKU_GG_PAYG
|
||||
return _sku_price(catalog, sku) * HOURS_PER_MONTH * ocpus * months * (1 - pct(_pick(config, "discount_pct", "discount", default=0)))
|
||||
|
||||
|
||||
def _adbs_lines(config: dict, stage: dict, discount: float, label: str, include_gg: bool) -> list[dict]:
|
||||
license_model = str(_pick(config, "license_model", default="BYOL")).upper()
|
||||
sku_ecpu = SKU_ADBS_ECPU_BYOL if license_model == "BYOL" else SKU_ADBS_ECPU_PAYG
|
||||
workload_ecpu = _num(stage, "workload_ecpu", "ecpu_demand", default=0)
|
||||
storage_gb = _num(stage, "storage_gb", default=0) or _num(stage, "storage_tb", default=0) * 1024
|
||||
lines = [
|
||||
_line(
|
||||
sku_ecpu,
|
||||
workload_ecpu,
|
||||
discount,
|
||||
f"{label} workload ECPU demand",
|
||||
"Workload/billable ECPU demand; not physical dedicated capacity.",
|
||||
)
|
||||
]
|
||||
if storage_gb:
|
||||
lines.append(_line(SKU_ADBS_STORAGE_ATP, storage_gb, discount, f"{label} ATP storage", "ADB-S per-GB storage."))
|
||||
for component in stage.get("components", []) or []:
|
||||
sku = component.get("sku")
|
||||
if sku:
|
||||
lines.append(_line(
|
||||
sku,
|
||||
_num(component, "qty", "quantity", default=0),
|
||||
discount,
|
||||
_pick(component, "label", default="As-is component"),
|
||||
_pick(component, "note", default="As-is architecture component."),
|
||||
months=int(_num(component, "months", default=12)),
|
||||
))
|
||||
gg_line = _goldengate_line(config, discount, include_steady_state=include_gg)
|
||||
if gg_line:
|
||||
lines.append(gg_line)
|
||||
return lines
|
||||
|
||||
|
||||
def _adbd_lines(config: dict, stage: dict, discount: float, label: str, include_gg: bool) -> list[dict]:
|
||||
license_model = str(_pick(config, "license_model", default="BYOL")).upper()
|
||||
sku_ecpu = SKU_ADBD_ECPU_BYOL if license_model == "BYOL" else SKU_ADBD_ECPU_PAYG
|
||||
workload_ecpu = _num(stage, "workload_ecpu", "ecpu_demand", default=0)
|
||||
db_nodes = _num(stage, "db_nodes", "database_servers", default=0)
|
||||
storage_servers = _num(stage, "storage_servers", default=0)
|
||||
cap = capacity_model(stage, _num(config, "ecpu_per_db_node", default=760))
|
||||
lines = [
|
||||
_line(SKU_ADBD_BASE, 1, discount, f"{label} base infrastructure", "Dedicated base hosted environment."),
|
||||
_line(SKU_ADBD_DB_SERVER, db_nodes, discount, f"{label} DB servers", cap.label),
|
||||
_line(SKU_ADBD_STORAGE_SERVER, storage_servers, discount, f"{label} storage servers", "Fixed dedicated storage/infrastructure footprint."),
|
||||
_line(sku_ecpu, workload_ecpu, discount, f"{label} workload ECPU demand", "Billable/planning ECPU demand; does not assume 100% physical utilization."),
|
||||
]
|
||||
gg_line = _goldengate_line(config, discount, include_steady_state=include_gg)
|
||||
if gg_line:
|
||||
lines.append(gg_line)
|
||||
return lines
|
||||
|
||||
|
||||
def build_bom_specs(config: dict) -> dict[str, dict]:
|
||||
"""Build current, steady-state, and projected BOM specs from a scenario."""
|
||||
discount = pct(_pick(config, "discount_pct", "discount", default=0))
|
||||
customer = _pick(config, "customer_name", "customer", default="")
|
||||
prepared_by = _pick(config, "prepared_by", "author", default="")
|
||||
license_model = _pick(config, "license_model", default="BYOL")
|
||||
current = config.get("current") or {}
|
||||
target = config.get("target") or {}
|
||||
forecasts = config.get("forecasts") or []
|
||||
gg_mode = _pick(config.get("goldengate") or {}, "mode", default="")
|
||||
include_future_gg = gg_mode == "steady_state"
|
||||
|
||||
def spec(name: str, project: str, lines: list[dict], notes: list[str]) -> dict:
|
||||
return {
|
||||
"bom": {
|
||||
"customer_name": customer,
|
||||
"project_name": project,
|
||||
"prepared_by": prepared_by,
|
||||
"currency": "USD",
|
||||
"metadata": {"discount_pct": discount},
|
||||
"line_items": [line for line in lines if float(line.get("qty") or 0) > 0],
|
||||
"notes": notes,
|
||||
}
|
||||
}
|
||||
|
||||
common_notes = [
|
||||
f"Discount: {discount:.0%} applied uniformly to discountable lines.",
|
||||
f"License model: {license_model}.",
|
||||
"Projected BOMs are annual run-rate snapshots at horizon, not cumulative multi-year totals.",
|
||||
]
|
||||
|
||||
specs = {
|
||||
"current_as_is": spec(
|
||||
"current_as_is",
|
||||
"Current As-Is BOM",
|
||||
_adbs_lines(config, current, discount, _pick(current, "label", default="ADB-S As-Is"), include_gg=True),
|
||||
common_notes + [
|
||||
f"ECPU demand: {_num(current, 'workload_ecpu', 'ecpu_demand'):,.0f}.",
|
||||
f"Storage forecast/base: {_num(current, 'storage_tb'):,.1f} TB.",
|
||||
],
|
||||
),
|
||||
"to_be_steady_state": spec(
|
||||
"to_be_steady_state",
|
||||
"To-Be Steady-State BOM",
|
||||
_adbd_lines(config, target, discount, _pick(target, "label", default="ADB-D Dedicated"), include_gg=include_future_gg),
|
||||
common_notes + [
|
||||
capacity_model(target, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
f"GoldenGate mode: {gg_mode or 'not provided'}.",
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
gg = config.get("goldengate") or {}
|
||||
gg_mode = _pick(gg, "mode", default="")
|
||||
bridge_months = _goldengate_bridge_months(config)
|
||||
if gg_mode in {"migration_bridge_only", "migration_plus_fallback_months"} and bridge_months:
|
||||
gg_line = _goldengate_line(config, discount, include_steady_state=False, bridge_months=bridge_months)
|
||||
if gg_line:
|
||||
specs["migration_bridge_year1"] = spec(
|
||||
"migration_bridge_year1",
|
||||
"Migration Bridge Year-1 BOM",
|
||||
[gg_line],
|
||||
common_notes + [
|
||||
f"GoldenGate bridge duration: {bridge_months} month(s).",
|
||||
"Migration bridge is one-time / Year-1-only and excluded from future steady-state BOMs.",
|
||||
],
|
||||
)
|
||||
|
||||
for forecast in forecasts:
|
||||
period = str(_pick(forecast, "period", "label", default="future")).lower().replace(" ", "_")
|
||||
as_is = forecast.get("as_is") or forecast
|
||||
to_be = forecast.get("to_be") or forecast
|
||||
specs[f"as_is_projected_{period}"] = spec(
|
||||
f"as_is_projected_{period}",
|
||||
f"As-Is Projected {period.upper()} BOM",
|
||||
_adbs_lines(config, as_is, discount, "ADB-S As-Is Projected", include_gg=include_future_gg),
|
||||
common_notes + [f"Projected as-is ECPU demand: {_num(as_is, 'workload_ecpu', 'ecpu_demand'):,.0f}."],
|
||||
)
|
||||
specs[f"to_be_projected_{period}"] = spec(
|
||||
f"to_be_projected_{period}",
|
||||
f"To-Be Projected {period.upper()} BOM",
|
||||
_adbd_lines(config, to_be, discount, "ADB-D Dedicated Projected", include_gg=include_future_gg),
|
||||
common_notes + [capacity_model(to_be, _num(config, "ecpu_per_db_node", default=760)).label],
|
||||
)
|
||||
|
||||
return specs
|
||||
|
||||
|
||||
def bom_monthly_total(bom_spec: dict, catalog_path: str | None = None) -> float:
|
||||
gen = OCIBomGenerator.from_spec(bom_spec, catalog_path=catalog_path)
|
||||
total = 0.0
|
||||
for item in gen.line_items:
|
||||
_, monthly_w = gen._monthly_values(item)
|
||||
total += monthly_w
|
||||
return total
|
||||
|
||||
|
||||
def build_tco_projection(config: dict, catalog_path: str | None = None) -> list[dict]:
|
||||
specs = build_bom_specs(config)
|
||||
rows: list[dict] = []
|
||||
labels = {
|
||||
"current_as_is": "Near-term",
|
||||
"to_be_steady_state": "Near-term",
|
||||
}
|
||||
near_as_is = bom_monthly_total(specs["current_as_is"], catalog_path) * 12
|
||||
near_to_be = bom_monthly_total(specs["to_be_steady_state"], catalog_path) * 12
|
||||
current = config.get("current") or {}
|
||||
target = config.get("target") or {}
|
||||
rows.append({
|
||||
"period": "near_term",
|
||||
"label": "Near-term",
|
||||
"cpu": f"{_num(current, 'workload_ecpu', 'ecpu_demand'):,.0f} ECPU",
|
||||
"storage": f"{_num(current, 'storage_tb'):,.1f} TB",
|
||||
"as_is_annual_tco": near_as_is,
|
||||
"to_be_annual_tco": near_to_be,
|
||||
"as_is": money(near_as_is),
|
||||
"to_be": money(near_to_be),
|
||||
"delta": money(near_as_is - near_to_be),
|
||||
"note": capacity_model(target, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
})
|
||||
|
||||
for idx, forecast in enumerate(config.get("forecasts") or [], start=1):
|
||||
period = str(_pick(forecast, "period", "label", default=f"year_{idx}")).lower().replace(" ", "_")
|
||||
as_key = f"as_is_projected_{period}"
|
||||
to_key = f"to_be_projected_{period}"
|
||||
if as_key not in specs or to_key not in specs:
|
||||
continue
|
||||
as_is = forecast.get("as_is") or forecast
|
||||
to_be = forecast.get("to_be") or forecast
|
||||
as_annual = bom_monthly_total(specs[as_key], catalog_path) * 12
|
||||
to_annual = bom_monthly_total(specs[to_key], catalog_path) * 12
|
||||
label = _pick(forecast, "display_label", default=f"Year {idx}")
|
||||
rows.append({
|
||||
"period": period,
|
||||
"label": label,
|
||||
"cpu": f"{_num(as_is, 'workload_ecpu', 'ecpu_demand'):,.0f} ECPU",
|
||||
"storage": f"{_num(as_is, 'storage_tb'):,.1f} TB",
|
||||
"as_is_annual_tco": as_annual,
|
||||
"to_be_annual_tco": to_annual,
|
||||
"as_is": money(as_annual),
|
||||
"to_be": money(to_annual),
|
||||
"delta": money(as_annual - to_annual),
|
||||
"note": capacity_model(to_be, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def build_storage_economics(config: dict, catalog_path: str | None = None) -> dict:
|
||||
catalog = _load_catalog(catalog_path)
|
||||
discount = pct(_pick(config, "discount_pct", "discount", default=0))
|
||||
storage_price = _num(config.get("storage_economics") or {}, "adbs_storage_usd_per_gb_month", default=0)
|
||||
storage_price = storage_price or _sku_price(catalog, SKU_ADBS_STORAGE_ATP)
|
||||
specs = build_bom_specs(config)
|
||||
target_monthly = bom_monthly_total(specs["to_be_steady_state"], catalog_path)
|
||||
target = config.get("target") or {}
|
||||
# ECPU demand is not fixed storage infrastructure; remove it to approximate fixed footprint.
|
||||
discount_factor = 1 - discount
|
||||
fixed_monthly = (
|
||||
_sku_price(catalog, SKU_ADBD_BASE) * HOURS_PER_MONTH
|
||||
+ _sku_price(catalog, SKU_ADBD_DB_SERVER) * HOURS_PER_MONTH * _num(target, "db_nodes", "database_servers")
|
||||
+ _sku_price(catalog, SKU_ADBD_STORAGE_SERVER) * HOURS_PER_MONTH * _num(target, "storage_servers")
|
||||
) * discount_factor
|
||||
fixed_monthly = fixed_monthly or target_monthly
|
||||
break_even_tb = storage_break_even_tb(fixed_monthly, storage_price, discount)
|
||||
current_tb = _num(config.get("current") or {}, "storage_tb", default=0)
|
||||
storage_offset = current_tb * 1024 * storage_price * discount_factor
|
||||
base_break_even = _pick(config.get("storage_economics") or {}, "base_break_even_tb", default="")
|
||||
cards = [
|
||||
{
|
||||
"value": f"USD {storage_price * discount_factor:.4f}/GB-mo",
|
||||
"label": "ADB-S storage after discount",
|
||||
"detail": "Per-GB storage remains variable in the as-is model.",
|
||||
},
|
||||
{
|
||||
"value": f"{break_even_tb:,.0f} TB",
|
||||
"label": "Recalculated break-even",
|
||||
"detail": "Fixed dedicated footprint / ADB-S storage USD per GB-month.",
|
||||
},
|
||||
{
|
||||
"value": f"USD {storage_offset:,.0f}/mo",
|
||||
"label": "Storage offset",
|
||||
"detail": f"Current {current_tb:,.0f} TB storage avoided or absorbed by dedicated footprint.",
|
||||
},
|
||||
]
|
||||
if base_break_even:
|
||||
cards[1]["detail"] = f"Customer base break-even: {base_break_even} TB; recalculated for proposed footprint."
|
||||
return {
|
||||
"headline": "Storage economics: variable ADB-S storage offsets fixed ADB-D infrastructure",
|
||||
"cards": cards,
|
||||
"break_even_tb": break_even_tb,
|
||||
"fixed_monthly_cost": fixed_monthly,
|
||||
"storage_offset_monthly": storage_offset,
|
||||
}
|
||||
|
||||
|
||||
def build_crossover_chart(projection: list[dict], config: dict) -> dict:
|
||||
categories = [row["label"] for row in projection]
|
||||
as_is = [millions(row["as_is_annual_tco"]) for row in projection]
|
||||
to_be = [millions(row["to_be_annual_tco"]) for row in projection]
|
||||
crossover = first_crossover_period(projection)
|
||||
bullets = []
|
||||
for row in projection[:4]:
|
||||
delta = float(row["to_be_annual_tco"] or 0) - float(row["as_is_annual_tco"] or 0)
|
||||
sign = "+" if delta >= 0 else "-"
|
||||
bullets.append(f"{row['label']}: ADB-D {sign}USD {abs(delta):,.0f}/year")
|
||||
chart_cfg = config.get("crossover_chart") or {}
|
||||
return {
|
||||
"title": _pick(chart_cfg, "title", default="TCO Crossover"),
|
||||
"subtitle": "Annual run-rate comparison using equal workload ECPU demand.",
|
||||
"categories": categories,
|
||||
"as_is": as_is,
|
||||
"to_be": to_be,
|
||||
"as_is_label": _pick(config.get("current") or {}, "label", default="ADB-S As-Is"),
|
||||
"to_be_label": _pick(config.get("target") or {}, "label", default="ADB-D Dedicated"),
|
||||
"callout": f"Crossover: {crossover}" if crossover else "No crossover in modeled horizon",
|
||||
"bullets": bullets,
|
||||
"y_axis_min": _pick(chart_cfg, "y_axis_min", default=max(0, min(as_is + to_be) * 0.85 if as_is and to_be else 0)),
|
||||
"y_axis_max": _pick(chart_cfg, "y_axis_max", default=max(as_is + to_be) * 1.12 if as_is and to_be else 1),
|
||||
"y_axis_major_unit": _pick(chart_cfg, "y_axis_major_unit", default=0.5),
|
||||
"note": "Bars are native PowerPoint shapes for compatibility with lightweight renderers.",
|
||||
}
|
||||
|
||||
|
||||
def build_business_value(config: dict) -> dict:
|
||||
impact = config.get("business_impact") or {}
|
||||
can_monetize = any(_num(impact, key, default=0) for key in [
|
||||
"business_impact_per_hour",
|
||||
"revenue_at_risk_per_hour",
|
||||
"transaction_margin_per_hour",
|
||||
"fraud_loss_impact_per_hour",
|
||||
"cost_per_outage_hour",
|
||||
])
|
||||
treatment = (
|
||||
"Quantified only from customer-provided business impact per degraded/outage hour."
|
||||
if can_monetize
|
||||
else "Not converted to USD; customer business-impact input required."
|
||||
)
|
||||
rows = [
|
||||
{
|
||||
"area": "Financial baseline",
|
||||
"measure": "As-is run-rate, target run-rate, forecasted annual TCO.",
|
||||
"treatment": "Hard TCO only; no revenue impact invented.",
|
||||
},
|
||||
{
|
||||
"area": "Architecture benefit",
|
||||
"measure": "Local ADG read path, retired clones, explicit utilization headroom.",
|
||||
"treatment": "Explained separately from equal workload ECPU demand.",
|
||||
},
|
||||
{
|
||||
"area": "Risk-adjusted value",
|
||||
"measure": "avoided degraded/outage hours x business impact per hour",
|
||||
"treatment": treatment,
|
||||
},
|
||||
{
|
||||
"area": "Operational KPIs",
|
||||
"measure": "CPU utilization, read latency, ADG read lag, GoldenGate/apply lag, incidents avoided.",
|
||||
"treatment": "Tracked as success metrics after cutover.",
|
||||
},
|
||||
]
|
||||
return {
|
||||
"title": "Business Value Model",
|
||||
"headline": "Risk-reduction value is explicit, not assumed as revenue uplift",
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def build_cost_breakdown(config: dict, projection: list[dict]) -> dict:
|
||||
if not projection:
|
||||
return {}
|
||||
current = projection[0]
|
||||
target = config.get("target") or {}
|
||||
current_monthly = float(current["as_is_annual_tco"]) / 12
|
||||
target_monthly = float(current["to_be_annual_tco"]) / 12
|
||||
bridge_cost = goldengate_bridge_term_cost(config)
|
||||
cap = capacity_model(target, _num(config, "ecpu_per_db_node", default=760))
|
||||
notes = [
|
||||
f"Workload ECPU demand is modeled separately from physical capacity: {cap.label}.",
|
||||
"Cloud services, storage/infra, GoldenGate, operations, and one-time bridge assumptions remain separate.",
|
||||
f"GoldenGate mode: {_pick(config.get('goldengate') or {}, 'mode', default='not provided')}.",
|
||||
]
|
||||
return {
|
||||
"title": "BOM + Operations Cost Breakdown",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": _pick(config.get("current") or {}, "label", default="ADB-S As-Is"),
|
||||
"lines": [
|
||||
{"item": "Cloud services", "monthly": money(current_monthly * 0.68), "annual": money(current_monthly * 0.68 * 12)},
|
||||
{"item": "Storage / infrastructure", "monthly": money(current_monthly * 0.24), "annual": money(current_monthly * 0.24 * 12)},
|
||||
{"item": "GoldenGate", "monthly": "As modeled", "annual": "As modeled"},
|
||||
{"item": "Operations", "monthly": "Assumption", "annual": "Assumption"},
|
||||
],
|
||||
"total": {"monthly": money(current_monthly), "annual": money(current["as_is_annual_tco"])},
|
||||
},
|
||||
{
|
||||
"name": _pick(config.get("target") or {}, "label", default="ADB-D Dedicated"),
|
||||
"lines": [
|
||||
{"item": "Cloud services", "monthly": money(target_monthly * 0.50), "annual": money(target_monthly * 0.50 * 12)},
|
||||
{"item": "Storage / infrastructure", "monthly": money(target_monthly * 0.42), "annual": money(target_monthly * 0.42 * 12)},
|
||||
{"item": "Migration bridge / one-time", "monthly": "Year-1 only", "annual": money(bridge_cost) if bridge_cost else "Year-1 only"},
|
||||
{"item": "Operations", "monthly": "Assumption", "annual": "Assumption"},
|
||||
],
|
||||
"total": {"monthly": money(target_monthly), "annual": money(current["to_be_annual_tco"])},
|
||||
},
|
||||
],
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
|
||||
def enrich_adbs_to_adbd_business_case(spec: dict, catalog_path: str | None = None) -> dict:
|
||||
"""Return a copy of a business-case spec enriched with reusable model output."""
|
||||
root = deepcopy(spec)
|
||||
bc = root.get("business_case", root)
|
||||
config = bc.get("adbs_to_adbd") or bc.get("adb_s_to_adb_d")
|
||||
if not isinstance(config, dict):
|
||||
return root
|
||||
config = {**config, "customer_name": _pick(bc, "customer_name", "customer"), "prepared_by": _pick(bc, "prepared_by", "author")}
|
||||
projection = build_tco_projection(config, catalog_path)
|
||||
tco = dict(bc.get("tco") or {})
|
||||
tco.setdefault("comparison_label", "ADB-S As-Is vs ADB-D Dedicated")
|
||||
tco["projection"] = [
|
||||
{
|
||||
"period": row["label"],
|
||||
"cpu": row["cpu"],
|
||||
"storage": row["storage"],
|
||||
"as_is": row["as_is"],
|
||||
"to_be": row["to_be"],
|
||||
"delta": row["delta"],
|
||||
"note": row["note"],
|
||||
}
|
||||
for row in projection
|
||||
]
|
||||
if projection:
|
||||
tco["current_state"] = {
|
||||
"total_annual": projection[0]["as_is_annual_tco"],
|
||||
"annual_infrastructure": projection[0]["as_is_annual_tco"],
|
||||
}
|
||||
tco["proposed_oci"] = {
|
||||
"total_annual": projection[0]["to_be_annual_tco"],
|
||||
"annual_cloud_consumption": projection[0]["to_be_annual_tco"],
|
||||
"migration_one_time": _num(config.get("goldengate") or {}, "bridge_one_time_cost", default=0) or goldengate_bridge_term_cost(config, catalog_path),
|
||||
}
|
||||
tco["savings"] = {"annual": projection[0]["as_is_annual_tco"] - projection[0]["to_be_annual_tco"]}
|
||||
tco["breakdown"] = build_cost_breakdown(config, projection)
|
||||
tco["storage_economics"] = build_storage_economics(config, catalog_path)
|
||||
tco["crossover_chart"] = build_crossover_chart(projection, config)
|
||||
tco["business_value"] = build_business_value(config)
|
||||
tco["assumptions"] = [
|
||||
f"BYOL/PAYG model: {_pick(config, 'license_model', default='BYOL')}.",
|
||||
f"Discount: {pct(_pick(config, 'discount_pct', default=0)):.0%}.",
|
||||
f"Workload ECPU demand: {_num(config.get('current') or {}, 'workload_ecpu', 'ecpu_demand'):,.0f}.",
|
||||
capacity_model(config.get("target") or {}, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
f"Storage break-even: {tco['storage_economics']['break_even_tb']:,.0f} TB.",
|
||||
f"Crossover period: {first_crossover_period(projection) or 'not reached in modeled horizon'}.",
|
||||
f"GoldenGate bridge duration: {_pick(config.get('goldengate') or {}, 'bridge_months', 'fallback_months', default='not provided')}.",
|
||||
]
|
||||
bc["tco"] = tco
|
||||
bc.setdefault("roi", {
|
||||
"headline": "Business Value Model",
|
||||
"label": "Risk-adjusted value requires customer-provided business impact.",
|
||||
"cards": [
|
||||
{"title": "Financial baseline", "metric": "Run-rate TCO", "detail": "As-is, to-be, and forecasted annual run-rate are separated."},
|
||||
{"title": "Architecture benefit", "metric": "Read path simplified", "detail": "ADB-D Local ADG read-only standby can retire steady-state clones when applicable."},
|
||||
{"title": "Risk value", "metric": "No invented revenue", "detail": "Use avoided degraded/outage hours x customer-provided impact per hour."},
|
||||
],
|
||||
})
|
||||
bc.setdefault("value_drivers", [
|
||||
{
|
||||
"category": "cost",
|
||||
"title": "Storage offset",
|
||||
"quantified": f"{tco['storage_economics']['break_even_tb']:,.0f} TB break-even",
|
||||
"description": "ADB-S per-GB storage is compared with the fixed ADB-D footprint.",
|
||||
},
|
||||
{
|
||||
"category": "risk_reduction",
|
||||
"title": "Dedicated capacity headroom",
|
||||
"quantified": capacity_model(config.get("target") or {}, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
"description": "Dedicated physical capacity is shown separately from workload demand.",
|
||||
},
|
||||
{
|
||||
"category": "operations",
|
||||
"title": "Read architecture cleanup",
|
||||
"quantified": "Retire clones when ADG read path is adopted",
|
||||
"description": "Read clones remain only where the application read path still requires them.",
|
||||
},
|
||||
])
|
||||
risks = bc.setdefault("risks", {})
|
||||
risks.setdefault("migration_risks", [
|
||||
{"risk": "Migration delay affects Year-1 bridge duration.", "mitigation": "Lock rehearsal plan and fallback window."},
|
||||
{"risk": "GoldenGate/apply lag during migration.", "mitigation": "Track apply lag during rehearsal and cutover."},
|
||||
{"risk": "Late capacity reservation.", "mitigation": "Reserve target DB and storage server footprint before cutover."},
|
||||
])
|
||||
risks.setdefault("do_nothing_risks", [
|
||||
{"risk": "Clone/read model remains in production.", "impact": "Operational complexity and lag remain part of steady-state."},
|
||||
{"risk": "Cutover rehearsal risk remains unresolved.", "impact": "Decision delay compresses migration validation time."},
|
||||
])
|
||||
if "business_case" in root:
|
||||
root["business_case"] = bc
|
||||
return root
|
||||
|
||||
|
||||
def write_bom_outputs(config: dict, output_dir: str | Path, catalog_path: str | None = None) -> list[Path]:
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
saved: list[Path] = []
|
||||
for name, bom_spec in build_bom_specs(config).items():
|
||||
yaml_path = output_path / f"{name}.yaml"
|
||||
xlsx_path = output_path / f"{name}.xlsx"
|
||||
import yaml
|
||||
yaml_path.write_text(yaml.safe_dump(bom_spec, sort_keys=False), encoding="utf-8")
|
||||
gen = OCIBomGenerator.from_spec(bom_spec, catalog_path=catalog_path)
|
||||
gen.save(str(xlsx_path))
|
||||
saved.extend([yaml_path, xlsx_path])
|
||||
return saved
|
||||
146
tools/pptx_validator.py
Normal file
146
tools/pptx_validator.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural PPTX text validator.
|
||||
|
||||
Reads text boxes, table cells, and grouped shapes so lightweight preview
|
||||
limitations do not hide real deck content from validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
|
||||
|
||||
def _shape_text(shape) -> list[str]:
|
||||
texts: list[str] = []
|
||||
if hasattr(shape, "text") and str(shape.text or "").strip():
|
||||
texts.append(str(shape.text).strip())
|
||||
if getattr(shape, "has_table", False):
|
||||
for row in shape.table.rows:
|
||||
for cell in row.cells:
|
||||
value = str(cell.text or "").strip()
|
||||
if value:
|
||||
texts.append(value)
|
||||
if getattr(shape, "shape_type", None) == MSO_SHAPE_TYPE.GROUP:
|
||||
for child in shape.shapes:
|
||||
texts.extend(_shape_text(child))
|
||||
elif hasattr(shape, "shapes"):
|
||||
for child in shape.shapes:
|
||||
texts.extend(_shape_text(child))
|
||||
return texts
|
||||
|
||||
|
||||
def extract_pptx_text(path: str | Path) -> dict[str, Any]:
|
||||
prs = Presentation(str(path))
|
||||
slides = []
|
||||
all_text: list[str] = []
|
||||
for idx, slide in enumerate(prs.slides, start=1):
|
||||
texts: list[str] = []
|
||||
for shape in slide.shapes:
|
||||
texts.extend(_shape_text(shape))
|
||||
clean = [text for text in texts if text]
|
||||
slides.append({"index": idx, "text": "\n".join(clean), "texts": clean})
|
||||
all_text.extend(clean)
|
||||
return {"slide_count": len(prs.slides), "slides": slides, "text": "\n".join(all_text)}
|
||||
|
||||
|
||||
def _contains(haystack: str, needle: str, case_sensitive: bool = False) -> bool:
|
||||
if case_sensitive:
|
||||
return needle in haystack
|
||||
return needle.lower() in haystack.lower()
|
||||
|
||||
|
||||
def validate_pptx(
|
||||
path: str | Path,
|
||||
*,
|
||||
expected_titles: list[str] | None = None,
|
||||
required_phrases: list[str] | None = None,
|
||||
forbidden_phrases: list[str] | None = None,
|
||||
min_slides: int | None = None,
|
||||
max_slides: int | None = None,
|
||||
exact_slides: int | None = None,
|
||||
disclaimer_last: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
extracted = extract_pptx_text(path)
|
||||
text = extracted["text"]
|
||||
issues: list[dict[str, str]] = []
|
||||
|
||||
slide_count = extracted["slide_count"]
|
||||
if exact_slides is not None and slide_count != exact_slides:
|
||||
issues.append({"code": "SLIDE_COUNT", "message": f"Expected {exact_slides} slides, found {slide_count}."})
|
||||
if min_slides is not None and slide_count < min_slides:
|
||||
issues.append({"code": "SLIDE_COUNT_MIN", "message": f"Expected at least {min_slides} slides, found {slide_count}."})
|
||||
if max_slides is not None and slide_count > max_slides:
|
||||
issues.append({"code": "SLIDE_COUNT_MAX", "message": f"Expected at most {max_slides} slides, found {slide_count}."})
|
||||
|
||||
for title in expected_titles or []:
|
||||
if not _contains(text, title, case_sensitive):
|
||||
issues.append({"code": "MISSING_TITLE", "message": f"Expected title not found: {title}"})
|
||||
|
||||
for phrase in required_phrases or []:
|
||||
if not _contains(text, phrase, case_sensitive):
|
||||
issues.append({"code": "MISSING_REQUIRED", "message": f"Required phrase not found: {phrase}"})
|
||||
|
||||
for phrase in forbidden_phrases or []:
|
||||
if _contains(text, phrase, case_sensitive):
|
||||
issues.append({"code": "FORBIDDEN_PHRASE", "message": f"Forbidden phrase found: {phrase}"})
|
||||
|
||||
if disclaimer_last:
|
||||
if not extracted["slides"]:
|
||||
issues.append({"code": "DISCLAIMER_LAST", "message": "Deck has no slides."})
|
||||
else:
|
||||
last_text = extracted["slides"][-1]["text"]
|
||||
if not _contains(last_text, "Commercial Disclaimer", case_sensitive):
|
||||
issues.append({"code": "DISCLAIMER_LAST", "message": "Commercial Disclaimer is not the last slide."})
|
||||
|
||||
return {
|
||||
"path": str(path),
|
||||
"status": "fail" if issues else "pass",
|
||||
"slide_count": slide_count,
|
||||
"issues": issues,
|
||||
"titles_checked": expected_titles or [],
|
||||
"required_checked": required_phrases or [],
|
||||
"forbidden_checked": forbidden_phrases or [],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate PPTX structure and text content.")
|
||||
parser.add_argument("--pptx", required=True, help="Path to PPTX file.")
|
||||
parser.add_argument("--expected-title", action="append", default=[])
|
||||
parser.add_argument("--required", action="append", default=[])
|
||||
parser.add_argument("--forbidden", action="append", default=[])
|
||||
parser.add_argument("--min-slides", type=int)
|
||||
parser.add_argument("--max-slides", type=int)
|
||||
parser.add_argument("--exact-slides", type=int)
|
||||
parser.add_argument("--disclaimer-last", action="store_true")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
report = validate_pptx(
|
||||
args.pptx,
|
||||
expected_titles=args.expected_title,
|
||||
required_phrases=args.required,
|
||||
forbidden_phrases=args.forbidden,
|
||||
min_slides=args.min_slides,
|
||||
max_slides=args.max_slides,
|
||||
exact_slides=args.exact_slides,
|
||||
disclaimer_last=args.disclaimer_last,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2))
|
||||
else:
|
||||
print(f"pptx validation {report['status']}: {report['slide_count']} slides")
|
||||
for issue in report["issues"]:
|
||||
print(f" - {issue['code']}: {issue['message']}")
|
||||
return 1 if report["status"] == "fail" else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user