forked from diegoecab/oci-deal-accelerator
Compare commits
2 Commits
automation
...
automation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aea208129 | ||
|
|
4552ef7226 |
@@ -86,7 +86,7 @@ Present these options as a compact numbered list. Each option has a bold title f
|
|||||||
DESIGN & PROPOSE
|
DESIGN & PROPOSE
|
||||||
─────────────────
|
─────────────────
|
||||||
1. 📋 Full proposal — *notes → architecture + deck + native PPTX diagram + costs*
|
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*
|
3. 📊 Slide deck — *architecture → .pptx with native OCI diagram*
|
||||||
4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
|
4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
|
||||||
|
|
||||||
@@ -124,6 +124,7 @@ Pick a number, or just describe what you need.
|
|||||||
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:
|
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.`
|
- `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 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.`
|
- `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.`
|
- `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.`
|
||||||
@@ -136,15 +137,29 @@ If the user asks for examples, or skips the menu and writes a natural-language r
|
|||||||
```
|
```
|
||||||
1. Describe the architecture you want to diagram (or paste a YAML spec if you have one).
|
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:
|
2. Which output format(s) do you want? Pick one or more:
|
||||||
(a) .drawio — editable technical diagram
|
(a) sketch — native .excalidraw whiteboard draft for brainstorming
|
||||||
(b) .pptx — native Oracle-style PowerPoint diagram/slide
|
(b) .drawio — editable technical diagram
|
||||||
(c) both
|
(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.
|
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 `_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.
|
- **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.
|
- **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.
|
- **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).
|
2. **Pre-generation review.** Confirm the component list with the user (REQUESTED + TECHNICAL DEPENDENCIES per the whitelist).
|
||||||
@@ -187,14 +202,22 @@ If the user asks for examples, or skips the menu and writes a natural-language r
|
|||||||
1. Parse input into `templates/business-case.yaml` structure
|
1. Parse input into `templates/business-case.yaml` structure
|
||||||
2. Identify business drivers and urgency from discovery notes
|
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/*`
|
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
|
4. For ADB-S to ADB-D cases, use the reusable `adbs_to_adbd` model:
|
||||||
5. Map value drivers (cost, risk, agility, innovation) with quantified evidence
|
- keep workload/billable ECPU demand separate from dedicated physical capacity (`DB nodes × ECPU per DB node`)
|
||||||
6. Assess migration risks from `kb/field-knowledge/gotchas.yaml` and do-nothing risks
|
- do not assume ADB-D uses 100% physical capacity; show utilization explicitly
|
||||||
7. Compare with alternatives using `kb/competitive/*`
|
- keep workload demand comparable between ADB-S and ADB-D unless customer data says otherwise
|
||||||
8. Generate implementation roadmap based on engagement tier
|
- model ADB-D fixed DB server + storage server infrastructure separately from workload ECPU
|
||||||
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
|
- keep ADB-S as-is components explicit: primaries, local ADG, cross-region standby, refreshable/read clones, read clones, GoldenGate
|
||||||
10. Output: business-case.pptx + business-case.yaml (reusable spec)
|
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`.
|
||||||
11. **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 the same structural deck verification described in option 4 step 8 (slide count, titles, chart types, disclaimer-last) before reporting completion.
|
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 **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 **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:
|
- If the user picks **11**, switch to finding intake mode:
|
||||||
@@ -263,7 +286,7 @@ After delivering an output, show elapsed time and offer the natural next step.
|
|||||||
```
|
```
|
||||||
✅ Done — [task description] completed in [Xm Ys]
|
✅ 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)
|
→ [B] Modify the architecture (add/remove/change services)
|
||||||
→ [C] Run Well-Architected review on this architecture
|
→ [C] Run Well-Architected review on this architecture
|
||||||
→ [D] Build a business case from this architecture
|
→ [D] Build a business case from this architecture
|
||||||
@@ -466,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:
|
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 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.
|
- **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`.
|
- **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`.
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -339,8 +339,11 @@ Option 8 in the menu generates a business case deck for customer internal approv
|
|||||||
| Cover | Dark - Title_Pillar | Customer name + subtitle |
|
| Cover | Dark - Title_Pillar | Customer name + subtitle |
|
||||||
| Executive Summary | Impact Statement | Bold 1-sentence opportunity |
|
| Executive Summary | Impact Statement | Bold 1-sentence opportunity |
|
||||||
| Business Drivers | Multi Statement | 3 key drivers: Why now |
|
| Business Drivers | Multi Statement | 3 key drivers: Why now |
|
||||||
| TCO Comparison | Blank + Table | Current vs OCI (3-5 year) |
|
| TCO Comparison | Blank + Table | Current/as-is run-rate vs target/to-be run-rate |
|
||||||
| ROI Headline | Blank + Metric | Big number (e.g., "2080% ROI") |
|
| 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 |
|
| Value Drivers | Blank + Cards | 4 categories: Cost, Risk, Agility, Innovation |
|
||||||
| Risk Assessment | Blank + 2-Column | Migration risks vs Do-nothing risks |
|
| Risk Assessment | Blank + 2-Column | Migration risks vs Do-nothing risks |
|
||||||
| Roadmap | Blank + Timeline | Implementation phases |
|
| Roadmap | Blank + Timeline | Implementation phases |
|
||||||
@@ -348,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.
|
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
|
```bash
|
||||||
python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case.pptx
|
python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case.pptx
|
||||||
```
|
```
|
||||||
|
|||||||
53
SKILL.md
53
SKILL.md
@@ -84,7 +84,7 @@ Present these options as a compact numbered list. Each option has a bold title f
|
|||||||
DESIGN & PROPOSE
|
DESIGN & PROPOSE
|
||||||
─────────────────
|
─────────────────
|
||||||
1. 📋 Full proposal — *notes → architecture + deck + native PPTX diagram + costs*
|
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*
|
3. 📊 Slide deck — *architecture → .pptx with native OCI diagram*
|
||||||
4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
|
4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
|
||||||
|
|
||||||
@@ -122,6 +122,7 @@ Pick a number, or just describe what you need.
|
|||||||
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:
|
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.`
|
- `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 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.`
|
- `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.`
|
- `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.`
|
||||||
@@ -134,15 +135,29 @@ If the user asks for examples, or skips the menu and writes a natural-language r
|
|||||||
```
|
```
|
||||||
1. Describe the architecture you want to diagram (or paste a YAML spec if you have one).
|
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:
|
2. Which output format(s) do you want? Pick one or more:
|
||||||
(a) .drawio — editable technical diagram
|
(a) sketch — native .excalidraw whiteboard draft for brainstorming
|
||||||
(b) .pptx — native Oracle-style PowerPoint diagram/slide
|
(b) .drawio — editable technical diagram
|
||||||
(c) both
|
(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.
|
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 `_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.
|
- **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.
|
- **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.
|
- **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).
|
2. **Pre-generation review.** Confirm the component list with the user (REQUESTED + TECHNICAL DEPENDENCIES per the whitelist).
|
||||||
@@ -185,14 +200,22 @@ If the user asks for examples, or skips the menu and writes a natural-language r
|
|||||||
1. Parse input into `templates/business-case.yaml` structure
|
1. Parse input into `templates/business-case.yaml` structure
|
||||||
2. Identify business drivers and urgency from discovery notes
|
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/*`
|
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
|
4. For ADB-S to ADB-D cases, use the reusable `adbs_to_adbd` model:
|
||||||
5. Map value drivers (cost, risk, agility, innovation) with quantified evidence
|
- keep workload/billable ECPU demand separate from dedicated physical capacity (`DB nodes × ECPU per DB node`)
|
||||||
6. Assess migration risks from `kb/field-knowledge/gotchas.yaml` and do-nothing risks
|
- do not assume ADB-D uses 100% physical capacity; show utilization explicitly
|
||||||
7. Compare with alternatives using `kb/competitive/*`
|
- keep workload demand comparable between ADB-S and ADB-D unless customer data says otherwise
|
||||||
8. Generate implementation roadmap based on engagement tier
|
- model ADB-D fixed DB server + storage server infrastructure separately from workload ECPU
|
||||||
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
|
- keep ADB-S as-is components explicit: primaries, local ADG, cross-region standby, refreshable/read clones, read clones, GoldenGate
|
||||||
10. Output: business-case.pptx + business-case.yaml (reusable spec)
|
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`.
|
||||||
11. **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 the same structural deck verification described in option 4 step 8 (slide count, titles, chart types, disclaimer-last) before reporting completion.
|
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 **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 **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:
|
- If the user picks **11**, switch to finding intake mode:
|
||||||
@@ -261,7 +284,7 @@ After delivering an output, show elapsed time and offer the natural next step.
|
|||||||
```
|
```
|
||||||
✅ Done — [task description] completed in [Xm Ys]
|
✅ 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)
|
→ [B] Modify the architecture (add/remove/change services)
|
||||||
→ [C] Run Well-Architected review on this architecture
|
→ [C] Run Well-Architected review on this architecture
|
||||||
→ [D] Build a business case from this architecture
|
→ [D] Build a business case from this architecture
|
||||||
@@ -464,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:
|
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 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.
|
- **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`.
|
- **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`.
|
||||||
|
|||||||
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"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
last_verified: '2026-05-01'
|
last_verified: '2026-07-01'
|
||||||
source: Oracle OCI Price List + internal BOM reference v1.73
|
source: Oracle OCI Price List + internal BOM reference v1.73
|
||||||
description: 'OCI SKU catalog for BOM generation. ~160 SKUs across 14+ categories. Prices are approximate and for estimation
|
description: 'OCI SKU catalog for BOM generation. ~160 SKUs across 14+ categories. Prices are approximate and for estimation
|
||||||
only. Always verify against https://www.oracle.com/cloud/pricing/
|
only. Always verify against https://www.oracle.com/cloud/pricing/
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ business_case:
|
|||||||
# --- Header ---
|
# --- Header ---
|
||||||
customer_name: ""
|
customer_name: ""
|
||||||
prepared_by: ""
|
prepared_by: ""
|
||||||
|
prepared_by_role: "" # e.g., "Solutions Architect"
|
||||||
|
cover_subtitle: "" # e.g., "ADB-S to ADB-D Migration Business Case"
|
||||||
date: ""
|
date: ""
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
status: draft # draft | review | approved
|
status: draft # draft | review | approved
|
||||||
@@ -36,6 +38,66 @@ business_case:
|
|||||||
annual_spend: 0 # Current total annual IT spend for in-scope workloads
|
annual_spend: 0 # Current total annual IT spend for in-scope workloads
|
||||||
team_size: 0 # FTEs dedicated to operations
|
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 Comparison ---
|
||||||
tco:
|
tco:
|
||||||
horizon_years: 3 # 3 or 5
|
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 "Implementation Roadmap" in text
|
||||||
assert "Our Recommendation" in text
|
assert "Our Recommendation" in text
|
||||||
assert "12 weeks" 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"
|
||||||
@@ -24,12 +24,24 @@ from typing import Optional, List, Dict
|
|||||||
from pptx.util import Inches, Pt
|
from pptx.util import Inches, Pt
|
||||||
from pptx.dml.color import RGBColor
|
from pptx.dml.color import RGBColor
|
||||||
from pptx.enum.text import PP_ALIGN
|
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:
|
try:
|
||||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
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:
|
except ModuleNotFoundError:
|
||||||
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
|
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 = "",
|
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 1: Cover using Dark Title_Pillar layout."""
|
||||||
slide = self._add_layout_slide(Layouts.COVER_DARK)
|
slide = self._add_layout_slide(Layouts.COVER_DARK)
|
||||||
self._set_placeholder(slide, 0, customer) # Title
|
self._set_placeholder(slide, 0, customer) # Title
|
||||||
@@ -264,7 +277,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
date_str = date or datetime.now().strftime("%B %Y")
|
date_str = date or datetime.now().strftime("%B %Y")
|
||||||
self._set_placeholder(slide, 35, date_str)
|
self._set_placeholder(slide, 35, date_str)
|
||||||
if prepared_by:
|
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):
|
def add_executive_summary_slide(self, statement: str):
|
||||||
"""Slide 2: Executive Summary — controlled typography on blank slide."""
|
"""Slide 2: Executive Summary — controlled typography on blank slide."""
|
||||||
@@ -346,7 +360,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
horizon = tco.get("horizon_years", 3)
|
horizon = tco.get("horizon_years", 3)
|
||||||
self._add_textbox(
|
self._add_textbox(
|
||||||
slide, Inches(0.8), Inches(1.1), Inches(11), Inches(0.4),
|
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,
|
font_size=13, color=Colors.TEAL, bold=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -354,8 +368,34 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
proposed = tco.get("proposed_oci", {})
|
proposed = tco.get("proposed_oci", {})
|
||||||
savings = tco.get("savings", {})
|
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 = [
|
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)),
|
("Licensing / Support", current.get("annual_licensing", 0), proposed.get("annual_licensing", 0)),
|
||||||
("Operations (People)", current.get("annual_operations", 0), proposed.get("annual_operations", 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)),
|
("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)
|
migration = proposed.get("migration_one_time", 0)
|
||||||
|
|
||||||
num_rows = len(rows_data) + 3 # header + rows + annual total + horizon total
|
num_rows = len(rows_data) + 3 # header + rows + annual total + horizon total
|
||||||
|
table_top = Inches(3.0)
|
||||||
table = self._add_table(
|
table = self._add_table(
|
||||||
slide, num_rows, 4,
|
slide, num_rows, 4,
|
||||||
Inches(0.8), Inches(1.6),
|
Inches(0.8), table_top,
|
||||||
Inches(11.7), Inches(0.42 * num_rows),
|
Inches(11.7), Inches(0.42 * num_rows),
|
||||||
)
|
)
|
||||||
table.columns[0].width = Inches(3.5)
|
table.columns[0].width = Inches(3.5)
|
||||||
@@ -379,7 +420,14 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
table.columns[3].width = Inches(2.8)
|
table.columns[3].width = Inches(2.8)
|
||||||
|
|
||||||
# Header
|
# 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(
|
self._style_table_cell(
|
||||||
table.cell(0, j), h, font_size=11, bold=True,
|
table.cell(0, j), h, font_size=11, bold=True,
|
||||||
color=Colors.WHITE, bg_color=Colors.TEAL,
|
color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||||
@@ -401,9 +449,9 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
# Annual total row
|
# Annual total row
|
||||||
total_row = len(rows_data) + 1
|
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_current = ann_current 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_oci = ann_oci 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_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, 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, 1), f"${ann_current:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
|
||||||
self._style_table_cell(table.cell(total_row, 2), f"${ann_oci:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
|
self._style_table_cell(table.cell(total_row, 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
|
# Migration note
|
||||||
if migration:
|
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(
|
self._add_textbox(
|
||||||
slide, Inches(0.8), note_y, Inches(11), Inches(0.3),
|
slide, Inches(0.8), note_y, Inches(11), Inches(0.3),
|
||||||
text=f"* Includes one-time migration investment of ${migration:,.0f} in Year 1",
|
text=f"* Includes one-time migration investment of ${migration:,.0f} in Year 1",
|
||||||
@@ -432,16 +480,16 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
# Assumptions
|
# Assumptions
|
||||||
assumptions = tco.get("assumptions", [])
|
assumptions = tco.get("assumptions", [])
|
||||||
if 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(
|
self._add_textbox(
|
||||||
slide, Inches(0.8), a_y, Inches(11), Inches(0.25),
|
slide, Inches(0.8), a_y, Inches(11), Inches(0.25),
|
||||||
text="Assumptions:", font_size=9, bold=True, color=Colors.SECONDARY_TEXT,
|
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(
|
self._add_textbox(
|
||||||
slide, Inches(1.0), a_y + Inches(0.25 + idx * 0.22),
|
slide, Inches(1.0), a_y + Inches(0.25 + idx * 0.22),
|
||||||
Inches(11), Inches(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 = ""):
|
def add_roi_slide(self, roi: dict, headline: str = ""):
|
||||||
@@ -450,6 +498,56 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
self._add_title_bar(slide, "Return on Investment")
|
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
|
# Big centered ROI number
|
||||||
pct = roi.get("three_year_roi_pct", 0)
|
pct = roi.get("three_year_roi_pct", 0)
|
||||||
metric_text = headline or (f"{pct:.0f}%" if pct else "—")
|
metric_text = headline or (f"{pct:.0f}%" if pct else "—")
|
||||||
@@ -466,7 +564,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
# Label under big number
|
# Label under big number
|
||||||
self._add_textbox(
|
self._add_textbox(
|
||||||
slide, Inches(0.8), Inches(3.75), Inches(11.7), Inches(0.4),
|
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,
|
color=Colors.SECONDARY_TEXT, alignment=PP_ALIGN.CENTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -509,11 +607,349 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
alignment=PP_ALIGN.CENTER,
|
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):
|
def add_value_drivers_slide(self, drivers: list):
|
||||||
"""Slide 6: Value Drivers — 4 categories on blank slide.
|
"""Slide 6: Value Drivers — 4 categories on blank slide.
|
||||||
|
|
||||||
drivers: list of {"category": str, "title": str, "description": str, "quantified": str}
|
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()
|
slide = self._add_blank_slide()
|
||||||
|
|
||||||
self._add_title_bar(slide, "Value Drivers")
|
self._add_title_bar(slide, "Value Drivers")
|
||||||
@@ -587,8 +1023,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
# Column header backgrounds
|
# Column header backgrounds
|
||||||
for x, color, label in [
|
for x, color, label in [
|
||||||
(left_x, Colors.TEAL, "✓ Migration Risks (Mitigated)"),
|
(left_x, Colors.TEAL, "Migration Risks (Mitigated)"),
|
||||||
(right_x, Colors.ERROR, "⚠ Risks of Inaction"),
|
(right_x, Colors.ERROR, "Risks of Inaction"),
|
||||||
]:
|
]:
|
||||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, header_y, col_w, Inches(0.45))
|
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, header_y, col_w, Inches(0.45))
|
||||||
bg.fill.solid()
|
bg.fill.solid()
|
||||||
@@ -614,7 +1050,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
self._add_textbox(
|
self._add_textbox(
|
||||||
slide, left_x + Inches(0.15), y + Inches(0.07), col_w - Inches(0.25), Inches(0.4),
|
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:
|
if mitigation:
|
||||||
self._add_textbox(
|
self._add_textbox(
|
||||||
@@ -638,7 +1074,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
self._add_textbox(
|
self._add_textbox(
|
||||||
slide, right_x + Inches(0.15), y + Inches(0.07), col_w - Inches(0.25), Inches(0.4),
|
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 = []
|
detail_parts = []
|
||||||
if impact:
|
if impact:
|
||||||
@@ -794,6 +1230,21 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
)
|
)
|
||||||
y += Inches(0.6)
|
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
|
# Build from YAML spec
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -801,6 +1252,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "BusinessCaseDeckGenerator":
|
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "BusinessCaseDeckGenerator":
|
||||||
"""Build a complete business case deck from a YAML specification."""
|
"""Build a complete business case deck from a YAML specification."""
|
||||||
|
spec = enrich_adbs_to_adbd_business_case(spec)
|
||||||
spec = _enrich_sparse_business_case(spec)
|
spec = _enrich_sparse_business_case(spec)
|
||||||
bc = spec.get("business_case", spec) # Support both wrapped and unwrapped
|
bc = spec.get("business_case", spec) # Support both wrapped and unwrapped
|
||||||
gen = cls(template=template)
|
gen = cls(template=template)
|
||||||
@@ -808,8 +1260,14 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
# Slide 1: Cover
|
# Slide 1: Cover
|
||||||
gen.add_cover_slide(
|
gen.add_cover_slide(
|
||||||
customer=pick(bc, "customer_name", "customer"),
|
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=pick(bc, "prepared_by", "author"),
|
||||||
|
prepared_by_role=pick(bc, "prepared_by_role", "role", "title"),
|
||||||
date=pick(bc, "date", "generated_on"),
|
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")):
|
if tco and (tco.get("current_state") or tco.get("proposed_oci")):
|
||||||
gen.add_tco_slide(tco)
|
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
|
# Slide 5: ROI
|
||||||
roi = bc.get("roi", {})
|
roi = bc.get("roi", {})
|
||||||
if roi and any(roi.get(k) for k in ["three_year_roi_pct", "payback_months", "total_investment"]):
|
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)
|
gen.add_roi_slide(roi, headline=pick(roi, "headline"))
|
||||||
|
|
||||||
# Slide 6: Value Drivers
|
# Slide 6: Value Drivers
|
||||||
value_drivers = bc.get("value_drivers", [])
|
value_drivers = bc.get("value_drivers", [])
|
||||||
@@ -916,6 +1394,10 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
next_steps=pick_list(rec, "next_steps"),
|
next_steps=pick_list(rec, "next_steps"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
disclaimer = pick(bc, "commercial_disclaimer", "disclaimer")
|
||||||
|
if disclaimer:
|
||||||
|
gen.add_disclaimer_slide(disclaimer)
|
||||||
|
|
||||||
return gen
|
return gen
|
||||||
|
|
||||||
|
|
||||||
@@ -939,11 +1421,30 @@ def main():
|
|||||||
"--template",
|
"--template",
|
||||||
help="Path to Oracle FY26 .pptx template (default: templates/Oracle_PPT-template_FY26.pptx)",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
with open(args.spec, 'r') as f:
|
with open(args.spec, 'r') as f:
|
||||||
spec = yaml.safe_load(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 = BusinessCaseDeckGenerator.from_spec(spec, template=args.template)
|
||||||
gen.save(args.output)
|
gen.save(args.output)
|
||||||
|
|
||||||
@@ -954,6 +1455,53 @@ def main():
|
|||||||
if customer:
|
if customer:
|
||||||
print(f" Customer: {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__":
|
if __name__ == "__main__":
|
||||||
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