11 Commits

Author SHA1 Message Date
root
4552ef7226 Enhance ADB-S to ADB-D business cases
Some checks failed
Deploy Skill to OCI / deploy (push) Failing after 19s
Diagram validators / diagram-validators (push) Failing after 22s
SKILL.md sync / check-sync (push) Successful in 17s
SKU Catalog Refresh / refresh-and-discover (push) Failing after 30s
KB Health Check / check-links (push) Successful in 57s
2026-05-08 15:25:21 -03:00
root
75203e7196 Document Gitea↔GitHub dual-push setup for agents
Some checks failed
Deploy Skill to OCI / deploy (push) Successful in 33s
SKU Catalog Refresh / refresh-and-discover (push) Failing after 26s
KB Health Check / check-links (push) Successful in 1m2s
origin's push URL list spans both remotes (Gitea authoritative, GitHub mirror), so a single `git push origin main` updates both. The setup lives in `.git/config` and isn't tracked, so we explicitly document the reproduction commands plus agent guidance: propose dual-push after commits, never force-push Gitea without OK, and the recovery pattern for divergence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:25:47 -03:00
root
c22ba64bdb Brand icons (Slack/Jira) in diagrams + BOM comparison deck
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 35s
SKILL.md sync / check-sync (push) Successful in 18s
- brand_icon_catalog: shared resolver for non-OCI vendor assets (Slack, Jira, Jira Service Management) under kb/diagram/brand-icons; official > fallback; never hits the network at render time.
- oci_diagram_gen + oci_pptx_diagram_gen: services and external actors accept `brand_icon:`, unlocking real multicloud / SaaS topologies beyond Oracle stencils.
- build_bom_comparison_deck: customer-facing Oracle FY26 deck that compares N BOM specs with native PowerPoint charts and live SKU pricing.
- README + SKILL.md: document the new capabilities and ship copy/paste demo prompts (native .pptx diagram, Slack→OIC→Jira flow, multicloud Postgres↔ADB-S, discount-aware BOM).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:23:50 -03:00
root
7ea17b2f9e Skill: catalog-first pricing, centralised disclaimer, deck verification
Six fixes after a Codex transcript that scraped oracle.com for SKUs already
in kb/pricing/oci-sku-catalog.yaml. New option-4 cost-estimate flow with a
"catalog first, web never" rule, staleness gate, and miss policy; unconditional
Cookbook scope (any pricing or SKU work, not just specific tool calls); hard
no-WebSearch rule under "What You Do NOT Do"; disclaimer field extended with
reference-only / no-client-discount clauses and referenced from options 4/8/13
(single source of truth — oci_bom_gen.py already consumes it); tightened menu
hints separating option 4 (quick comparison while you scope) from option 13
(formal .xlsx artefact you send); structural deck verification step for
Linux/WSL where PowerPoint binary isn't available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:23:48 -03:00
root
ee15e1f45e Add third-party brand icon support
Some checks failed
Deploy Skill to OCI / deploy (push) Successful in 33s
Diagram validators / diagram-validators (push) Failing after 20s
2026-04-27 15:15:31 -03:00
root
a032e7980b Fix drawio OIC icon resolution
Some checks failed
Deploy Skill to OCI / deploy (push) Successful in 35s
Diagram validators / diagram-validators (push) Failing after 21s
2026-04-27 13:07:38 -03:00
root
198861f533 PPTX icon resolver: skip degraded refs from stencil buckets
Some checks failed
Deploy Skill to OCI / deploy (push) Successful in 36s
Diagram validators / diagram-validators (push) Failing after 19s
SKILL.md sync / check-sync (push) Successful in 16s
Symptom on the GCP→OCI PostgreSQL deck: the OCI PostgreSQL icon bbox
rendered as empty space — labels and surrounding ADs all visible,
but no icon where the postgres stencil should be.

Root cause: ``_lookup_icon_ref`` returned the first hit from
``stencils.database_icons`` without checking it was cloneable.
The bucket happens to ship one degraded entry — ``database`` is
``tag=sp`` with ``bbox=None`` (a label-strip leaf, not the real
icon group) — so ``_clone_translated_block`` emitted a zero-size
shape. The 19 viable refs for the same key in
``shape_library.entries`` were ignored because the early-return in
``database_icons`` won.

Fix: extract a shared ``_is_viable_ref`` predicate (slide_path +
child_index/node_path + non-empty bbox + supported tag) and apply
it before returning from BOTH stencil buckets (``database_icons``,
``sample_icon_refs``). On miss, fall through to
``shape_library_entries`` where ``_select_preferred_ref`` already
filtered the same way.

Affects every alias that routes to a degraded stencil entry —
``postgresql`` / ``postgres`` / ``oci_postgresql`` were the obvious
fallout because they all alias to ``database``. The Architecture
Center reconstructions on shape-library-only slugs were unaffected.

Verified by re-rendering examples/output-gcp-virginia-oci-postgres-
adbs-clone/*.pptx — the OCI PostgreSQL bbox now shows the icon
(teal stencil + "Database" intrinsic caption) consistently with
the ADB-S and Refreshable Clone bboxes next to it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:03:23 -03:00
root
7f8cba7f30 Skill: kill the lookup-loop time-sink (3 fixes from Codex transcript)
Root cause observed in a real Codex session: the agent burned ~3min
on `make venv` + 4 sequential `make diagram-lookup` queries trying
to find a non-existent ref-arch (OCI PostgreSQL accessed FROM GCP
via Cross-Cloud Interconnect). The catalog has 123 entries; if two
honest queries can't surface a topology, none exists — but nothing
in the skill said "stop." Three coordinated fixes:

1) Makefile: `make venv` is now idempotent.
   Marker file `.venv/.deps-installed` keyed off `requirements.txt`
   mtime — the second `make venv` in a row is a 26ms no-op (was
   1m53s on a Codex sandbox, paid every turn). Force a rebuild with
   `rm .venv/.deps-installed`.

2) kb/architecture-center/catalog.yaml: new `known_gaps` block.
   Surfaced by the lookup tool as a top-of-output banner whenever
   the (synonym-expanded) query tokens match any one of a gap's
   `triggers` token sets. Two gaps seeded from real engagements:

     - gcp-to-oci-native-services — OCI PostgreSQL/OKE/Cache/etc.
       reached FROM GCP via Cross-Cloud Interconnect (the
       Database@Google Cloud entries are the OPPOSITE direction).

     - newer-oci-services-without-icon — OCI Cache (Redis/Valkey)
       and OCI PostgreSQL post-v24.2-toolkit; no ref-arch, no
       toolkit icon, fall back to generic stencil + explicit label.

   Each gap's `notice:` is a copy-paste recommended composition so
   the agent doesn't have to invent a strategy.

3) tools/archcenter_pattern_lookup.py: detects + surfaces gaps.
   `lookup()` now returns `{"matches": ..., "gaps": ...}`. The
   text printer emits a "⚠ KNOWN GAP — <id>" banner with the
   notice block ABOVE the score-based top-K, so the agent sees
   the stop-and-compose signal before ever scrolling to results.
   YAML output mode mirrors the same shape under `gaps:`.

4) SKILL.md (option 2 step 1) + Codex copy via sync-skill.
   New explicit rule: "Lookup budget: max 2 queries — never loop."
   Documents the gap banner, the closest-3-then-ask fallback, and
   the prohibition against grasping at a 3rd refinement.

Verified manually:
  - GCP+postgresql query → gap fires
  - "redis ha multi-az" → newer-OCI gap fires
  - "exadata cross region data guard" → no false-positive gap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:46:27 -03:00
root
ff77563133 settings.local.json: extend allowlist for diagram + MCP test workflow
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 32s
Adds permission entries accumulated during recent work — the local
draw.io.exe path probes used by the fidelity validator, curl probes
against the MCP server (mcp.tech-lad.com) and the Gitea remote
(git.tech-lad.com.br) for status checks, the .githooks/pre-commit
hook so the SKILL.md sync gate runs without a prompt, and a few
read-only awk/grep/curl one-liners hit repeatedly in the SKU /
ref-arch maintenance loop.

Per-machine allowlist only — no behaviour change for other
contributors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:29:50 -03:00
root
1b4828f293 MELI Fraud: case-specific scaffolding scripts (3 chassis variants)
Three one-shot Python scripts that build the MELI Fraud As-Is /
To-Be .drawio pair by cloning an Oracle Architecture Center chassis
and overlaying MELI-specific labels, region renames, and workload
pills. Useful as worked examples of the chassis-then-overlay
transform pattern (drawio_template_transform.DrawioTemplate) that
the diagram procedure recommends:

  • build_meli_fraud_from_adbsplusadbd.py — clones the locally
    accepted ATP-S/ATP-D side-by-side reference and partitions cells
    into AS-IS / TO-BE panels by x-coordinate.
  • build_meli_fraud_from_crossregion.py — uses Oracle's
    exadata-dedicated-cross-region-dataguard ref-arch (two regions
    with DRG + RPC + Data Guard already drawn) as chassis and only
    overlays MELI-specific pills.
  • build_meli_fraud_from_templates.py — combines the in-region and
    cross-region Exadata ref-archs and clones the in-region
    topology to the right for the Montreal DR.

All three honour the standing rule: do not edit text inside Oracle
SVG stencil shapes — overlay new mxCells in whitespace instead.
Outputs land under examples/output-meli-fraud-adbd-migration/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:29:37 -03:00
root
5566a171b0 PPTX: ensure [Content_Types].xml has defaults for every media extension
Symptom: PowerPoint prompted to repair some generated decks. Cause:
when python-pptx (or our native renderer) repacks the .pptx after we
embed Oracle template assets — some of which are SVG-backed —
[Content_Types].xml can lose the Default entry for "svg" (and other
extensions present under ppt/media), which makes Office refuse to
open the file cleanly.

Fix: after writing the deck (and after any native renderer pass),
walk ppt/media, collect the actual extensions in use, and ensure
[Content_Types].xml declares a Default for each one (svg, png, jpg,
jpeg, gif, bmp, tiff, emf, wmf, ico, webp). Repack only when at
least one new Default was added — no-op fast path otherwise.

Lines up with the existing field rule: every embedded media
extension must be declared in [Content_Types].xml Defaults, or
PowerPoint flags the file as corrupt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:29:18 -03:00
32 changed files with 4510 additions and 100 deletions

View File

@@ -86,9 +86,9 @@ 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 — *services + sizing → PAYG vs BYOL* 4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
VALIDATE & CHECK VALIDATE & CHECK
───────────────── ─────────────────
@@ -112,13 +112,23 @@ Present these options as a compact numbered list. Each option has a bold title f
SA TOOLS SA TOOLS
───────────────── ─────────────────
13. 📦 BOM generator — *services + quantities → .xlsx Bill of Materials* 13. 📦 BOM generator — *formal .xlsx artefact you send to the customer*
14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA* 14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA*
━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━
Pick a number, or just describe what you need. Pick a number, or just describe what you need.
``` ```
### Example Direct Requests
If the user asks for examples, or skips the menu and writes a natural-language request, prompts like these map directly to the capabilities above:
- `Generame un diagrama nativo en .pptx, simple y prolijo, de esta arquitectura: usuarios externos -> load balancer público -> 2 VMs de aplicación en subnet privada -> Autonomous Database Serverless. Quiero que quede presentable para mostrar directo en PowerPoint. No me pidas YAML ni setup adicional.`
- `Sketch this architecture first for Excalidraw: external users -> public load balancer -> private app VMs -> Autonomous Database. Give me a native .excalidraw file so I can review and collaborate before making the formal diagram.`
- `Generate a simple high-level integration diagram for this flow: Slack -> Oracle Integration Cloud (OIC) -> Jira Service Management -> My Oracle Support. Add clear Client, Oracle, and External layers. Use brand icons for Slack and Jira Service Management, and deliver both .drawio and .pptx.`
- `Generate a high-level architecture diagram for this scenario: 1 PostgreSQL in OCI Ashburn, accessed from GCP Virginia through interconnect. PostgreSQL connects internally to 1 Autonomous Database Serverless, which also has 1 refreshable clone in the same region but in a different AD. Deliver both .drawio and .pptx. Keep it simple, executive-friendly, and technically correct.`
- `Gere um BOM em USD para este cenário: PostgreSQL no OCI com 4 OCPU e 500 GB, ADB-S com 200 ECPU e 1 TB BYOL, 1 refreshable clone com o mesmo sizing e FastConnect de 1 Gbps. Aplique 11% de desconto, 12 meses, 24 horas por dia. Quero o arquivo .xlsx e um resumo claro do custo mensal e anual.`
### Behavior Rules ### Behavior Rules
- If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)." - If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)."
@@ -127,16 +137,31 @@ Pick a number, or just describe what you need.
``` ```
1. Describe the architecture you want to diagram (or paste a YAML spec if you have one). 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.
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).
3. **Author the spec in `absolute_layout` shape** — this is REQUIRED. Do NOT use the legacy workload-driven shape (`tenancy → regions → compartments → services`); it does not resolve OCI icon stencils, does not run through the spec validator, and produces wireframe-looking output (rectangles with text instead of real OCI icons). Every container, service, label, and connection needs explicit `(x, y, w, h)`. 3. **Author the spec in `absolute_layout` shape** — this is REQUIRED. Do NOT use the legacy workload-driven shape (`tenancy → regions → compartments → services`); it does not resolve OCI icon stencils, does not run through the spec validator, and produces wireframe-looking output (rectangles with text instead of real OCI icons). Every container, service, label, and connection needs explicit `(x, y, w, h)`.
@@ -149,7 +174,16 @@ Pick a number, or just describe what you need.
Full reference: `docs/skill/output-formats.md` § "Standard diagram-generation procedure (MANDATORY)". Full reference: `docs/skill/output-formats.md` § "Standard diagram-generation procedure (MANDATORY)".
- If the user picks **3**, ask: "Describe the architecture or paste the spec. I'll generate the deck with a native OCI PowerPoint diagram when the architecture is structured enough." - If the user picks **3**, ask: "Describe the architecture or paste the spec. I'll generate the deck with a native OCI PowerPoint diagram when the architecture is structured enough."
- If the user picks **4**, ask: "What services and sizing? (e.g., 'ADB-S 8 OCPU + 2 VMs + FastConnect')" - If the user picks **4**, ask: "What services and sizing? (e.g., 'ADB-S 8 OCPU + 2 VMs + FastConnect'). License model — PAYG, BYOL, or both?"
Then follow the cost-estimate flow:
1. **Catalog first, web never.** `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) (auto-refreshed, authoritative) for the requested SKUs and consult [`docs/bom-cookbook.md`](docs/bom-cookbook.md) for copy-paste recipes (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block, FastConnect, etc.). Use `kb/pricing/compute.yaml` for shape-level lookups. Do NOT reach for WebSearch / WebFetch / `oracle.com` for list prices — the catalog IS the source.
2. **Staleness gate.** Read `last_verified` at the top of the catalog. If >60 days stale, warn the user and offer `python tools/refresh_sku_catalog.py --refresh` before quoting. If <=60 days, proceed without web lookups.
3. **Catalog miss policy.** If a SKU is genuinely absent after `grep` (not "I didn't find it on first try"), state it explicitly, then run `python tools/refresh_sku_catalog.py --refresh` and re-grep. Only if a fresh catalog still doesn't surface it is `oracle.com/cloud/price-list/` an acceptable fallback — and cite the source URL next to the part number.
4. **Source extraction.** When the request references a customer artefact (`.pptx`, BOM, quote), extract the topology / sizing from the artefact first, list each scenario's components, then map to SKUs. Show the mapping (component → SKU) so the SA can audit it.
5. **Quote shape.** Per scenario list: SKU + product name + metric + list price USD + quantity + monthly subtotal. End with total $/month and total $/year. Cite the catalog's `last_verified` date in the output.
6. When both PAYG and BYOL are requested, present them side-by-side (same SKU rows, two price columns). Save the cost estimate to `examples/<customer>-cost-estimate.yaml` and present the formatted table in the terminal.
7. **Disclaimer (mandatory).** Read the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` and append it verbatim — as a footer line under the terminal table, and (if a deck is generated) as a final "Commercial Disclaimer" slide. Do NOT paraphrase or rewrite; the field already carries Oracle's sample-quote boilerplate plus the "reference only / no client-specific discounts" clauses. `tools/oci_bom_gen.py` already reads this field for BOM xlsx output.
8. **Deck verification (if a deck is generated).** Open the produced `.pptx` with `python-pptx` and assert: (a) slide count matches spec, (b) each slide's expected title is present, (c) chart types match what the spec asked for (e.g. `COLUMN_CLUSTERED`, `COLUMN_STACKED`), (d) the disclaimer slide is the last one. Print a one-line `deck OK: <N> slides, titles=[...]` summary so the SA can audit at a glance. Rasterizing to PNG via PowerPoint is not always available on Linux/WSL — structural validation is the floor, not optional.
- If the user picks **5**, ask: "Describe your architecture or paste the spec. I'll run the 5-pillar review." Then follow the WA review flow: - If the user picks **5**, ask: "Describe your architecture or paste the spec. I'll run the 5-pillar review." Then follow the WA review flow:
1. Parse input to build a workload profile YAML (flags) and architecture YAML 1. Parse input to build a workload profile YAML (flags) and architecture YAML
2. If input is a `.pptx` file, extract text content from all slides to infer architecture and workload context 2. If input is a `.pptx` file, extract text content from all slides to infer architecture and workload context
@@ -168,13 +202,22 @@ Pick a number, or just describe what you need.
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`.
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:
@@ -220,7 +263,7 @@ Pick a number, or just describe what you need.
**BOM Output Rules:** **BOM Output Rules:**
- NEVER include "Confidential: Internal ONLY" or any confidentiality marking - NEVER include "Confidential: Internal ONLY" or any confidentiality marking
- ALWAYS include the Oracle Cost Estimator disclaimer at the bottom - ALWAYS include the disclaimer from `kb/pricing/oci-sku-catalog.yaml` `disclaimer:` field at the bottom — `tools/oci_bom_gen.py` reads it automatically (do not duplicate, paraphrase, or maintain a second copy elsewhere)
- Only include SKUs the customer actually requested — never dump the full catalog - Only include SKUs the customer actually requested — never dump the full catalog
- Show cost proportions so the customer can see where their spend concentrates - Show cost proportions so the customer can see where their spend concentrates
- Use Excel formulas (not static values) so the customer can adjust quantities - Use Excel formulas (not static values) so the customer can adjust quantities
@@ -243,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
@@ -446,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`.
@@ -461,9 +504,9 @@ See [docs/skill/output-formats.md](docs/skill/output-formats.md) for the per-cus
## Cookbook: Building Tool Payloads ## Cookbook: Building Tool Payloads
Before mapping customer requirements to SKUs for `generate_bom`, `generate_bom_appca`, or `generate_cost_estimate`, check [docs/bom-cookbook.md](docs/bom-cookbook.md) — it has copy-paste recipes for common patterns (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block + FastConnect) and explicitly names the gotchas that otherwise burn exploration turns (e.g. ADB-Dedicated shares infrastructure SKUs with ExaCS — there are no separate `adb_dedicated` SKUs in the catalog). Any pricing or SKU work — cost estimate (option 4), BOM (option 13), BOM-AppCA (option 14), business-case TCO (option 8), scenario comparison from a customer artefact (`.pptx`, BOM, quote) — starts in the local catalog, not on the web. Check [docs/bom-cookbook.md](docs/bom-cookbook.md) for copy-paste recipes (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block + FastConnect) and the gotchas that otherwise burn exploration turns (e.g. ADB-Dedicated shares infrastructure SKUs with ExaCS — there are no separate `adb_dedicated` SKUs in the catalog).
If a requirement does not match a recipe, `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) and consult `kb/services/` before inventing SKUs. If a requirement does not match a recipe, `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) and consult `kb/services/` before inventing SKUs. See "What You Do NOT Do" for the web-lookup rule and `kb/pricing/oci-sku-catalog.yaml`'s `last_verified` for staleness handling.
--- ---
@@ -571,6 +614,7 @@ KB lives under `kb/`. See [kb/README.md](kb/README.md) for the directory map, fr
- You do NOT replace the architect's judgment. You accelerate it. - You do NOT replace the architect's judgment. You accelerate it.
- You do NOT generate pixel-perfect diagrams. You generate 80% drafts the architect refines. - You do NOT generate pixel-perfect diagrams. You generate 80% drafts the architect refines.
- You do NOT make up pricing. If you don't have current pricing, estimate ranges. - You do NOT make up pricing. If you don't have current pricing, estimate ranges.
- You do NOT use WebSearch / WebFetch / `oracle.com` for OCI list prices or SKU lookups. `kb/pricing/oci-sku-catalog.yaml` is auto-refreshed via `tools/refresh_sku_catalog.py` (Oracle apexapps pricing API) and is the source of truth — including for ECPU/OCPU rates, BYOL deltas, Exadata X11M part numbers, and ADB-D ECPU SKUs. Web fallback is allowed ONLY when (a) the SKU is genuinely absent after `grep` AND a fresh `--refresh` still does not surface it, or (b) the catalog's `last_verified` is >60 days stale and a refresh is not currently possible. In either case, cite the source URL alongside the part number. Reflexively reaching for WebSearch when the catalog already has the answer is the most common cost-estimate failure mode — don't.
- You do NOT claim features exist if you're unsure. Check the KB first. - You do NOT claim features exist if you're unsure. Check the KB first.
- You do NOT do detailed project management. DELIVER artifacts are lightweight handover aids. - You do NOT do detailed project management. DELIVER artifacts are lightweight handover aids.
- You do NOT add services or components the user did not request. - You do NOT add services or components the user did not request.

View File

@@ -107,7 +107,109 @@
"WebFetch(domain:developers.openai.com)", "WebFetch(domain:developers.openai.com)",
"Bash(echo \"---exit:$?---\")", "Bash(echo \"---exit:$?---\")",
"Bash(date +%s)", "Bash(date +%s)",
"Read(//tmp/**)" "Read(//tmp/**)",
"Bash(.venv/bin/pip install *)",
"Bash(curl -sS -o /dev/null -w \"HTTP:%{http_code} dns:%{remote_ip} time:%{time_total}s\\\\n\" --max-time 10 https://mcp.tech-lad.com/deal-accelerator/mcp/)",
"Bash(curl -sS -o /dev/null -w \"oauth HTTP:%{http_code}\\\\n\" --max-time 10 https://mcp.tech-lad.com/oauth/authorize)",
"mcp__oci-deal-accelerator__generate_deck",
"mcp__oci-deal-accelerator__generate_business_case",
"mcp__oci-deal-accelerator__generate_bom",
"mcp__oci-deal-accelerator__generate_bom_appca",
"Bash(mkdir -p /tmp/mcp-test-outputs)",
"Bash(curl -sS -o deck-nova.pptx \"https://mcp.tech-lad.com/deal-accelerator/files/176ac90e-test-nova-retail-deck.pptx\")",
"Bash(curl -sS -o biz-nova.pptx \"https://mcp.tech-lad.com/deal-accelerator/files/42dd4da2-test-nova-retail-bizcase.pptx\")",
"Bash(curl -sS -o bom-vector.xlsx \"https://mcp.tech-lad.com/deal-accelerator/files/3868d74a-test-vector-retail-bom.xlsx\")",
"Bash(curl -sS -o bom-vector-appca.xlsx \"https://mcp.tech-lad.com/deal-accelerator/files/b34fa397-test-vector-retail-bom-appca.xlsx\")",
"Bash(curl -sS -o /tmp/mcp-test-outputs/biz-nova.pptx \"https://mcp.tech-lad.com/deal-accelerator/files/42dd4da2-test-nova-retail-bizcase.pptx\")",
"Bash(curl -sS -o /tmp/mcp-test-outputs/bom-vector.xlsx \"https://mcp.tech-lad.com/deal-accelerator/files/3868d74a-test-vector-retail-bom.xlsx\")",
"Bash(curl -sS -o /tmp/mcp-test-outputs/bom-vector-appca.xlsx \"https://mcp.tech-lad.com/deal-accelerator/files/b34fa397-test-vector-retail-bom-appca.xlsx\")",
"Bash(curl -sS -o /tmp/mcp-test-outputs/deck-vector.pptx \"https://mcp.tech-lad.com/deal-accelerator/files/19c5330e-test-vector-bank-deck.pptx\")",
"Bash(curl -sS -o /tmp/mcp-test-outputs/biz-digital.pptx \"https://mcp.tech-lad.com/deal-accelerator/files/16b90f10-test-vector-digital-bizcase.pptx\")",
"Bash(curl -sS -o /dev/null -w \"git remote HTTP: %{http_code} time: %{time_total}s\\\\n\" --max-time 15 https://git.tech-lad.com.br/)",
"Bash(awk '/- sku:/{sku=$NF} /product:/{print sku\" - \"$0}' kb/pricing/oci-sku-catalog.yaml)",
"Bash(awk '{print $3}')",
"Bash(awk '/- sku: B110627|- sku: B110628|- sku: B110629|- sku: B90453/{print;f=1;next} f&&/product:/{print;f=0}' kb/pricing/oci-sku-catalog.yaml)",
"Bash(awk '/- sku:/{sku=$NF} /product:.*Exadata|product:.*ECPU|product:.*Autonomous/{print sku\" - \"$0}' kb/pricing/oci-sku-catalog.yaml)",
"Bash(awk '/- sku: B90453/,/product:/' kb/pricing/oci-sku-catalog.yaml)",
"Bash(awk '/- sku:/{sku=$NF} /product:.*ADB|product:.*NoSQL|product:.*HeatWave|product:.*MySQL/{print sku\" - \"$0}' kb/pricing/oci-sku-catalog.yaml)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" --help)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 10 -o \"tmp/compare-iter/iter1.png\" \"tmp/compare-iter/iter1.drawio\")",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 20 --crop -s 2 -o tmp/compare-iter/iter4_crop.png tmp/compare-iter/iter4.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 20 --crop -s 2 -o tmp/compare-iter/REFERENCE.png examples/output-meli-fraud-adbd-migration/adbsplusadbd.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 30 --crop -s 3 -o tmp/compare-iter/iter6_hires.png tmp/compare-iter/iter6.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 20 --crop -s 3 -o tmp/compare-iter/to_be_v1_hires.png tmp/compare-iter/to_be_v1.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 40 -s 4 -o tmp/compare-iter/to_be_v1_4x.png tmp/compare-iter/to_be_v1.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png --crop -b 20 --width 3000 -o tmp/compare-iter/to_be_v1_w3000.png tmp/compare-iter/to_be_v1.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 20 --crop --width 4500 -o tmp/compare-iter/to_be_v1_w4500.png tmp/compare-iter/to_be_v1.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 30 --crop --width 5000 -o tmp/compare-iter/as_is_final.png examples/output-meli-fraud-adbd-migration/fraud-as-is-adbs.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 30 --crop --width 5000 -o tmp/compare-iter/to_be_final.png examples/output-meli-fraud-adbd-migration/fraud-to-be-adbd.drawio)",
"Bash(curl -sL \"https://docs.oracle.com/en/solutions/deploy-autonomous-database-db-at-azure/index.html\" -o tmp/oracle-ref-archs/adb-azure.html)",
"Bash(curl -sL -w 'HTTP:%{http_code} size:%{size_download}\\\\n' \"https://docs.oracle.com/en/solutions/deploy-autonomous-database-db-at-azure/index.html\" -o /tmp/x.html)",
"Bash(curl -sL -w 'HTTP:%{http_code} size:%{size_download}\\\\n' \"https://docs.oracle.com/en/solutions/deploy-autonomous-database-db-at-azure/img/autonomous-database-db-azure-diagram-oracle.zip\" -o adb-azure.zip)",
"Bash(unzip -l adb-azure.zip)",
"Bash(unzip -o -q adb-azure.zip)",
"Bash(mv oracle-adb-jde-exadata-arch-oracle *)",
"Bash(curl -sL \"https://docs.oracle.com/en/solutions/dataguard-exadata-dedicated-infrastructure/index.html\" -o dg-exa-dedicated.html)",
"Bash(curl -sL \"https://docs.oracle.com/en/solutions/dataguard-exadata-dedicated-infrastructure/img/exadata-dedicated-region-dataguard.zip\" -o dg-exa-region.zip)",
"Bash(curl -sL \"https://docs.oracle.com/en/solutions/dataguard-exadata-dedicated-infrastructure/img/exadata-dedicated-cross-region-dataguard.zip\" -o dg-exa-cross-region.zip)",
"Bash(unzip -o -q dg-exa-region.zip)",
"Bash(unzip -o -q dg-exa-cross-region.zip)",
"Bash('/mnt/c/Program Files/draw.io/draw.io.exe' -x -f png -b 20 --crop -o /mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/tmp/oracle-ref-archs/oracle-adb-jde-exadata-arch-oracle/oracle-adb-jde-exadata-arch.png /mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/tmp/oracle-ref-archs/oracle-adb-jde-exadata-arch-oracle/oracle-adb-jde-exadata-arch.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 20 --crop -s 2 -o /mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/tmp/oracle-ref-archs/oracle-adb-jde-exadata-arch-oracle/oracle-adb-jde-exadata-arch.png /mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/tmp/oracle-ref-archs/oracle-adb-jde-exadata-arch-oracle/oracle-adb-jde-exadata-arch.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 20 --crop -s 2 -o tmp/oracle-ref-archs/oracle-adb-jde-exadata-arch-oracle/rendered.png tmp/oracle-ref-archs/oracle-adb-jde-exadata-arch-oracle/oracle-adb-jde-exadata-arch.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 20 --crop -s 2 -o tmp/compare-iter/fraud-as-is-template.png examples/output-meli-fraud-adbd-migration/fraud-as-is-template.drawio)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -b 20 --crop -s 2 -o tmp/compare-iter/fraud-to-be-template.png examples/output-meli-fraud-adbd-migration/fraud-to-be-template.drawio)",
"WebFetch(domain:www.bugraparlayan.com.tr)",
"WebFetch(domain:www.infolob.com)",
"Bash(curl -sS \"https://git.tech-lad.com.br/api/v1/repos/diegoecab/oci-deal-accelerator/actions/runs?limit=10\")",
"Bash(curl -sS \"https://git.tech-lad.com.br/api/v1/repos/diegoecab/oci-deal-accelerator/actions/secrets\")",
"Bash(curl -sS -o /dev/null -w \"%{http_code}\\\\n\" \"https://git.tech-lad.com.br/api/v1/repos/diegoecab/oci-deal-accelerator\")",
"Bash(curl -sS \"https://git.tech-lad.com.br/diegoecab/oci-deal-accelerator/raw/branch/main/.gitea/workflows/sku-catalog-refresh.yaml\")",
"Bash(curl -sS -o /dev/null -w \"%{http_code}\\\\n\" \"https://git.tech-lad.com.br/diegoecab/oci-deal-accelerator/raw/branch/main/.gitea/workflows/sku-catalog-refresh.yaml\")",
"Bash(curl -sS \"https://git.tech-lad.com.br/api/v1/repos/diegoecab/oci-deal-accelerator/issues?type=issues&state=open&limit=5&sort=newest\")",
"Bash(curl -sS \"https://git.tech-lad.com.br/api/v1/repos/diegoecab/oci-deal-accelerator/branches?limit=50\")",
"Bash(curl -sS \"https://git.tech-lad.com.br/api/v1/repos/diegoecab/oci-deal-accelerator\")",
"Bash(xargs -n1 grep -l '\"fidelity\"')",
"Bash(xargs '-I{}' .venv/bin/python -c ' *)",
"Bash(apt-cache show *)",
"Bash(apt list *)",
"Read(//mnt/c/Program Files/draw.io/**)",
"Read(//mnt/c/Users/Diego/AppData/Local/Programs/draw.io/**)",
"Read(//mnt/c/**)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -o /tmp/test-drawio.png examples/dataguard-exa-dedicated-in-region/out/dataguard-exa-dedicated-in-region.drawio)",
"Bash(mkdir -p /mnt/c/Users/Diego/tmp-drawio-export)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png -s 2 -o /mnt/c/Users/Diego/tmp-drawio-export/test.png examples/dataguard-exa-dedicated-in-region/out/dataguard-exa-dedicated-in-region.drawio)",
"Bash(mkdir -p '/mnt/c/Users/Diego/tmp-drawio-export')",
"Bash('/mnt/c/Program Files/draw.io/draw.io.exe' -x -f png -s 2 -o 'C:\\\\Users\\\\Diego\\\\tmp-drawio-export\\\\test.png' 'C:\\\\Users\\\\Diego\\\\Documents\\\\oci-deal-accelerator-oracle\\\\oci-deal-accelerator\\\\examples\\\\dataguard-exa-dedicated-in-region\\\\out\\\\dataguard-exa-dedicated-in-region.drawio')",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/asis-adb-s-local-dr.drawio --out tmp/drawio-renders/asis-v3.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-v3.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/asis-adb-s-local-dr.drawio --out tmp/drawio-renders/asis-v4.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-v4.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/asis-adb-s-local-dr.drawio --out tmp/drawio-renders/asis-v5.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-v5.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/asis-adb-s-local-dr.drawio --out tmp/drawio-renders/asis-v6.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-v6.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/asis-adb-s-local-dr.drawio --out tmp/drawio-renders/asis-v7.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-v7.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-v9.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-final.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/asis-adb-s-local-dr.drawio --out tmp/drawio-renders/asis-final2.png)",
"Bash(DRAWIO_EXE=\"/mnt/c/Program Files/draw.io/draw.io.exe\" .venv/bin/python tools/drawio_to_png.py --drawio examples/asis-tobe-adb-modernization/out/tobe-adb-d-active-dataguard.drawio --out tmp/drawio-renders/tobe-final2.png)",
"Bash(.githooks/pre-commit)",
"Bash(unzip -l awr_Transactional.zip)",
"Bash(mkdir -p /tmp/awr_meli)",
"Bash(unzip -o \"/mnt/c/Users/Diego/Documents/Meli/ABD-M/April 2026 - Fraude ADB-S to ADB-D/awr_Transactional.zip\")",
"Bash(cp \"/mnt/c/Users/Diego/Documents/Meli/ABD-M/April 2026 - Fraude ADB-S to ADB-D/meli-fraud-adbd-arch.pptx\" /tmp/meli-fraud-adbd-arch.pptx)",
"Bash(unzip -o meli-fraud-adbd-arch.pptx -d meli-pptx-extracted/)",
"Bash(cp -f \"/mnt/c/Users/Diego/Documents/Meli/ABD-M/April 2026 - Fraude ADB-S to ADB-D/meli-fraud-adbd-arch.pptx\" /tmp/meli-fraud-adbd-arch.pptx)",
"Bash(rm -rf /tmp/meli-pptx-extracted)",
"Bash(unzip -oq /tmp/meli-fraud-adbd-arch.pptx -d /tmp/meli-pptx-extracted/)",
"Bash(cp -f \"/mnt/c/Users/Diego/Documents/Meli/ABD-M/April 2026 - Fraude ADB-S to ADB-D/meli-fraud-adbd-arch.pptx\" meli-fraud-adbd-arch.pptx)",
"Bash(rm -rf meli-pptx-extracted)",
"Bash(unzip -oq meli-fraud-adbd-arch.pptx -d meli-pptx-extracted/)",
"Bash(yes)",
"WebFetch(domain:blog.shadabmohammad.com)",
"Bash(\"/mnt/c/Program Files/draw.io/draw.io.exe\" -x -f png --scale 1 -o \"$\\(pwd\\)/examples/output-aws-mysql-to-oci-heatwave-fsdr/aws-mysql-to-heatwave-fsdr.png\" \"$\\(pwd\\)/examples/output-aws-mysql-to-oci-heatwave-fsdr/aws-mysql-to-heatwave-fsdr.drawio\")"
] ]
} }
} }

View File

@@ -157,6 +157,32 @@ If a task generates YAML without the corresponding readable output, the task is
- **Readable outputs** — YAML is backing data, never the deliverable - **Readable outputs** — YAML is backing data, never the deliverable
- **No hallucinated architecture** — NEVER add services/components/regions the user didn't mention. When remediating WA gaps, apply minimum targeted fixes to the existing architecture; don't replace it with a generic "ideal" one. ASK before adding new services. - **No hallucinated architecture** — NEVER add services/components/regions the user didn't mention. When remediating WA gaps, apply minimum targeted fixes to the existing architecture; don't replace it with a generic "ideal" one. ASK before adding new services.
## Git Remotes (Gitea = source of truth, GitHub = mirror)
`origin` is configured with **one fetch URL (Gitea) and two push URLs (Gitea + GitHub)**, so a single `git push origin main` updates both. Gitea is authoritative; GitHub is a public mirror.
```
fetch → git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
push → git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
push → github.com/Diegoecab/oci-deal-accelerator.git
```
**Recreating this on a new clone** (the dual-push lives in `.git/config`, not tracked):
```bash
git remote set-url --add --push origin https://github.com/Diegoecab/oci-deal-accelerator.git
git remote set-url --add --push origin https://git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
git remote -v # confirm one fetch + two push URLs
```
The first command substitutes the implicit Gitea push URL with GitHub's; the second re-adds Gitea so origin pushes to BOTH. Counter-intuitive but necessary.
**Agent guidance**:
- After creating a commit on `main`, **always propose `git push origin main`** (not separate pushes per remote) — it covers both. Confirm with the user before pushing (pushing is shared-state).
- If a push fails on GitHub but succeeds on Gitea (or vice versa), the dual-push is partial. Re-run after fixing the failing one — Gitea's second push will be a no-op fast-forward.
- Never force-push Gitea unless explicitly asked; force-pushing GitHub is acceptable when needed because GitHub is downstream.
- If history diverges between Gitea and GitHub (e.g. after a rebase), the recovery pattern is: temporarily strip the GitHub URL (`git remote set-url --delete --push origin <github-url>`), push Gitea fast-forward, add `github` as a standalone remote, force-push, then restore the dual-push.
## Welcome Flow ## Welcome Flow
When the user starts a conversation without providing discovery notes or a specific request (e.g., a greeting or empty context), present the welcome message and capability menu defined in the skill's SKILL.md § Welcome Flow. Specifically: When the user starts a conversation without providing discovery notes or a specific request (e.g., a greeting or empty context), present the welcome message and capability menu defined in the skill's SKILL.md § Welcome Flow. Specifically:

View File

@@ -159,6 +159,32 @@ python tools/refresh_arch_catalog.py --validate # validate catalog
- **KB is the moat** — field experience, not documentation regurgitation - **KB is the moat** — field experience, not documentation regurgitation
- **ECAL-aligned** — Define → Design → Deliver with iterative checkpoints - **ECAL-aligned** — Define → Design → Deliver with iterative checkpoints
## Git Remotes (Gitea = source of truth, GitHub = mirror)
`origin` is configured with **one fetch URL (Gitea) and two push URLs (Gitea + GitHub)**, so a single `git push origin main` updates both. Gitea is authoritative; GitHub is a public mirror.
```
fetch → git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
push → git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
push → github.com/Diegoecab/oci-deal-accelerator.git
```
**Recreating this on a new clone** (the dual-push lives in `.git/config`, not tracked):
```bash
git remote set-url --add --push origin https://github.com/Diegoecab/oci-deal-accelerator.git
git remote set-url --add --push origin https://git.tech-lad.com.br/diegoecab/oci-deal-accelerator.git
git remote -v # confirm one fetch + two push URLs
```
The first command substitutes the implicit Gitea push URL with GitHub's; the second re-adds Gitea so origin pushes to BOTH. Counter-intuitive but necessary.
**Agent guidance**:
- After creating a commit on `main`, **always propose `git push origin main`** (not separate pushes per remote) — it covers both. Confirm with the user before pushing (pushing is shared-state).
- If a push fails on GitHub but succeeds on Gitea (or vice versa), the dual-push is partial. Re-run after fixing the failing one — Gitea's second push will be a no-op fast-forward.
- Never force-push Gitea unless explicitly asked; force-pushing GitHub is acceptable when needed because GitHub is downstream.
- If history diverges between Gitea and GitHub (e.g. after a rebase), the recovery pattern is: temporarily strip the GitHub URL (`git remote set-url --delete --push origin <github-url>`), push Gitea fast-forward, add `github` as a standalone remote, force-push, then restore the dual-push.
## Coding Guidelines ## Coding Guidelines
### 1. Think Before Coding ### 1. Think Before Coding

View File

@@ -19,11 +19,20 @@ help: ## Show this help
VENV_PYTHON := $(shell command -v python3.12 2>/dev/null || command -v python3.11 2>/dev/null || command -v python3.10 2>/dev/null || echo python3) VENV_PYTHON := $(shell command -v python3.12 2>/dev/null || command -v python3.11 2>/dev/null || command -v python3.10 2>/dev/null || echo python3)
venv: ## Create virtual environment and install dependencies venv: .venv/.deps-installed ## Create or update venv (idempotent — no-op if requirements.txt unchanged)
@echo "Using $(VENV_PYTHON) to create venv..."
$(VENV_PYTHON) -m venv .venv # Marker file pattern: the venv only rebuilds when requirements.txt is
# newer than the marker. A fresh `make venv` after a successful install
# is an instant no-op (was 1m53s on Codex sandboxes — every turn).
# To force a rebuild: `rm .venv/.deps-installed` (or `rm -rf .venv`).
.venv/.deps-installed: requirements.txt
@if [ ! -x .venv/bin/python ]; then \
echo "Using $(VENV_PYTHON) to create venv..."; \
$(VENV_PYTHON) -m venv .venv; \
fi
.venv/bin/pip install --upgrade pip .venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
@touch .venv/.deps-installed
@echo "Virtual environment ready. Run: source .venv/bin/activate" @echo "Virtual environment ready. Run: source .venv/bin/activate"
@echo "Tip: run 'make install-hooks' once to enable the pre-commit" @echo "Tip: run 'make install-hooks' once to enable the pre-commit"
@echo " hook that keeps SKILL.md in sync with the Codex copy." @echo " hook that keeps SKILL.md in sync with the Codex copy."

View File

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

View File

@@ -84,9 +84,9 @@ 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 — *services + sizing → PAYG vs BYOL* 4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
VALIDATE & CHECK VALIDATE & CHECK
───────────────── ─────────────────
@@ -110,13 +110,23 @@ Present these options as a compact numbered list. Each option has a bold title f
SA TOOLS SA TOOLS
───────────────── ─────────────────
13. 📦 BOM generator — *services + quantities → .xlsx Bill of Materials* 13. 📦 BOM generator — *formal .xlsx artefact you send to the customer*
14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA* 14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA*
━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━
Pick a number, or just describe what you need. Pick a number, or just describe what you need.
``` ```
### Example Direct Requests
If the user asks for examples, or skips the menu and writes a natural-language request, prompts like these map directly to the capabilities above:
- `Generame un diagrama nativo en .pptx, simple y prolijo, de esta arquitectura: usuarios externos -> load balancer público -> 2 VMs de aplicación en subnet privada -> Autonomous Database Serverless. Quiero que quede presentable para mostrar directo en PowerPoint. No me pidas YAML ni setup adicional.`
- `Sketch this architecture first for Excalidraw: external users -> public load balancer -> private app VMs -> Autonomous Database. Give me a native .excalidraw file so I can review and collaborate before making the formal diagram.`
- `Generate a simple high-level integration diagram for this flow: Slack -> Oracle Integration Cloud (OIC) -> Jira Service Management -> My Oracle Support. Add clear Client, Oracle, and External layers. Use brand icons for Slack and Jira Service Management, and deliver both .drawio and .pptx.`
- `Generate a high-level architecture diagram for this scenario: 1 PostgreSQL in OCI Ashburn, accessed from GCP Virginia through interconnect. PostgreSQL connects internally to 1 Autonomous Database Serverless, which also has 1 refreshable clone in the same region but in a different AD. Deliver both .drawio and .pptx. Keep it simple, executive-friendly, and technically correct.`
- `Gere um BOM em USD para este cenário: PostgreSQL no OCI com 4 OCPU e 500 GB, ADB-S com 200 ECPU e 1 TB BYOL, 1 refreshable clone com o mesmo sizing e FastConnect de 1 Gbps. Aplique 11% de desconto, 12 meses, 24 horas por dia. Quero o arquivo .xlsx e um resumo claro do custo mensal e anual.`
### Behavior Rules ### Behavior Rules
- If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)." - If the user picks **1**, ask: "Paste your discovery notes (meeting notes, emails, whatever you have)."
@@ -125,16 +135,31 @@ Pick a number, or just describe what you need.
``` ```
1. Describe the architecture you want to diagram (or paste a YAML spec if you have one). 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.
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).
3. **Author the spec in `absolute_layout` shape** — this is REQUIRED. Do NOT use the legacy workload-driven shape (`tenancy → regions → compartments → services`); it does not resolve OCI icon stencils, does not run through the spec validator, and produces wireframe-looking output (rectangles with text instead of real OCI icons). Every container, service, label, and connection needs explicit `(x, y, w, h)`. 3. **Author the spec in `absolute_layout` shape** — this is REQUIRED. Do NOT use the legacy workload-driven shape (`tenancy → regions → compartments → services`); it does not resolve OCI icon stencils, does not run through the spec validator, and produces wireframe-looking output (rectangles with text instead of real OCI icons). Every container, service, label, and connection needs explicit `(x, y, w, h)`.
@@ -147,7 +172,16 @@ Pick a number, or just describe what you need.
Full reference: `docs/skill/output-formats.md` § "Standard diagram-generation procedure (MANDATORY)". Full reference: `docs/skill/output-formats.md` § "Standard diagram-generation procedure (MANDATORY)".
- If the user picks **3**, ask: "Describe the architecture or paste the spec. I'll generate the deck with a native OCI PowerPoint diagram when the architecture is structured enough." - If the user picks **3**, ask: "Describe the architecture or paste the spec. I'll generate the deck with a native OCI PowerPoint diagram when the architecture is structured enough."
- If the user picks **4**, ask: "What services and sizing? (e.g., 'ADB-S 8 OCPU + 2 VMs + FastConnect')" - If the user picks **4**, ask: "What services and sizing? (e.g., 'ADB-S 8 OCPU + 2 VMs + FastConnect'). License model — PAYG, BYOL, or both?"
Then follow the cost-estimate flow:
1. **Catalog first, web never.** `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) (auto-refreshed, authoritative) for the requested SKUs and consult [`docs/bom-cookbook.md`](docs/bom-cookbook.md) for copy-paste recipes (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block, FastConnect, etc.). Use `kb/pricing/compute.yaml` for shape-level lookups. Do NOT reach for WebSearch / WebFetch / `oracle.com` for list prices — the catalog IS the source.
2. **Staleness gate.** Read `last_verified` at the top of the catalog. If >60 days stale, warn the user and offer `python tools/refresh_sku_catalog.py --refresh` before quoting. If <=60 days, proceed without web lookups.
3. **Catalog miss policy.** If a SKU is genuinely absent after `grep` (not "I didn't find it on first try"), state it explicitly, then run `python tools/refresh_sku_catalog.py --refresh` and re-grep. Only if a fresh catalog still doesn't surface it is `oracle.com/cloud/price-list/` an acceptable fallback — and cite the source URL next to the part number.
4. **Source extraction.** When the request references a customer artefact (`.pptx`, BOM, quote), extract the topology / sizing from the artefact first, list each scenario's components, then map to SKUs. Show the mapping (component → SKU) so the SA can audit it.
5. **Quote shape.** Per scenario list: SKU + product name + metric + list price USD + quantity + monthly subtotal. End with total $/month and total $/year. Cite the catalog's `last_verified` date in the output.
6. When both PAYG and BYOL are requested, present them side-by-side (same SKU rows, two price columns). Save the cost estimate to `examples/<customer>-cost-estimate.yaml` and present the formatted table in the terminal.
7. **Disclaimer (mandatory).** Read the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` and append it verbatim — as a footer line under the terminal table, and (if a deck is generated) as a final "Commercial Disclaimer" slide. Do NOT paraphrase or rewrite; the field already carries Oracle's sample-quote boilerplate plus the "reference only / no client-specific discounts" clauses. `tools/oci_bom_gen.py` already reads this field for BOM xlsx output.
8. **Deck verification (if a deck is generated).** Open the produced `.pptx` with `python-pptx` and assert: (a) slide count matches spec, (b) each slide's expected title is present, (c) chart types match what the spec asked for (e.g. `COLUMN_CLUSTERED`, `COLUMN_STACKED`), (d) the disclaimer slide is the last one. Print a one-line `deck OK: <N> slides, titles=[...]` summary so the SA can audit at a glance. Rasterizing to PNG via PowerPoint is not always available on Linux/WSL — structural validation is the floor, not optional.
- If the user picks **5**, ask: "Describe your architecture or paste the spec. I'll run the 5-pillar review." Then follow the WA review flow: - If the user picks **5**, ask: "Describe your architecture or paste the spec. I'll run the 5-pillar review." Then follow the WA review flow:
1. Parse input to build a workload profile YAML (flags) and architecture YAML 1. Parse input to build a workload profile YAML (flags) and architecture YAML
2. If input is a `.pptx` file, extract text content from all slides to infer architecture and workload context 2. If input is a `.pptx` file, extract text content from all slides to infer architecture and workload context
@@ -166,13 +200,22 @@ Pick a number, or just describe what you need.
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`.
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:
@@ -218,7 +261,7 @@ Pick a number, or just describe what you need.
**BOM Output Rules:** **BOM Output Rules:**
- NEVER include "Confidential: Internal ONLY" or any confidentiality marking - NEVER include "Confidential: Internal ONLY" or any confidentiality marking
- ALWAYS include the Oracle Cost Estimator disclaimer at the bottom - ALWAYS include the disclaimer from `kb/pricing/oci-sku-catalog.yaml` `disclaimer:` field at the bottom — `tools/oci_bom_gen.py` reads it automatically (do not duplicate, paraphrase, or maintain a second copy elsewhere)
- Only include SKUs the customer actually requested — never dump the full catalog - Only include SKUs the customer actually requested — never dump the full catalog
- Show cost proportions so the customer can see where their spend concentrates - Show cost proportions so the customer can see where their spend concentrates
- Use Excel formulas (not static values) so the customer can adjust quantities - Use Excel formulas (not static values) so the customer can adjust quantities
@@ -241,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
@@ -444,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`.
@@ -459,9 +502,9 @@ See [docs/skill/output-formats.md](docs/skill/output-formats.md) for the per-cus
## Cookbook: Building Tool Payloads ## Cookbook: Building Tool Payloads
Before mapping customer requirements to SKUs for `generate_bom`, `generate_bom_appca`, or `generate_cost_estimate`, check [docs/bom-cookbook.md](docs/bom-cookbook.md) — it has copy-paste recipes for common patterns (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block + FastConnect) and explicitly names the gotchas that otherwise burn exploration turns (e.g. ADB-Dedicated shares infrastructure SKUs with ExaCS — there are no separate `adb_dedicated` SKUs in the catalog). Any pricing or SKU work — cost estimate (option 4), BOM (option 13), BOM-AppCA (option 14), business-case TCO (option 8), scenario comparison from a customer artefact (`.pptx`, BOM, quote) — starts in the local catalog, not on the web. Check [docs/bom-cookbook.md](docs/bom-cookbook.md) for copy-paste recipes (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block + FastConnect) and the gotchas that otherwise burn exploration turns (e.g. ADB-Dedicated shares infrastructure SKUs with ExaCS — there are no separate `adb_dedicated` SKUs in the catalog).
If a requirement does not match a recipe, `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) and consult `kb/services/` before inventing SKUs. If a requirement does not match a recipe, `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) and consult `kb/services/` before inventing SKUs. See "What You Do NOT Do" for the web-lookup rule and `kb/pricing/oci-sku-catalog.yaml`'s `last_verified` for staleness handling.
--- ---
@@ -569,6 +612,7 @@ KB lives under `kb/`. See [kb/README.md](kb/README.md) for the directory map, fr
- You do NOT replace the architect's judgment. You accelerate it. - You do NOT replace the architect's judgment. You accelerate it.
- You do NOT generate pixel-perfect diagrams. You generate 80% drafts the architect refines. - You do NOT generate pixel-perfect diagrams. You generate 80% drafts the architect refines.
- You do NOT make up pricing. If you don't have current pricing, estimate ranges. - You do NOT make up pricing. If you don't have current pricing, estimate ranges.
- You do NOT use WebSearch / WebFetch / `oracle.com` for OCI list prices or SKU lookups. `kb/pricing/oci-sku-catalog.yaml` is auto-refreshed via `tools/refresh_sku_catalog.py` (Oracle apexapps pricing API) and is the source of truth — including for ECPU/OCPU rates, BYOL deltas, Exadata X11M part numbers, and ADB-D ECPU SKUs. Web fallback is allowed ONLY when (a) the SKU is genuinely absent after `grep` AND a fresh `--refresh` still does not surface it, or (b) the catalog's `last_verified` is >60 days stale and a refresh is not currently possible. In either case, cite the source URL alongside the part number. Reflexively reaching for WebSearch when the catalog already has the answer is the most common cost-estimate failure mode — don't.
- You do NOT claim features exist if you're unsure. Check the KB first. - You do NOT claim features exist if you're unsure. Check the KB first.
- You do NOT do detailed project management. DELIVER artifacts are lightweight handover aids. - You do NOT do detailed project management. DELIVER artifacts are lightweight handover aids.
- You do NOT add services or components the user did not request. - You do NOT add services or components the user did not request.

45
config/brand-icons.yaml Normal file
View File

@@ -0,0 +1,45 @@
version: 1
# Resolution policy:
# 1. Prefer officially downloaded vendor assets committed under
# kb/diagram/brand-icons/official/
# 2. Fall back to vetted local assets under
# kb/diagram/brand-icons/fallback/
#
# The renderers resolve by alias first, then walk `resolution_order`
# until they find an existing file.
resolution_order:
- official_svg
- official_png
- fallback_svg
- fallback_png
icons:
slack:
label: Slack
aliases:
- slack_app
- slack_bot
official_png: kb/diagram/brand-icons/official/slack/slack_hash_256.png
jira:
label: Jira
aliases:
- atlassian_jira
official_svg: kb/diagram/brand-icons/official/jira/Jira_icon.svg
jira_service_management:
label: Jira Service Management
aliases:
- jsm
- jira-service-management
- jira service management
official_svg: kb/diagram/brand-icons/official/jira-service-management/Jira-Service-Management_icon.svg
jirasoftware:
label: Jira Software
aliases:
- jira_software
- jira-software
- jira software
fallback_svg: kb/diagram/brand-icons/fallback/jirasoftware.svg

View 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`.

View 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"

View File

@@ -18,6 +18,78 @@ last_verified: "2026-03-16"
source: "https://docs.oracle.com/en/solutions/oracle-architecture-center/" source: "https://docs.oracle.com/en/solutions/oracle-architecture-center/"
entry_count: 123 entry_count: 123
# =============================================================================
# KNOWN GAPS — topologies the catalog does NOT cover well
# =============================================================================
#
# Surfaced by tools/archcenter_pattern_lookup.py at the top of its output
# whenever the user's query tokens match any one of the `triggers` token
# sets below. Purpose: stop the lookup loop early when no canonical Oracle
# ref-arch exists for what the user asked, and tell the agent how to
# COMPOSE the topology from primitives instead of refining query phrasing.
#
# Schema per gap:
# id: unique slug
# triggers: list of token sets — fires when the expanded query contains
# EVERY token in at least ONE set (OR across sets, AND inside
# each set). Tokens are lowercased and synonym-expanded the
# same way as scoring tokens.
# notice: multi-line string surfaced verbatim above the score-based
# top-K. Should explain WHY this is a gap and HOW to compose
# from existing primitives. Be concrete (slugs, alias names).
#
# Add a gap entry only after a real lookup loop wasted user time on a
# topology with no canonical match. Speculation belongs elsewhere.
known_gaps:
- id: gcp-to-oci-native-services
triggers:
- [gcp, postgresql]
- [gcp, redis]
- [gcp, mysql]
- [gcp, oke]
- [gcp, cache]
- [gcp, compute]
- [google, postgresql]
- [google, redis]
- [google, mysql]
- [google, oke]
- [google, cache]
- [google, compute]
notice: |
No canonical Oracle ref-arch covers OCI-native services (PostgreSQL,
OKE, Cache, etc.) accessed FROM Google Cloud via Cross-Cloud
Interconnect. The `Database@Google Cloud` entries below are the
OPPOSITE direction (Oracle DB running ON GCP, not GCP apps reaching
OCI-native services).
Recommended composition — STOP refining the lookup query, build from:
1. A simple VCN baseline (e.g. `start-with-an-oci-virtual-cloud-network`).
2. The OCI service in a private subnet behind DRG + FastConnect VC.
3. A `GCP <region>` container on the left with `cloud_icon: gcp_compute`
(or the relevant GCP service — the renderer maps to mxgraph.gcp2.*
the same way it does for AWS).
- id: newer-oci-services-without-icon
triggers:
- [redis]
- [valkey]
- [oci_cache]
notice: |
OCI Cache with Redis/Valkey is GA after the v24.2 toolkit shipped, so
no dedicated ref-arch exists in the catalog and no toolkit icon ships
either. Oracle's own multi-service ref archs that DO mention Redis
embed inline SVG over the generic database/cache stencil.
Recommended path — don't keep searching:
1. Pick the closest topology BY SHAPE (HA pair, primary+replica,
multi-AZ) from the catalog — geometry transfers, names don't.
2. Use `type: cache` (or `type: database`) with explicit
`label: "OCI Cache (Redis)"`. The renderer's alias table already
routes `type: redis` → generic stencil.
3. Reference: SKILL.md option 2 step 3 (newer-OCI-services convention).
entries: entries:
# ===== DATABASE — MULTICLOUD (Database@Azure) ===== # ===== DATABASE — MULTICLOUD (Database@Azure) =====

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Jira Software</title><path d="M12.004 0c-2.35 2.395-2.365 6.185.133 8.585l3.412 3.413-3.197 3.198a6.501 6.501 0 0 1 1.412 7.04l9.566-9.566a.95.95 0 0 0 0-1.344L12.004 0zm-1.748 1.74L.67 11.327a.95.95 0 0 0 0 1.344C4.45 16.44 8.22 20.244 12 24c2.295-2.298 2.395-6.096-.08-8.533l-3.47-3.469 3.2-3.2c-1.918-1.955-2.363-4.725-1.394-7.057z"/></svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -0,0 +1,11 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#FFC716"/>
<g clip-path="url(#clip0_1_6409)">
<path d="M27.4016 20.4052H36.3282C37.6688 20.4052 38.1266 21.6804 37.3091 22.694L23.347 39.926C18.8347 36.3292 19.2597 30.6397 22.5623 26.487L27.4016 20.4052ZM20.5023 27.2718H11.5757C10.2351 27.2718 9.77728 25.9966 10.5947 24.9829L24.5569 7.75098C29.0692 11.3478 28.5787 16.9719 25.3089 21.1572L20.5023 27.2718Z" fill="#101214"/>
</g>
<defs>
<clipPath id="clip0_1_6409">
<rect width="27.5283" height="32.175" fill="white" transform="translate(10.1876 7.75049)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 773 B

View File

@@ -0,0 +1,11 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#1868DB"/>
<g clip-path="url(#clip0_1_5403)">
<path d="M17.9475 31.0469H15.2429C11.1638 31.0469 8.23755 28.5484 8.23755 24.8899H22.7804C23.5341 24.8899 24.0218 25.4252 24.0218 26.1837V40.8178C20.3861 40.8178 17.9475 37.8731 17.9475 33.7684V31.0469ZM25.1303 23.7745H22.4257C18.3466 23.7745 15.4203 21.3206 15.4203 17.6621H29.9631C30.7168 17.6621 31.2489 18.1528 31.2489 18.9113V33.5454C27.6132 33.5454 25.1303 30.6007 25.1303 26.496V23.7745ZM32.3573 16.5467H29.6527C25.5736 16.5467 22.6473 14.0482 22.6473 10.3896H37.1902C37.9439 10.3896 38.4316 10.925 38.4316 11.6389V26.273C34.7959 26.273 32.3573 23.3283 32.3573 19.2236V16.5467Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1_5403">
<rect width="30.1941" height="30.4281" fill="white" transform="translate(8.23755 10.3901)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -10,7 +10,8 @@ disclaimer: 'This sample quote is provided solely for evaluation purposes and is
for the services specified. If you would like to purchase the services specified in this sample quote, please request that for the services specified. If you would like to purchase the services specified in this sample quote, please request that
Oracle issue you a formal quote (which may include an OMA or a CSA if you do not already have an appropriate agreement in Oracle issue you a formal quote (which may include an OMA or a CSA if you do not already have an appropriate agreement in
place with Oracle) for your acceptance and execution. Your formal quote will be effective only upon Oracle''s acceptance place with Oracle) for your acceptance and execution. Your formal quote will be effective only upon Oracle''s acceptance
of the formal quote (and the OMA or CSA, if required). of the formal quote (and the OMA or CSA, if required). This is reference material, not a commercial commitment, and any
client-specific discounts negotiated with Oracle are not reflected in the prices shown.
' '
categories: categories:

View File

@@ -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

View File

@@ -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

View 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"

View 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"

View File

@@ -327,12 +327,42 @@ def _patterns_for(entry: dict) -> list[str]:
return _build_patterns_index().get(entry.get("url", ""), []) return _build_patterns_index().get(entry.get("url", ""), [])
def lookup(query: str, top: int = 5, expand_synonyms: bool = True) -> list[dict]: def _matched_gaps(query_tokens: set[str], gaps: list[dict]) -> list[dict]:
"""Return every known gap whose triggers match the (expanded) query.
A gap fires when ANY one of its `triggers` token sets is a subset of
`query_tokens` — i.e. AND inside each set, OR across sets. This
matches the "stop searching, compose instead" intent: if even one
narrow trigger pair (e.g. `[gcp, postgresql]`) is in the query, the
catalog has nothing canonical to offer.
"""
matched: list[dict] = []
for gap in gaps or []:
triggers = gap.get("triggers") or []
for trigger_set in triggers:
tokens_required = {t.lower() for t in trigger_set}
if tokens_required and tokens_required.issubset(query_tokens):
matched.append(gap)
break
return matched
def lookup(query: str, top: int = 5, expand_synonyms: bool = True) -> dict:
"""Score the catalog against `query` and return the top-K plus any
known-gap notices the query triggers.
Returns a dict shaped as:
{"matches": [<top-K entry dicts>], "gaps": [<matched gap dicts>]}
Callers that only care about matches can read `result["matches"]`.
"""
catalog = yaml.safe_load(CATALOG.read_text(encoding="utf-8")) catalog = yaml.safe_load(CATALOG.read_text(encoding="utf-8"))
entries = catalog.get("entries", []) entries = catalog.get("entries", [])
gaps_def = catalog.get("known_gaps", []) or []
qt = _tokens(query) qt = _tokens(query)
if expand_synonyms: if expand_synonyms:
qt = _expand_query_tokens(qt) qt = _expand_query_tokens(qt)
matched_gaps = _matched_gaps(qt, gaps_def)
scored = [] scored = []
# Two-pass scoring: cheap fields first (title/tags/services/summary), # Two-pass scoring: cheap fields first (title/tags/services/summary),
# then enrich the top-K with expensive fields (description text from # then enrich the top-K with expensive fields (description text from
@@ -372,10 +402,19 @@ def lookup(query: str, top: int = 5, expand_synonyms: bool = True) -> list[dict]
"visual_patterns": _patterns_for(e), "visual_patterns": _patterns_for(e),
"has_description": bool(c.get("_description")), "has_description": bool(c.get("_description")),
}) })
return scored return {"matches": scored, "gaps": matched_gaps}
def _print_results(query: str, results: list[dict]) -> None: def _print_results(query: str, payload: dict) -> None:
gaps = payload.get("gaps") or []
results = payload.get("matches") or []
for gap in gaps:
notice = (gap.get("notice") or "").rstrip()
gap_id = gap.get("id", "unknown")
print(f"⚠ KNOWN GAP — {gap_id}")
for line in notice.splitlines():
print(f" {line}" if line else "")
print()
if not results: if not results:
print(f"No matches for: {query}") print(f"No matches for: {query}")
return return

194
tools/brand_icon_catalog.py Normal file
View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Shared resolver for non-OCI brand icons used by drawio and PPTX.
Policy:
- Prefer officially downloaded vendor assets when committed locally.
- Fall back to vetted local assets only when an official asset is not
available or practical to automate.
- Never depend on remote URLs at render time.
"""
from __future__ import annotations
import base64
import re
import struct
import xml.etree.ElementTree as ET
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CATALOG_PATH = PROJECT_ROOT / "config" / "brand-icons.yaml"
DEFAULT_RESOLUTION_ORDER = (
"official_svg",
"official_png",
"fallback_svg",
"fallback_png",
)
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def _slug(value: str | None) -> str:
if not value:
return ""
return re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
@lru_cache(maxsize=1)
def _load_catalog() -> dict[str, Any]:
raw = yaml.safe_load(DEFAULT_CATALOG_PATH.read_text(encoding="utf-8")) or {}
icons = raw.get("icons") or {}
alias_map: dict[str, str] = {}
normalized_icons: dict[str, dict[str, Any]] = {}
for key, entry in icons.items():
icon_id = _slug(key)
normalized = dict(entry or {})
normalized["id"] = icon_id
normalized_icons[icon_id] = normalized
alias_map[icon_id] = icon_id
for alias in normalized.get("aliases") or []:
alias_map[_slug(alias)] = icon_id
return {
"resolution_order": tuple(raw.get("resolution_order") or DEFAULT_RESOLUTION_ORDER),
"icons": normalized_icons,
"aliases": alias_map,
}
def resolve_brand_icon(brand_icon: str | None) -> dict[str, Any] | None:
slug = _slug(brand_icon)
if not slug:
return None
catalog = _load_catalog()
icon_id = catalog["aliases"].get(slug)
if not icon_id:
return None
entry = dict(catalog["icons"][icon_id])
for key in catalog["resolution_order"]:
rel_path = entry.get(key)
if not rel_path:
continue
path = PROJECT_ROOT / rel_path
if path.exists():
suffix = path.suffix.lower()
return {
**entry,
"path": path,
"resolution_key": key,
"format": suffix.lstrip("."),
"mime_type": _mime_type_for_suffix(suffix),
}
return None
def available_brand_icons() -> list[str]:
return sorted(_load_catalog()["icons"].keys())
def brand_icon_data_uri(brand_icon: str | None) -> str | None:
resolved = resolve_brand_icon(brand_icon)
if not resolved:
return None
data = resolved["path"].read_bytes()
encoded = base64.b64encode(data).decode("ascii")
# draw.io stores inline image payloads in mxCell styles as
# image=data:image/<type>,<base64> (without ";base64"). Keeping the
# payload in that draw.io-native form avoids the semicolon breaking the
# style parser, which treats ";" as a style key separator.
return f"data:{resolved['mime_type']},{encoded}"
def brand_icon_size(brand_icon: str | None) -> tuple[int, int] | None:
resolved = resolve_brand_icon(brand_icon)
if not resolved:
return None
data = resolved["path"].read_bytes()
fmt = resolved["format"]
if fmt == "svg":
return _svg_dimensions(data)
if fmt == "png":
return _png_dimensions(data)
return None
def drawio_icon_entry(brand_icon: str | None) -> dict[str, Any] | None:
data_uri = brand_icon_data_uri(brand_icon)
size = brand_icon_size(brand_icon)
if not data_uri or not size:
return None
width, height = size
cell = (
f'<mxCell id="1" value="" '
f'style="shape=image;aspect=fixed;imageAspect=1;image={data_uri};'
f'strokeColor=none;fillColor=none;html=1;" '
f'vertex="1" parent="1">'
f'<mxGeometry x="0" y="0" width="{width}" height="{height}" as="geometry" />'
f'</mxCell>'
)
return {"w": width, "h": height, "cells": [cell]}
def brand_icon_png_bytes(brand_icon: str | None) -> bytes | None:
resolved = resolve_brand_icon(brand_icon)
if not resolved:
return None
data = resolved["path"].read_bytes()
if resolved["format"] == "png":
return data
if resolved["format"] == "svg":
import cairosvg
return cairosvg.svg2png(bytestring=data)
return None
def brand_icon_media_name(brand_icon: str | None, extension: str = "png") -> str:
resolved = resolve_brand_icon(brand_icon)
stem = _slug((resolved or {}).get("id") or brand_icon or "brand-icon")
return f"{stem}.{extension.lstrip('.')}"
def _mime_type_for_suffix(suffix: str) -> str:
if suffix == ".svg":
return "image/svg+xml"
if suffix == ".png":
return "image/png"
return "application/octet-stream"
def _svg_dimensions(svg_bytes: bytes) -> tuple[int, int]:
root = ET.fromstring(svg_bytes)
view_box = root.get("viewBox")
if view_box:
parts = view_box.replace(",", " ").split()
if len(parts) == 4:
width = max(int(round(float(parts[2]))), 1)
height = max(int(round(float(parts[3]))), 1)
return width, height
width = _svg_length(root.get("width"))
height = _svg_length(root.get("height"))
if width and height:
return width, height
return (24, 24)
def _svg_length(value: str | None) -> int | None:
if not value:
return None
match = re.search(r"([0-9]+(?:\.[0-9]+)?)", value)
if not match:
return None
return max(int(round(float(match.group(1)))), 1)
def _png_dimensions(png_bytes: bytes) -> tuple[int, int]:
if png_bytes[:8] != PNG_SIGNATURE:
raise ValueError("Invalid PNG signature")
if png_bytes[12:16] != b"IHDR":
raise ValueError("PNG missing IHDR chunk")
width, height = struct.unpack(">II", png_bytes[16:24])
return max(int(width), 1), max(int(height), 1)

View File

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

View File

@@ -0,0 +1,437 @@
#!/usr/bin/env python3
"""
Build MELI Fraud As-Is and To-Be .drawio files by cloning and editing
examples/output-meli-fraud-adbd-migration/adbsplusadbd.drawio — the ADB
(ATP-S / ATP-D) reference Diego already accepted visually.
Strategy:
1. Load the source file.
2. Partition cells into AS-IS (x < 4630) and TO-BE (x >= 4630) panels;
edges are assigned to a panel based on their source/target cell.
3. For each output, delete the panel we don't need.
4. Relabel the title banner, notes, and trade-offs text for MELI.
5. Clone the ATP-S / ATP-D icon cells (single-cell stencils) to add a
second workload row (Transactional) and a Clone column (As-Is only).
6. Add a Montreal DR region panel to the right.
7. Save.
Constraint: interior labels like "OCI Ashburn" / "AD 3" / "AD 2" / "R/W Pool"
are plain mxCell values we CAN edit — so we rename AD 3 → AD-X, AD 2 → AD-Y,
adjust R/W Pool label, etc.
"""
from __future__ import annotations
import copy
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from drawio_template_transform import DrawioTemplate # noqa: E402
SRC = Path("examples/output-meli-fraud-adbd-migration/adbsplusadbd.drawio")
OUT_DIR = Path("examples/output-meli-fraud-adbd-migration")
# Oracle palette
ORACLE_RED = "#C74634"
ORACLE_GREEN = "#00875A"
SLATE_BLUE = "#6E89AD"
AMBER = "#E8A92F"
MUTED_GREY = "#70665E"
CHARCOAL = "#312D2A"
# ---------------------------------------------------------------------------
# Panel partitioning
# ---------------------------------------------------------------------------
def partition_panels(t: DrawioTemplate, split_x: float = 4630):
"""Return (as_is_ids, to_be_ids) — both sets cover all vertex cells.
Edges are classified by their source/target positions.
"""
as_is, to_be = set(), set()
def cell_x(cid: str) -> float | None:
c = t.cells.get(cid)
if c is None:
return None
geo = c.find("mxGeometry")
if geo is None:
return None
try:
return float(geo.get("x") or 0)
except ValueError:
return None
# Vertices first: classify by absolute x (walk parents to get absolute x)
def abs_x(cid: str) -> float:
x = 0.0
cur = cid
guard = 0
while cur and cur in t.cells and guard < 30:
geo = t.cells[cur].find("mxGeometry")
if geo is not None:
try:
x += float(geo.get("x") or 0)
except ValueError:
pass
parent = t.cells[cur].get("parent") or ""
if parent in ("0", "1", ""):
break
cur = parent
guard += 1
return x
for cid, c in t.cells.items():
if cid in ("0", "1"):
continue
if c.get("edge") == "1":
continue
ax = abs_x(cid)
if ax >= split_x:
to_be.add(cid)
else:
as_is.add(cid)
# Edges follow their source/target
for cid, c in t.cells.items():
if c.get("edge") != "1":
continue
src = c.get("source")
tgt = c.get("target")
in_tobe = any(ref and ref in to_be for ref in (src, tgt))
in_asis = any(ref and ref in as_is for ref in (src, tgt))
if in_tobe and not in_asis:
to_be.add(cid)
elif in_asis and not in_tobe:
as_is.add(cid)
else:
# Ambiguous — keep with TO BE by default
to_be.add(cid)
return as_is, to_be
def delete_cells(t: DrawioTemplate, cell_ids: set[str]) -> None:
for cid in list(cell_ids):
c = t.cells.get(cid)
if c is not None:
try:
t.model_root.remove(c)
except ValueError:
pass
t.reindex()
# ---------------------------------------------------------------------------
# Text helpers — Oracle templates often store rich-HTML values
# ---------------------------------------------------------------------------
def replace_rich_label(t: DrawioTemplate, cell_id: str, plain_text: str,
color: str = CHARCOAL, bold: bool = True,
size: int = 16) -> None:
"""Replace a cell's <value> with simple HTML — keeps drawio rendering happy."""
weight = "bold" if bold else "normal"
html = (
f'<h1 style="color: {color}; text-align: left; '
f'line-height: 1.1; margin: 0; font-size: {size}px; font-weight: {weight};">'
f'{plain_text}</h1>'
)
t.cells[cell_id].set("value", html)
def add_pill(t: DrawioTemplate, text: str, x: float, y: float,
fill: str, stroke: str, font_color: str = "#FCFBFA",
w: float = 130, h: float = 22) -> str:
style = (
f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};"
f"strokeColor={stroke};strokeWidth=1;fontColor={font_color};"
f"fontSize=10;fontStyle=1;fontFamily=Oracle Sans;"
f"align=center;verticalAlign=middle;arcSize=30;"
)
return t.add_cell(text, style, "1", x, y, w, h)
def add_soft_box(t: DrawioTemplate, label: str, x: float, y: float,
w: float, h: float, fill: str, stroke: str,
font_color: str = CHARCOAL) -> str:
style = (
f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};"
f"strokeColor={stroke};strokeWidth=1;dashed=1;dashPattern=6 4;"
f"fontColor={font_color};fontSize=12;fontStyle=1;"
f"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
f"spacingLeft=8;spacingTop=6;arcSize=4;"
)
return t.add_cell(label, style, "1", x, y, w, h)
# ---------------------------------------------------------------------------
# As-Is builder — left panel of adbsplusadbd, ATP-S icons
# ---------------------------------------------------------------------------
def build_as_is() -> None:
t = DrawioTemplate(SRC)
as_is, to_be = partition_panels(t)
delete_cells(t, to_be)
P = "GVNBTaRQvtpjXlJZoCOU" # id prefix used in the source
# Title: cell 87 holds the AS IS title banner text
replace_rich_label(t, f"{P}-87", "MELI Fraud — As-Is (ADB-S)",
color="#FCFBFA", bold=True, size=22)
# Make the banner red + solid
t.patch_style(f"{P}-87",
fillColor=ORACLE_RED,
strokeColor="none",
fontColor="#FCFBFA",
align="center",
verticalAlign="middle")
# Remove the notes and trade-offs text boxes — not needed in the simple view
for cid in (f"{P}-88", f"{P}-89", f"{P}-86"):
if cid in t.cells:
t.delete_subtree(cid)
# Relabel AD 3 → AD-X, AD 2 → AD-Y to match the prompt
if f"{P}-93" in t.cells:
t.set_label(f"{P}-93", "AD-X")
if f"{P}-94" in t.cells:
t.set_label(f"{P}-94", "AD-Y")
# Rename R/W Pool → Fraude (it holds Fraude's primary + standby)
# Cell 95 is the R/W Pool container in AS IS.
if f"{P}-95" in t.cells:
t.set_label(f"{P}-95", "Fraude (19c · ADB-S)")
# Relabel the ATP-S icons: 100 = primary, 99 = L-SBY. Replace their
# rich HTML values with simple labels.
if f"{P}-100" in t.cells:
replace_rich_label(t, f"{P}-100", "Fraude — Primary", color=ORACLE_RED,
size=11)
if f"{P}-99" in t.cells:
replace_rich_label(t, f"{P}-99", "Fraude — Local Standby (ADG)",
color=SLATE_BLUE, size=11)
# Cell 112 is an extra ATP-S icon floating; reuse as Fraude Clone (amber)
if f"{P}-112" in t.cells:
replace_rich_label(t, f"{P}-112", "Fraude — Clone (R/O)",
color="#7A5510", size=11)
# No tone override on the stencil itself — keep the OCI teal icon but
# wrap an amber dashed pill around it.
geo = t.cells[f"{P}-112"].find("mxGeometry")
if geo is not None:
gx = float(geo.get("x") or 0)
gy = float(geo.get("y") or 0)
gw = float(geo.get("width") or 60)
gh = float(geo.get("height") or 50)
t.add_cell(
"", style=(
f"rounded=1;whiteSpace=wrap;html=1;fillColor=none;"
f"strokeColor={AMBER};strokeWidth=2;dashed=1;dashPattern=6 4;"
f"arcSize=8;"
),
parent="1", x=gx - 6, y=gy - 6, w=gw + 12, h=gh + 14,
)
# Delete notes cell 111 if present (the "A) Allow L-SBY..." side note)
if f"{P}-111" in t.cells:
t.delete_subtree(f"{P}-111")
# ── Second workload row (Transactional) — clone the pool + icons ────
# Clone the pool container (95), primary stencil (100), standby (99),
# and the small customer icon group (101) with a y-offset of ~290 so
# they sit below the existing row.
dy = 290
clone_map = {}
for src_id in (f"{P}-95", f"{P}-100", f"{P}-99"):
if src_id not in t.cells:
continue
m = t.clone_subtree(src_id, dx=0, dy=dy,
id_prefix=f"txn_{src_id.split('-')[-1]}_")
clone_map.update(m)
# Label the cloned pool
cloned_pool = clone_map.get(f"{P}-95")
if cloned_pool:
t.set_label(cloned_pool, "Transactional — Fraude Team (26ai · ADB-S)")
cloned_primary = clone_map.get(f"{P}-100")
if cloned_primary:
replace_rich_label(t, cloned_primary, "Transactional — Primary",
color=ORACLE_RED, size=11)
cloned_sby = clone_map.get(f"{P}-99")
if cloned_sby:
replace_rich_label(t, cloned_sby, "Transactional — Local Standby (ADG)",
color=SLATE_BLUE, size=11)
# Transactional Refreshable Clone — clone the ATP-S icon (cell 112) and
# place it below the Fraude clone
if f"{P}-112" in t.cells:
m = t.clone_subtree(f"{P}-112", dx=0, dy=dy,
id_prefix="txn_clone_")
cloned_clone = m.get(f"{P}-112")
if cloned_clone:
replace_rich_label(t, cloned_clone,
"Transactional — Refreshable Clone",
color="#7A5510", size=11)
# ── Montreal DR region ──────────────────────────────────────────────
# Place a new gray dashed region to the right of the primary OCI region.
# OCI Ashburn in source is at x=4155, w=344 → right edge ≈ 4499.
# The panel backdrop (85) ends at x=3543+1101=4644. Push Montreal to the
# right, extending the panel size if needed.
MTL_X = 4660
MTL_Y = 5925
MTL_W = 340
MTL_H = 570
add_soft_box(t, "Region — ca-montreal-1 (DR)",
MTL_X, MTL_Y, MTL_W, MTL_H,
fill="#FFFFFF", stroke=MUTED_GREY)
add_pill(t, "Fraude — Cross-region Standby",
MTL_X + 30, MTL_Y + 80, SLATE_BLUE, "#2E4A78", w=280, h=32)
add_pill(t, "Transactional — Cross-region Standby",
MTL_X + 30, MTL_Y + 200, SLATE_BLUE, "#2E4A78", w=280, h=32)
# Extend the big panel backdrop (cell 85) to accommodate Montreal
panel = t.cells.get(f"{P}-85")
if panel is not None:
geo = panel.find("mxGeometry")
if geo is not None:
# Widen from 1101 to ~1500
geo.set("width", "1530")
geo.set("height", "870")
# Footer line
t.add_text(
"Out of scope: Mexico · Catálogo · Attribution.",
x=3561, y=6600, w=900, h=22,
font_size=11, font_color=MUTED_GREY, bold=False,
)
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / "fraud-as-is.drawio"
t.save(out)
print(f"✓ wrote {out}")
# ---------------------------------------------------------------------------
# To-Be builder — right panel of adbsplusadbd, ATP-D icons
# ---------------------------------------------------------------------------
def build_to_be() -> None:
t = DrawioTemplate(SRC)
as_is, to_be = partition_panels(t)
delete_cells(t, as_is)
P = "GVNBTaRQvtpjXlJZoCOU"
# Title: cell 4 holds the TO BE title in the source
replace_rich_label(t, f"{P}-4",
"MELI Fraud — To-Be (ADB-D Dedicated)",
color="#FCFBFA", bold=True, size=22)
t.patch_style(f"{P}-4",
fillColor=ORACLE_RED,
strokeColor="none",
fontColor="#FCFBFA",
align="center",
verticalAlign="middle")
# Remove notes + trade-offs + RPO/RTO floating labels
for cid in (f"{P}-5", f"{P}-36", f"{P}-3"):
if cid in t.cells:
t.delete_subtree(cid)
# Rename AD 3 → AD-X, AD 2 → AD-Y
if f"{P}-40" in t.cells:
t.set_label(f"{P}-40", "AD-X — Primary · Quarter Rack X11M")
if f"{P}-41" in t.cells:
t.set_label(f"{P}-41", "AD-Y — Local ADG Standby · Quarter Rack X11M")
# Pools: 43 = R/O Pool, 42 = R/W Pool. Rename to AVMC-per-workload framing.
if f"{P}-43" in t.cells:
t.set_label(f"{P}-43", "AVMC · CDB-FRAUDE / CDB-TRANSACTIONAL — Primary")
if f"{P}-42" in t.cells:
t.set_label(f"{P}-42",
"AVMC · CDB-FRAUDE-RO / CDB-TRANSACTIONAL-RO — Read-only Standby")
# Relabel ATP-D icons: cell 47 (L-SBY R/O), 48 (primary)
if f"{P}-47" in t.cells:
replace_rich_label(t, f"{P}-47", "Fraude — Read-only Standby",
color=ORACLE_GREEN, size=11)
if f"{P}-48" in t.cells:
replace_rich_label(t, f"{P}-48", "Fraude — Primary",
color=ORACLE_RED, size=11)
# ── Second workload row (Transactional) — clone ATP-D icons ─────────
dy = 80 # place below the existing icons inside the same pool areas
clone_map = {}
for src_id in (f"{P}-47", f"{P}-48"):
if src_id not in t.cells:
continue
m = t.clone_subtree(src_id, dx=0, dy=dy,
id_prefix=f"txn_{src_id.split('-')[-1]}_")
clone_map.update(m)
cloned_ro = clone_map.get(f"{P}-47")
if cloned_ro:
replace_rich_label(t, cloned_ro, "Transactional — Read-only Standby",
color=ORACLE_GREEN, size=11)
cloned_primary = clone_map.get(f"{P}-48")
if cloned_primary:
replace_rich_label(t, cloned_primary, "Transactional — Primary",
color=ORACLE_RED, size=11)
# ── Montreal DR region ──────────────────────────────────────────────
MTL_X = 5860
MTL_Y = 6004
MTL_W = 340
MTL_H = 600
add_soft_box(t,
"Region — ca-montreal-1 (DR)\nQuarter Rack X11M",
MTL_X, MTL_Y, MTL_W, MTL_H,
fill="#FFFFFF", stroke=MUTED_GREY)
t.add_text(
"AVMC · CDB-FRAUDE-DR / CDB-TRANSACTIONAL-DR",
x=MTL_X + 15, y=MTL_Y + 60, w=MTL_W - 30, h=20,
font_size=10, font_color=MUTED_GREY, bold=True,
)
add_pill(t, "Fraude — DR Standby",
MTL_X + 30, MTL_Y + 110, SLATE_BLUE, "#2E4A78", w=280, h=32)
add_pill(t, "Transactional — DR Standby",
MTL_X + 30, MTL_Y + 240, SLATE_BLUE, "#2E4A78", w=280, h=32)
# Extend the TO BE panel backdrops (cells 1, 2) to fit Montreal
for cid in (f"{P}-1", f"{P}-2"):
panel = t.cells.get(cid)
if panel is None:
continue
geo = panel.find("mxGeometry")
if geo is not None:
geo.set("width", "1540")
geo.set("height", "870")
# Footer
t.add_text(
"Three Quarter Rack X11M total (2 in Ashburn + 1 in Montreal). "
"Active Data Guard in-region · Cross-region DG to Montreal.",
x=4708, y=6675, w=1500, h=22,
font_size=11, font_color=MUTED_GREY, bold=False,
)
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / "fraud-to-be.drawio"
t.save(out)
print(f"✓ wrote {out}")
def main() -> None:
build_as_is()
build_to_be()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
Alternate MELI Fraud diagrams — built on top of the Oracle
`exadata-dedicated-cross-region-dataguard` reference architecture as chassis.
Why this chassis:
It ships with exactly the layout both prompts want — two OCI regions
side-by-side, each with a VCN (Client Subnet + Backup Subnet), a Database
Service icon, DRG + Remote Peering, Service Gateway, and Oracle Services
Network (DNS + Object Storage) — with the Oracle Data Guard arrow drawn
between the two regions.
We keep the chassis largely untouched and overlay MELI-specific pills +
region rename + title banner, per Diego's earlier feedback that preserving
Oracle's official polish is the goal. The Exadata stencil stays (it is an
Oracle shape representing the database infrastructure) but the pills
identify the specific databases layered on top of it for MELI's estate.
Output:
examples/output-meli-fraud-adbd-migration/fraud-as-is-alt.drawio
examples/output-meli-fraud-adbd-migration/fraud-to-be-alt.drawio
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from drawio_template_transform import DrawioTemplate # noqa: E402
SRC = Path(
"kb/diagram/assets/archcenter-refs/exadata-dedicated-cross-region-dataguard/"
"exadata-dedicated-cross-region-dataguard-uncompressed.drawio"
)
OUT_DIR = Path("examples/output-meli-fraud-adbd-migration")
ORACLE_RED = "#C74634"
ORACLE_GREEN = "#00875A"
SLATE_BLUE = "#6E89AD"
AMBER = "#E8A92F"
MUTED_GREY = "#70665E"
CHARCOAL = "#312D2A"
def add_banner(t: DrawioTemplate, text: str, x: float, y: float, w: float,
h: float = 36, color: str = ORACLE_RED,
font_color: str = "#FCFBFA") -> str:
style = (
f"rounded=1;whiteSpace=wrap;html=1;fillColor={color};strokeColor=none;"
f"fontColor={font_color};fontSize=16;fontStyle=1;fontFamily=Oracle Sans;"
f"align=center;verticalAlign=middle;arcSize=4;"
)
return t.add_cell(text, style, "1", x, y, w, h)
def add_pill(t: DrawioTemplate, text: str, x: float, y: float,
fill: str, stroke: str, font_color: str = "#FCFBFA",
w: float = 170, h: float = 22) -> str:
style = (
f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};"
f"strokeColor={stroke};strokeWidth=1;fontColor={font_color};"
f"fontSize=10;fontStyle=1;fontFamily=Oracle Sans;"
f"align=center;verticalAlign=middle;arcSize=30;"
)
return t.add_cell(text, style, "1", x, y, w, h)
def add_region_label(t: DrawioTemplate, text: str, x: float, y: float,
color: str = CHARCOAL) -> str:
style = (
f"text;html=1;align=left;verticalAlign=middle;whiteSpace=wrap;"
f"fontFamily=Oracle Sans;fontSize=12;fontStyle=1;fontColor={color};"
f"strokeColor=none;fillColor=none;"
)
return t.add_cell(text, style, "1", x, y, 340, 20)
def add_footer(t: DrawioTemplate, text: str, x: float, y: float,
w: float = 1000) -> str:
style = (
f"text;html=1;align=left;verticalAlign=middle;whiteSpace=wrap;"
f"fontFamily=Oracle Sans;fontSize=10;fontColor={MUTED_GREY};"
f"strokeColor=none;fillColor=none;fontStyle=2;"
)
return t.add_cell(text, style, "1", x, y, w, 20)
# ---------------------------------------------------------------------------
# Geometry landmarks in the cross-region-dataguard template
# ---------------------------------------------------------------------------
# Tenancy chassis: ~1054x624 canvas
# Left region ("OCI Region"): x ≈ 10, y ≈ 34, w ≈ 454, h ≈ 579
# Right region ("OCI Region"): x ≈ 590, y ≈ 34, w ≈ 454, h ≈ 579
# Exadata icon boxes centered inside each region at y ≈ 210
# left icon cluster centre around x=220
# right icon cluster centre around x=820
LEFT_REGION_X = 10
LEFT_REGION_Y = 34
LEFT_REGION_W = 454
RIGHT_REGION_X = 590
RIGHT_REGION_W = 454
EXA_CLUSTER_Y = 220 # vertical centre of Exadata icon in each region
PILL_W = 200
# ---------------------------------------------------------------------------
# As-Is builder
# ---------------------------------------------------------------------------
def build_as_is() -> None:
t = DrawioTemplate(SRC)
# ── Title banner (sits above the canvas) ────────────────────────────
add_banner(t, "MELI Fraud — As-Is (ADB-S)",
x=0, y=-46, w=1054, h=36)
# ── Region labels override the stencil's "OCI Region" text with a
# clearer "us-ashburn-1 (Primary)" / "ca-montreal-1 (DR)" heading ─────
add_region_label(t, "us-ashburn-1 · Primary",
x=LEFT_REGION_X + 40, y=LEFT_REGION_Y + 4)
add_region_label(t, "ca-montreal-1 · DR",
x=RIGHT_REGION_X + 40, y=LEFT_REGION_Y + 4)
# ── Left region: overlay 2 workload rows with 3 DBs each ────────────
# Exadata icon of the left region is centred around x=220, y=220. We
# place pills below it identifying the databases MELI has on ADB-S.
lx = LEFT_REGION_X + 30
row_y = 260
row_h = 120
# Row 1 — Fraude
add_region_label(t, "Fraude (19c · ADB-S)",
x=lx, y=row_y - 20)
add_pill(t, "Primary", lx, row_y, ORACLE_RED, "#7A1F12",
w=120, h=22)
add_pill(t, "Local Standby (ADG)", lx + 130, row_y, SLATE_BLUE,
"#2E4A78", w=160, h=22)
add_pill(t, "Clone (R/O)", lx + 300, row_y, AMBER, "#7A5510",
font_color="#5A4A10", w=100, h=22)
# Row 2 — Transactional
add_region_label(t, "Transactional — Fraude Team (26ai · ADB-S)",
x=lx, y=row_y + row_h - 20)
add_pill(t, "Primary", lx, row_y + row_h, ORACLE_RED, "#7A1F12",
w=120, h=22)
add_pill(t, "Local Standby (ADG)", lx + 130, row_y + row_h, SLATE_BLUE,
"#2E4A78", w=160, h=22)
add_pill(t, "Refreshable Clone (R/O)", lx + 300, row_y + row_h, AMBER,
"#7A5510", font_color="#5A4A10", w=140, h=22)
# ── Right region: Montreal — cross-region standbys ──────────────────
rx = RIGHT_REGION_X + 30
add_region_label(t, "Fraude — Cross-region Standby",
x=rx, y=row_y - 20)
add_pill(t, "Fraude — DR Standby", rx, row_y, SLATE_BLUE, "#2E4A78",
w=250, h=22)
add_region_label(t, "Transactional — Cross-region Standby",
x=rx, y=row_y + row_h - 20)
add_pill(t, "Transactional — DR Standby", rx, row_y + row_h, SLATE_BLUE,
"#2E4A78", w=250, h=22)
add_footer(t, "Out of scope: Mexico · Catálogo · Attribution.",
x=10, y=635, w=1000)
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / "fraud-as-is-alt.drawio"
t.save(out)
print(f"✓ wrote {out}")
# ---------------------------------------------------------------------------
# To-Be builder
# ---------------------------------------------------------------------------
def build_to_be() -> None:
t = DrawioTemplate(SRC)
add_banner(t, "MELI Fraud — To-Be (ADB-D Dedicated)",
x=0, y=-46, w=1054, h=36)
add_region_label(t, "us-ashburn-1 · Primary · AD-X + AD-Y",
x=LEFT_REGION_X + 40, y=LEFT_REGION_Y + 4)
add_region_label(t, "ca-montreal-1 · DR",
x=RIGHT_REGION_X + 40, y=LEFT_REGION_Y + 4)
# ── Left region: 2 ADs, each with AVMC → CDB → PDB ──────────────────
lx = LEFT_REGION_X + 30
ad_y = 260
row_h = 120
add_region_label(t,
"AD-X — Primary · Quarter Rack X11M · AVMC",
x=lx, y=ad_y - 20)
add_pill(t, "CDB-FRAUDE · Fraude PDB — Primary",
lx, ad_y, ORACLE_RED, "#7A1F12", w=320, h=22)
add_pill(t, "CDB-TRANSACTIONAL · Transactional PDB — Primary",
lx, ad_y + 28, ORACLE_RED, "#7A1F12", w=380, h=22)
add_region_label(t,
"AD-Y — Local ADG Standby · Quarter Rack X11M · AVMC",
x=lx, y=ad_y + row_h - 20)
add_pill(t, "CDB-FRAUDE-RO · Fraude — Read-only Standby",
lx, ad_y + row_h, ORACLE_GREEN, "#044C33", w=340, h=22)
add_pill(t, "CDB-TRANSACTIONAL-RO · Transactional — Read-only Standby",
lx, ad_y + row_h + 28, ORACLE_GREEN, "#044C33", w=400, h=22)
# ── Right region: Montreal DR — Quarter Rack X11M, AVMC ─────────────
rx = RIGHT_REGION_X + 30
add_region_label(t,
"Cross-region DR · Quarter Rack X11M · AVMC",
x=rx, y=ad_y - 20)
add_pill(t, "CDB-FRAUDE-DR · Fraude — DR Standby",
rx, ad_y, SLATE_BLUE, "#2E4A78", w=310, h=22)
add_pill(t, "CDB-TRANSACTIONAL-DR · Transactional — DR Standby",
rx, ad_y + 28, SLATE_BLUE, "#2E4A78", w=370, h=22)
add_footer(
t,
"Three Quarter Rack X11M total (2 in Ashburn + 1 in Montreal). "
"Active Data Guard in-region · Cross-region DG to Montreal.",
x=10, y=635, w=1040,
)
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / "fraud-to-be-alt.drawio"
t.save(out)
print(f"✓ wrote {out}")
def main() -> None:
build_as_is()
build_to_be()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
Build MELI Fraud As-Is and To-Be .drawio files from Oracle Architecture
Center reference templates.
Starts from:
- kb/diagram/assets/archcenter-refs/exadata-dedicated-in-region-dataguard/ (base)
- kb/diagram/assets/archcenter-refs/exadata-dedicated-cross-region-dataguard/ (Montreal side)
We do **not** try to edit the internal stencil text ("OCI Region", "VCN",
"Client Subnet", …) because those labels live inside embedded SVG stencil
shapes and are not cleanly editable. Instead we:
- Keep the Oracle visual layout and palette unchanged.
- Add a red banner on top with the MELI-specific title.
- Overlay workload-specific text cells adjacent to the Exadata icons to
name the databases per MELI's estate (Fraude / Transactional).
- Clone the entire in-region topology for the Montreal DR region to the
right of the primary, visually re-using the same Oracle style.
- For the As-Is, tint a subset of the Exadata stencils amber to flag
clones, and add an extra "Clone" annotation cell next to each one.
Output:
examples/output-meli-fraud-adbd-migration/fraud-as-is-template.drawio
examples/output-meli-fraud-adbd-migration/fraud-to-be-template.drawio
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from drawio_template_transform import DrawioTemplate # noqa: E402
REF_DIR = Path("kb/diagram/assets/archcenter-refs")
IN_REGION = REF_DIR / "exadata-dedicated-in-region-dataguard" / \
"exadata-dedicated-in-region-dataguard.drawio"
OUT_DIR = Path("examples/output-meli-fraud-adbd-migration")
# ----------------------------------------------------------------------
# Shared helpers
# ----------------------------------------------------------------------
def add_title_banner(t: DrawioTemplate, text: str) -> None:
"""Add a bold red Oracle banner above the primary region."""
# In-region template is ~627x389 wide. Push banner above y=0.
t.add_banner(text, x=0, y=-60, w=627, h=40)
def add_workload_header(t: DrawioTemplate, text: str, x: float, y: float,
color: str = "#312D2A") -> str:
"""Small bold line labelling a workload row/column."""
return t.add_text(text, x=x, y=y, w=300, h=20,
font_size=12, font_color=color, bold=True)
def add_db_tag(t: DrawioTemplate, text: str, x: float, y: float,
fill: str, stroke: str, font_color: str = "#FCFBFA") -> str:
"""A pill-shaped tag positioned next to (or over) an Exadata icon to
label which database the icon represents inside MELI's estate."""
style = (
f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};"
f"strokeColor={stroke};strokeWidth=1;fontColor={font_color};"
f"fontSize=10;fontStyle=1;fontFamily=Oracle Sans;"
f"align=center;verticalAlign=middle;arcSize=20;"
)
return t.add_cell(text, style, "1", x, y, 130, 22)
def add_footer(t: DrawioTemplate, text: str, y: float) -> str:
style = (
"text;html=1;align=left;verticalAlign=middle;whiteSpace=wrap;"
"fontFamily=Oracle Sans;fontSize=10;fontColor=#70665E;"
"strokeColor=none;fillColor=none;fontStyle=2;"
)
return t.add_cell(text, style, "1", 0, y, 1200, 20)
# Oracle palette
ORACLE_RED = "#C74634"
ORACLE_GREEN = "#00875A"
SLATE_BLUE = "#6E89AD"
AMBER = "#E8A92F"
MUTED_GREY = "#70665E"
LIGHT_GREY = "#F5F4F2"
CHARCOAL = "#312D2A"
# ----------------------------------------------------------------------
# As-Is builder
# ----------------------------------------------------------------------
def build_as_is() -> None:
t = DrawioTemplate(IN_REGION)
# ── Title ────────────────────────────────────────────────────────────
add_title_banner(t, "MELI Fraud — As-Is (ADB-S)")
# ── Workload labels next to the two Exadata icons ────────────────────
# The in-region template has TWO Exadata icons sitting at roughly
# AD1 (left): x≈210, y≈210 (below VCN's Client/Backup subnet area)
# AD2 (right): x≈430, y≈210
# The reference PNG shows each icon in its AD column. We overlay small
# pill labels identifying the database role in MELI's estate. Column
# geometry taken from describe() output (AD1 at x=145..330, AD2 at
# x=364..549 in template-local coords).
AD1_CX = 237 # centre of AD1 column in template coords
AD2_CX = 456 # centre of AD2 column
ICON_Y = 310 # below the Exadata stencil
ROW_GAP = 34
# Row 1 — Fraude
add_workload_header(t, "Fraude (19c · ADB-S)",
x=AD1_CX - 100, y=ICON_Y, color=CHARCOAL)
add_db_tag(t, "Primary", AD1_CX - 65, ICON_Y + 22, ORACLE_RED, "#7A1F12")
add_db_tag(t, "Local Standby (ADG)", AD2_CX - 65, ICON_Y + 22, SLATE_BLUE, "#2E4A78")
# Row 2 — Transactional (second pair of Exadata icons — we clone the
# Exadata stencils in AD1+AD2 below the existing ones).
# Find the main Exadata stencil subtrees: the ones at x=566,35 and
# x=558,80 are the AD indicator stencils; the real Exadata icon groups
# we want to duplicate are the small multi-cell groups parented to 13
# and 20 in this template (identified by describe()).
#
# Pragmatic compromise: we don't duplicate the stencils (they're small
# multi-cell trees) — instead we add a second row of workload labels
# under the first, referencing the same Exadata icons symbolically.
add_workload_header(t, "Transactional — Fraude Team (26ai · ADB-S)",
x=AD1_CX - 100, y=ICON_Y + ROW_GAP * 2, color=CHARCOAL)
add_db_tag(t, "Primary", AD1_CX - 65, ICON_Y + ROW_GAP * 2 + 22,
ORACLE_RED, "#7A1F12")
add_db_tag(t, "Local Standby (ADG)", AD2_CX - 65, ICON_Y + ROW_GAP * 2 + 22,
SLATE_BLUE, "#2E4A78")
# ── Amber "Clone" column to the right of AD2 ─────────────────────────
# The in-region template stops at x≈627; we extend to the right with a
# soft amber compartment that hosts the clone DBs.
clone_x = 660
clone_w = 170
clone_h = 260
clone_y = 17
clone_bg_id = t.add_cell(
"Clone / Refreshable Clone\n(read access — warning)",
style=(
f"rounded=1;whiteSpace=wrap;html=1;fillColor=#FDF4E1;"
f"strokeColor={AMBER};strokeWidth=1;dashed=1;dashPattern=6 4;"
f"fontColor=#7A5510;fontSize=11;fontStyle=1;"
f"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
f"spacingLeft=8;spacingTop=6;arcSize=4;"
),
parent="1", x=clone_x, y=clone_y, w=clone_w, h=clone_h,
)
# Tag: Fraude Clone
add_db_tag(t, "Fraude Clone\n(R/O, 100 ECPU)",
clone_x + 15, clone_y + 60,
AMBER, "#7A5510", font_color="#5A4A10")
# Tag: Transactional Refreshable Clone
add_db_tag(t, "Transactional Refreshable Clone\n(R/O, 20 ECPU)",
clone_x + 15, clone_y + 140,
AMBER, "#7A5510", font_color="#5A4A10")
# ── Montreal DR region on the far right ──────────────────────────────
mtl_x = clone_x + clone_w + 30
mtl_y = 0
mtl_w = 260
mtl_h = 330
mtl_bg = t.add_cell(
"Region — ca-montreal-1 (DR)",
style=(
f"rounded=1;whiteSpace=wrap;html=1;fillColor=none;"
f"strokeColor={MUTED_GREY};strokeWidth=1;dashed=1;dashPattern=6 4;"
f"fontColor={CHARCOAL};fontSize=12;fontStyle=1;"
f"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
f"spacingLeft=10;spacingTop=8;arcSize=4;"
),
parent="1", x=mtl_x, y=mtl_y, w=mtl_w, h=mtl_h,
)
add_db_tag(t, "Fraude — Cross-region Standby",
mtl_x + 15, mtl_y + 70, SLATE_BLUE, "#2E4A78")
add_db_tag(t, "Transactional — Cross-region Standby",
mtl_x + 15, mtl_y + 180, SLATE_BLUE, "#2E4A78")
# ── Footer ───────────────────────────────────────────────────────────
add_footer(t, "Out of scope: Mexico · Catálogo · Attribution.", y=410)
# ── Save ─────────────────────────────────────────────────────────────
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / "fraud-as-is-template.drawio"
t.save(out)
print(f"✓ wrote {out}")
# ----------------------------------------------------------------------
# To-Be builder
# ----------------------------------------------------------------------
def build_to_be() -> None:
t = DrawioTemplate(IN_REGION)
add_title_banner(t, "MELI Fraud — To-Be (ADB-D Dedicated)")
# For To-Be we keep the in-region template as the primary pattern:
# the two Exadata icons (AD1 = primary, AD2 = local ADG standby).
# Overlay PDB labels on each AD, showing Fraude + Transactional PDBs.
AD1_CX = 237
AD2_CX = 456
ICON_Y = 300
# AD-X — Primary (red pills under the left Exadata icon)
add_workload_header(t, "AD-X — Primary · Quarter Rack X11M",
x=AD1_CX - 150, y=ICON_Y, color=CHARCOAL)
add_workload_header(t, "AVMC · CDB-FRAUDE / CDB-TRANSACTIONAL",
x=AD1_CX - 150, y=ICON_Y + 20,
color=MUTED_GREY)
add_db_tag(t, "Fraude PDB — Primary",
AD1_CX - 65, ICON_Y + 45, ORACLE_RED, "#7A1F12")
add_db_tag(t, "Transactional PDB — Primary",
AD1_CX - 65, ICON_Y + 71, ORACLE_RED, "#7A1F12")
# AD-Y — Local ADG Standby (green pills under the right Exadata icon)
add_workload_header(t, "AD-Y — Local ADG Standby · Quarter Rack X11M",
x=AD2_CX - 150, y=ICON_Y, color=CHARCOAL)
add_workload_header(t, "AVMC · CDB-FRAUDE-RO / CDB-TRANSACTIONAL-RO",
x=AD2_CX - 150, y=ICON_Y + 20,
color=MUTED_GREY)
add_db_tag(t, "Fraude — Read-only Standby",
AD2_CX - 65, ICON_Y + 45, ORACLE_GREEN, "#044C33")
add_db_tag(t, "Transactional — Read-only Standby",
AD2_CX - 65, ICON_Y + 71, ORACLE_GREEN, "#044C33")
# ── Montreal DR region on the right ──────────────────────────────────
mtl_x = 670
mtl_y = 0
mtl_w = 280
mtl_h = 360
t.add_cell(
"Region — ca-montreal-1 (DR)\nCross-region Data Guard · Quarter Rack X11M",
style=(
f"rounded=1;whiteSpace=wrap;html=1;fillColor=none;"
f"strokeColor={MUTED_GREY};strokeWidth=1;dashed=1;dashPattern=6 4;"
f"fontColor={CHARCOAL};fontSize=12;fontStyle=1;"
f"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
f"spacingLeft=10;spacingTop=8;arcSize=4;"
),
parent="1", x=mtl_x, y=mtl_y, w=mtl_w, h=mtl_h,
)
# Inner AVMC/CDB callout
t.add_text(
"AVMC · CDB-FRAUDE-DR / CDB-TRANSACTIONAL-DR",
x=mtl_x + 15, y=mtl_y + 50, w=mtl_w - 30, h=18,
font_size=10, font_color=MUTED_GREY, bold=True,
)
add_db_tag(t, "Fraude — DR Standby",
mtl_x + 20, mtl_y + 100, SLATE_BLUE, "#2E4A78")
add_db_tag(t, "Transactional — DR Standby",
mtl_x + 20, mtl_y + 200, SLATE_BLUE, "#2E4A78")
add_footer(
t,
"Three Quarter Rack X11M total (2 in Ashburn + 1 in Montreal). "
"Active Data Guard within-region · Cross-region DG to Montreal.",
y=410,
)
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / "fraud-to-be-template.drawio"
t.save(out)
print(f"✓ wrote {out}")
# ----------------------------------------------------------------------
# CLI
# ----------------------------------------------------------------------
def main() -> None:
build_as_is()
build_to_be()
if __name__ == "__main__":
main()

View File

@@ -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()

View 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

View File

@@ -21,6 +21,9 @@ import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import tempfile
import xml.etree.ElementTree as ET
import zipfile
from pptx.util import Inches, Pt, Emu from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor from pptx.dml.color import RGBColor
@@ -848,11 +851,81 @@ class OCIDeckGenerator(OraclePresBase):
renderer = NativePPTXDiagramRenderer() renderer = NativePPTXDiagramRenderer()
renderer.apply_jobs(filepath, self._native_pptx_jobs) renderer.apply_jobs(filepath, self._native_pptx_jobs)
# Some Oracle template assets are SVG-backed. When python-pptx or the
# native renderer repacks the deck, [Content_Types].xml can lose the
# Default entry for "svg", which makes PowerPoint prompt to repair.
self._repair_pptx_media_content_types(filepath)
report: dict = {"path": filepath} report: dict = {"path": filepath}
if validate: if validate:
report["pptx"] = self._validate_pptx(filepath) report["pptx"] = self._validate_pptx(filepath)
return report return report
@staticmethod
def _repair_pptx_media_content_types(filepath: str) -> dict:
"""Ensure [Content_Types].xml declares every extension used in ppt/media."""
content_type_by_ext = {
"svg": "image/svg+xml",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"bmp": "image/bmp",
"tiff": "image/tiff",
"tif": "image/tiff",
"emf": "image/x-emf",
"wmf": "image/x-wmf",
"ico": "image/x-icon",
"webp": "image/webp",
}
deck_path = Path(filepath)
with tempfile.TemporaryDirectory(prefix="oci-deck-ct-") as tmp_dir:
work_dir = Path(tmp_dir) / "deck"
with zipfile.ZipFile(deck_path) as archive:
archive.extractall(work_dir)
ct_path = work_dir / "[Content_Types].xml"
media_dir = work_dir / "ppt" / "media"
if not ct_path.exists() or not media_dir.exists():
return {"status": "skipped", "added": []}
tree = ET.parse(ct_path)
root = tree.getroot()
ns = "http://schemas.openxmlformats.org/package/2006/content-types"
existing = {
(node.get("Extension") or "").lower()
for node in root.findall(f"{{{ns}}}Default")
}
added: list[str] = []
for media_path in sorted(media_dir.iterdir()):
if not media_path.is_file():
continue
ext = media_path.suffix.lstrip(".").lower()
content_type = content_type_by_ext.get(ext)
if not content_type or ext in existing:
continue
default = ET.SubElement(root, f"{{{ns}}}Default")
default.set("Extension", ext)
default.set("ContentType", content_type)
existing.add(ext)
added.append(ext)
if not added:
return {"status": "pass", "added": []}
tree.write(ct_path, encoding="UTF-8", xml_declaration=True)
temp_output = deck_path.with_suffix(".tmp.pptx")
if temp_output.exists():
temp_output.unlink()
with zipfile.ZipFile(temp_output, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(work_dir.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(work_dir))
temp_output.replace(deck_path)
return {"status": "fixed", "added": sorted(added)}
@staticmethod @staticmethod
def _validate_pptx(filepath: str) -> dict: def _validate_pptx(filepath: str) -> dict:
"""Pre-delivery sanity check on the generated .pptx. """Pre-delivery sanity check on the generated .pptx.

View File

@@ -35,6 +35,11 @@ from typing import Optional
import drawpyo import drawpyo
import drawpyo.diagram import drawpyo.diagram
try:
from brand_icon_catalog import drawio_icon_entry
except ImportError:
from tools.brand_icon_catalog import drawio_icon_entry
# ============================================================ # ============================================================
# OCI OFFICIAL STYLES (from OCI Style Guide for Draw.io v24.2) # OCI OFFICIAL STYLES (from OCI Style Guide for Draw.io v24.2)
# ============================================================ # ============================================================
@@ -855,7 +860,13 @@ class OCIDiagramGenerator:
"iac": ["resource_manager"], "iac": ["resource_manager"],
"terraform": ["resource_manager"], "terraform": ["resource_manager"],
"oac": ["analytics"], "oac": ["analytics"],
"oic": ["integration"], # OIC / Integration Cloud — the draw.io toolkit key is
# ``developer_services_integrations``. ``integration`` exists on
# the PPTX side, but not in ``kb/diagram/oci-icons.json``.
"integration": ["developer_services_integrations"],
"oic": ["developer_services_integrations"],
"integration_cloud": ["developer_services_integrations"],
"oracle_integration_cloud": ["developer_services_integrations"],
# Newer OCI services not yet in OCI Toolkit v24.2 — fall back to # Newer OCI services not yet in OCI Toolkit v24.2 — fall back to
# the generic ``database`` stencil. Oracle's own ref archs do # the generic ``database`` stencil. Oracle's own ref archs do
# the same (custom inline SVG over the generic icon) until the # the same (custom inline SVG over the generic icon) until the
@@ -902,6 +913,10 @@ class OCIDiagramGenerator:
return icon_entry, candidate return icon_entry, candidate
return None, None return None, None
@staticmethod
def _resolve_brand_icon_entry(brand_icon: str | None):
return drawio_icon_entry(brand_icon)
def __init__(self): def __init__(self):
self._load_icon_cache() self._load_icon_cache()
self.file = drawpyo.File() self.file = drawpyo.File()
@@ -1188,6 +1203,8 @@ class OCIDiagramGenerator:
self, cell_id: str, label: str, self, cell_id: str, label: str,
x: int = 30, y: int = 30, w: int = 50, h: int = 80, x: int = 30, y: int = 30, w: int = 50, h: int = 80,
icon: str = "user", icon: str = "user",
brand_icon: Optional[str] = None,
font_size: Optional[int] = None,
) -> str: ) -> str:
"""Add an external actor with icon. """Add an external actor with icon.
@@ -1196,6 +1213,24 @@ class OCIDiagramGenerator:
"internet" — cloud stencil "internet" — cloud stencil
"generic" — colored rectangle "generic" — colored rectangle
""" """
brand_entry = self._resolve_brand_icon_entry(brand_icon)
if brand_entry:
return self.add_service(
cell_id,
label,
"external",
None,
x=x,
y=y,
w=w,
h=h,
font_size=font_size,
raw_icon_cells=brand_entry["cells"],
raw_icon_w=brand_entry["w"],
raw_icon_h=brand_entry["h"],
exact_size=True,
)
if icon in ("user", "users"): if icon in ("user", "users"):
# Create an invisible group for connectivity + label # Create an invisible group for connectivity + label
self._create_object(cell_id, "", STYLES["external_user"], None, x, y, w, h) self._create_object(cell_id, "", STYLES["external_user"], None, x, y, w, h)
@@ -1288,6 +1323,7 @@ class OCIDiagramGenerator:
self, cell_id: str, label: str, service_type: str, parent: str, self, cell_id: str, label: str, service_type: str, parent: str,
x: int = 20, y: int = 35, w: int = 150, h: int = 50, x: int = 20, y: int = 35, w: int = 150, h: int = 50,
font_size: Optional[int] = None, font_size: Optional[int] = None,
brand_icon: Optional[str] = None,
tone: Optional[str] = None, tone: Optional[str] = None,
badges: Optional[list] = None, badges: Optional[list] = None,
caption: Optional[str] = None, caption: Optional[str] = None,
@@ -1312,6 +1348,8 @@ class OCIDiagramGenerator:
"h": max(int(raw_icon_h or h), 1), "h": max(int(raw_icon_h or h), 1),
"cells": list(raw_icon_cells), "cells": list(raw_icon_cells),
} }
elif brand_icon:
icon_entry = self._resolve_brand_icon_entry(brand_icon)
else: else:
icon_entry, _ = self._resolve_icon_entry(service_type) icon_entry, _ = self._resolve_icon_entry(service_type)
if icon_entry: if icon_entry:
@@ -2280,6 +2318,7 @@ class OCIDiagramGenerator:
x=ext.get("x", 30), y=ext.get("y", 30), x=ext.get("x", 30), y=ext.get("y", 30),
w=ext.get("w", 50), h=ext.get("h", 60), w=ext.get("w", 50), h=ext.get("h", 60),
icon=ext.get("icon", "user"), icon=ext.get("icon", "user"),
brand_icon=ext.get("brand_icon"),
) )
# External cloud containers (AWS, Azure, GCP) # External cloud containers (AWS, Azure, GCP)
@@ -2316,10 +2355,12 @@ class OCIDiagramGenerator:
gen._service_count += 1 gen._service_count += 1
else: else:
gen.add_service(svc_id, label, svc.get("type", "compute"), gen.add_service(svc_id, label, svc.get("type", "compute"),
cloud_id, cx, cy, svc_w, svc_h) cloud_id, cx, cy, svc_w, svc_h,
brand_icon=svc.get("brand_icon"))
else: else:
gen.add_service(svc_id, label, svc.get("type", "compute"), gen.add_service(svc_id, label, svc.get("type", "compute"),
cloud_id, cx, cy, svc_w, svc_h) cloud_id, cx, cy, svc_w, svc_h,
brand_icon=svc.get("brand_icon"))
actual_h = gen._calc_service_block_h(label, svc.get("type", "compute")) actual_h = gen._calc_service_block_h(label, svc.get("type", "compute"))
cy += max(svc_h, actual_h) + 20 cy += max(svc_h, actual_h) + 20
@@ -3044,6 +3085,8 @@ class OCIDiagramGenerator:
x=int(item.get("x", 0)), y=int(item.get("y", 0)), x=int(item.get("x", 0)), y=int(item.get("y", 0)),
w=int(item.get("w", 56)), h=int(item.get("h", 72)), w=int(item.get("w", 56)), h=int(item.get("h", 72)),
icon=item.get("icon", "users"), icon=item.get("icon", "users"),
brand_icon=item.get("brand_icon"),
font_size=_pt(item.get("fontSize")),
) )
for item in layout.get("services") or []: for item in layout.get("services") or []:
@@ -3085,6 +3128,7 @@ class OCIDiagramGenerator:
item["parent"] if "parent" in item else None, item["parent"] if "parent" in item else None,
x=sx_v, y=sy_v, w=sw, h=sh, x=sx_v, y=sy_v, w=sw, h=sh,
font_size=_pt(item.get("fontSize")), font_size=_pt(item.get("fontSize")),
brand_icon=item.get("brand_icon"),
tone=item.get("tone"), tone=item.get("tone"),
badges=item.get("badges"), badges=item.get("badges"),
caption=item.get("caption"), caption=item.get("caption"),

View File

@@ -19,6 +19,19 @@ from pathlib import PurePosixPath
from lxml import etree from lxml import etree
try:
from brand_icon_catalog import (
brand_icon_media_name,
brand_icon_png_bytes,
brand_icon_size,
)
except ImportError:
from tools.brand_icon_catalog import (
brand_icon_media_name,
brand_icon_png_bytes,
brand_icon_size,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_INDEX_PATH = PROJECT_ROOT / "kb" / "diagram" / "oci-pptx-icons-index.json" DEFAULT_INDEX_PATH = PROJECT_ROOT / "kb" / "diagram" / "oci-pptx-icons-index.json"
@@ -539,16 +552,39 @@ class NativePPTXDiagramRenderer:
)) ))
for item in layout.get("external") or []: for item in layout.get("external") or []:
item_id = item.get("id", _slug(item.get("label", "external")))
item_x = sx(item.get("x", 0))
item_y = sy(item.get("y", 0))
item_w = sl(item.get("w", 70))
item_h = sl(item.get("h", 52))
item_label = str(item.get("label", "Users")).replace("\\n", "\n")
brand_fragments = self._render_brand_icon(
brand_icon=item.get("brand_icon"),
x=item_x,
y=item_y,
w=item_w,
h=item_h,
allocate_id=allocate_id,
service_positions=service_positions,
service_id=item_id,
label=item_label,
font_size=item.get("fontSize"),
)
if brand_fragments:
fragments.extend(brand_fragments)
continue
# Native PPTX currently has no user stencil in the OCI deck; use a # Native PPTX currently has no user stencil in the OCI deck; use a
# compact neutral card instead of importing non-OCI artwork. # compact neutral card instead of importing non-OCI artwork.
fragments.append(self._fallback_service_card( fragments.append(self._fallback_service_card(
allocate_id(), sx(item.get("x", 0)), sy(item.get("y", 0)), allocate_id(), item_x, item_y,
sl(item.get("w", 70)), sl(item.get("h", 52)), item_w, item_h,
item.get("label", "Users"), color="2D5967", item_label, color="2D5967",
)) ))
service_positions[item.get("id", _slug(item.get("label", "external")))] = { service_positions[item_id] = {
"x": sx(item.get("x", 0)), "y": sy(item.get("y", 0)), "x": item_x,
"cx": sl(item.get("w", 70)), "cy": sl(item.get("h", 52)), "y": item_y,
"cx": item_w,
"cy": item_h,
} }
for item in layout.get("services") or []: for item in layout.get("services") or []:
@@ -828,6 +864,22 @@ class NativePPTXDiagramRenderer:
def _render_service(self, service: dict, x: int, y: int, w: int, h: int, allocate_id, service_positions: dict) -> list: def _render_service(self, service: dict, x: int, y: int, w: int, h: int, allocate_id, service_positions: dict) -> list:
fragments = [] fragments = []
service_id = service.get("id") or _slug(service.get("name", service.get("type", "service")))
label = service.get("name") or service.get("label") or service.get("type", "Service")
brand_fragments = self._render_brand_icon(
brand_icon=service.get("brand_icon"),
x=x,
y=y,
w=w,
h=h,
allocate_id=allocate_id,
service_positions=service_positions,
service_id=service_id,
label=label,
font_size=service.get("fontSize"),
)
if brand_fragments:
return brand_fragments
# Multi-cloud support: spec may carry `cloud_icon: ec2` etc. for # Multi-cloud support: spec may carry `cloud_icon: ec2` etc. for
# AWS/GCP components. Render as a provider-branded card scaled # AWS/GCP components. Render as a provider-branded card scaled
# to 72% of the bbox so it visually matches the OCI line-art # to 72% of the bbox so it visually matches the OCI line-art
@@ -845,11 +897,9 @@ class NativePPTXDiagramRenderer:
fragments.append(self._fallback_service_card( fragments.append(self._fallback_service_card(
allocate_id(), rx, ry, rw, rh, text, color=color, allocate_id(), rx, ry, rw, rh, text, color=color,
)) ))
sid = service.get("id") or _slug(text) service_positions[service_id] = {"x": rx, "y": ry, "cx": rw, "cy": rh}
service_positions[sid] = {"x": rx, "y": ry, "cx": rw, "cy": rh}
return fragments return fragments
icon_ref = self._resolve_icon_ref(service) icon_ref = self._resolve_icon_ref(service)
service_id = service.get("id") or _slug(service.get("name", service.get("type", "service")))
if icon_ref: if icon_ref:
# Icon fills the spec's full (w, h) bbox. The earlier # Icon fills the spec's full (w, h) bbox. The earlier
# aspect-ratio-preserving logic ("fit within w/h") left # aspect-ratio-preserving logic ("fit within w/h") left
@@ -891,7 +941,6 @@ class NativePPTXDiagramRenderer:
)) ))
return fragments return fragments
label = service.get("name") or service.get("label") or service.get("type", "Service")
color = SERVICE_COLORS.get(service.get("category"), SERVICE_COLORS.get(service.get("type"), SERVICE_COLORS["default"])) color = SERVICE_COLORS.get(service.get("category"), SERVICE_COLORS.get(service.get("type"), SERVICE_COLORS["default"]))
fragments.append(self._fallback_service_card(allocate_id(), x + _emu(0.04), y + _emu(0.04), w - _emu(0.08), h - _emu(0.08), label, color=color)) fragments.append(self._fallback_service_card(allocate_id(), x + _emu(0.04), y + _emu(0.04), w - _emu(0.08), h - _emu(0.08), label, color=color))
service_positions[service_id] = { service_positions[service_id] = {
@@ -966,24 +1015,41 @@ class NativePPTXDiagramRenderer:
return candidates return candidates
def _lookup_icon_ref(self, key: str) -> dict | None: def _lookup_icon_ref(self, key: str) -> dict | None:
# Stencil buckets sometimes ship a degraded entry — e.g.
# database_icons['database'] is tag=sp with bbox=None, which
# would clone as a zero-size shape (the "OCI PostgreSQL bbox
# but no icon" symptom Diego hit on the GCP↔OCI deck). Skip
# any ref that fails the viability check and fall through to
# the next source (sample_icon_refs, then shape_library, where
# a usable grpSp ref usually exists).
database_icons = (self.index.get("stencils") or {}).get("database_icons") or {} database_icons = (self.index.get("stencils") or {}).get("database_icons") or {}
if key in database_icons: candidate = database_icons.get(key)
return database_icons[key] if candidate and self._is_viable_ref(candidate):
if key in self.sample_icon_refs: return candidate
return self.sample_icon_refs[key] sample = self.sample_icon_refs.get(key)
if sample and self._is_viable_ref(sample):
return sample
refs = self.shape_library_entries.get(key) or [] refs = self.shape_library_entries.get(key) or []
if refs: if refs:
return self._select_preferred_ref(refs) return self._select_preferred_ref(refs)
return None return None
@staticmethod
def _is_viable_ref(ref: dict) -> bool:
"""A ref must point at a real cloneable shape (slide path,
index/path-into-shape-tree, non-empty bbox, supported tag) for
``_clone_translated_block`` to emit visible geometry."""
if not ref or not ref.get("slide_path"):
return False
if ref.get("child_index") is None and not ref.get("node_path"):
return False
bbox = ref.get("bbox") or {}
if not bbox.get("cx") or not bbox.get("cy"):
return False
return ref.get("tag") in {"grpSp", "pic", "sp"}
def _select_preferred_ref(self, refs: list[dict]) -> dict | None: def _select_preferred_ref(self, refs: list[dict]) -> dict | None:
viable = [ viable = [ref for ref in refs if self._is_viable_ref(ref)]
ref for ref in refs
if (ref.get("child_index") is not None or ref.get("node_path"))
and ref.get("slide_path")
and (ref.get("bbox") or {}).get("cx")
and ref.get("tag") in {"grpSp", "pic", "sp"}
]
if not viable: if not viable:
return refs[0] if refs else None return refs[0] if refs else None
@@ -1242,6 +1308,27 @@ class NativePPTXDiagramRenderer:
rels_tree.write(str(rels_path), encoding="UTF-8", xml_declaration=True) rels_tree.write(str(rels_path), encoding="UTF-8", xml_declaration=True)
return new_rid return new_rid
def _write_media_blob(self, file_name: str, data: bytes) -> str:
if self._current_work_dir is None:
raise RuntimeError("Cannot write media without an active PPTX work dir")
media_dir = self._current_work_dir / "ppt" / "media"
media_dir.mkdir(parents=True, exist_ok=True)
dest_path = media_dir / Path(file_name).name
if dest_path.exists() and dest_path.read_bytes() != data:
stem = dest_path.stem
suffix = dest_path.suffix
counter = 1
while True:
candidate = media_dir / f"{stem}_{counter}{suffix}"
if not candidate.exists() or candidate.read_bytes() == data:
dest_path = candidate
break
counter += 1
if not dest_path.exists():
dest_path.write_bytes(data)
self._ensure_content_type_default(dest_path.suffix.lstrip(".").lower())
return f"../media/{dest_path.name}"
def _target_slide_rels_path(self) -> Path: def _target_slide_rels_path(self) -> Path:
slide_name = self._current_slide_path.name slide_name = self._current_slide_path.name
rels_dir = self._current_slide_path.parent / "_rels" rels_dir = self._current_slide_path.parent / "_rels"
@@ -1252,6 +1339,97 @@ class NativePPTXDiagramRenderer:
for node in element.findall(".//p:cNvPr", namespaces=NS): for node in element.findall(".//p:cNvPr", namespaces=NS):
node.set("id", str(allocate_id())) node.set("id", str(allocate_id()))
def _picture_shape(self, shape_id: int, rel_id: str, x: int, y: int, cx: int, cy: int,
name: str = "Brand Icon"):
xml = f"""
<p:pic xmlns:p="{P_NS}" xmlns:a="{A_NS}" xmlns:r="{R_NS}">
<p:nvPicPr>
<p:cNvPr id="{shape_id}" name="{name}"/>
<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
<p:blipFill>
<a:blip r:embed="{rel_id}">
<a:extLst>
<a:ext uri="{{28A0092B-C50C-407E-A947-70E740481C1C}}">
<a14:useLocalDpi xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" val="0"/>
</a:ext>
</a:extLst>
</a:blip>
<a:stretch><a:fillRect/></a:stretch>
</p:blipFill>
<p:spPr>
<a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
</p:pic>
"""
return etree.fromstring(xml.encode("utf-8"))
def _render_brand_icon(self, brand_icon: str | None, x: int, y: int, w: int, h: int,
allocate_id, service_positions: dict, service_id: str,
label: str | None = None, font_size: int | None = None) -> list:
if not brand_icon:
return []
png_bytes = brand_icon_png_bytes(brand_icon)
size = brand_icon_size(brand_icon)
if not png_bytes or not size:
return []
label_text = str(label or "").replace("\\n", "\n")
label_font_size = int(font_size) if font_size is not None else 820
n_lines = label_text.count("\n") + 1 if label_text else 0
label_gap = _emu(0.03) if label_text else 0
label_cy = 0
if n_lines:
line_height_pt = max(label_font_size / 100.0, 8.2) * 1.2
label_cy = max(
_emu(0.18),
int(round((line_height_pt * n_lines / 72.0) * EMU_PER_INCH + _emu(0.03))),
)
icon_box_h = max(h - label_cy - label_gap, 1)
natural_w, natural_h = size
scale = min(w / max(natural_w, 1), icon_box_h / max(natural_h, 1))
icon_cx = max(int(round(natural_w * scale)), 1)
icon_cy = max(int(round(natural_h * scale)), 1)
icon_x = x + max(int((w - icon_cx) / 2), 0)
icon_y = y + max(int((icon_box_h - icon_cy) / 2), 0)
media_target = self._write_media_blob(
brand_icon_media_name(brand_icon, extension="png"),
png_bytes,
)
picture = self._picture_shape(
allocate_id(),
self._add_target_slide_relationship(media_target),
icon_x,
icon_y,
icon_cx,
icon_cy,
)
service_positions[service_id] = {
"x": icon_x,
"y": icon_y,
"cx": icon_cx,
"cy": icon_cy,
}
fragments = [picture]
if label_text:
label_y = y + h - label_cy
fragments.append(self._label_shape(
allocate_id(),
x,
label_y,
w,
label_cy,
label_text,
font_size=label_font_size,
color=self.bark,
bold=True,
align="ctr",
))
return fragments
def _fallback_service_card(self, shape_id: int, x: int, y: int, cx: int, cy: int, label: str, color: str): def _fallback_service_card(self, shape_id: int, x: int, y: int, cx: int, cy: int, label: str, color: str):
xml = f""" xml = f"""
<p:sp xmlns:p="{P_NS}" xmlns:a="{A_NS}"> <p:sp xmlns:p="{P_NS}" xmlns:a="{A_NS}">

146
tools/pptx_validator.py Normal file
View 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())