Compare commits
11 Commits
eef213612c
...
automation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3ad1e3884 | ||
|
|
75203e7196 | ||
|
|
c22ba64bdb | ||
|
|
7ea17b2f9e | ||
|
|
ee15e1f45e | ||
|
|
a032e7980b | ||
|
|
198861f533 | ||
|
|
7f8cba7f30 | ||
|
|
ff77563133 | ||
|
|
1b4828f293 | ||
|
|
5566a171b0 |
@@ -88,7 +88,7 @@ Present these options as a compact numbered list. Each option has a bold title f
|
|||||||
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 → .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,22 @@ 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.`
|
||||||
|
- `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)."
|
||||||
@@ -137,6 +146,7 @@ Pick a number, or just describe what you need.
|
|||||||
- **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.
|
||||||
- **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 +159,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
|
||||||
@@ -175,6 +194,7 @@ Pick a number, or just describe what you need.
|
|||||||
8. Generate implementation roadmap based on engagement tier
|
8. Generate implementation roadmap based on engagement tier
|
||||||
9. Produce a 8-10 slide deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), using a native OCI PowerPoint diagram when the architecture is structured enough
|
9. Produce a 8-10 slide deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), using a native OCI PowerPoint diagram when the architecture is structured enough
|
||||||
10. Output: business-case.pptx + business-case.yaml (reusable spec)
|
10. Output: business-case.pptx + business-case.yaml (reusable spec)
|
||||||
|
11. **Disclaimer + deck verification.** Append the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` as a final "Commercial Disclaimer" slide (verbatim — see option 4 step 7). Then run the same structural deck verification described in option 4 step 8 (slide count, titles, chart types, disclaimer-last) before reporting completion.
|
||||||
- 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 +240,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
|
||||||
@@ -461,9 +481,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 +591,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.
|
||||||
|
|||||||
@@ -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\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
AGENTS.md
26
AGENTS.md
@@ -157,6 +157,32 @@ If a task generates YAML without the corresponding readable output, the task is
|
|||||||
- **Readable outputs** — YAML is backing data, never the deliverable
|
- **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:
|
||||||
|
|||||||
26
CLAUDE.md
26
CLAUDE.md
@@ -159,6 +159,32 @@ python tools/refresh_arch_catalog.py --validate # validate catalog
|
|||||||
- **KB is the moat** — field experience, not documentation regurgitation
|
- **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
|
||||||
|
|||||||
15
Makefile
15
Makefile
@@ -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."
|
||||||
|
|||||||
57
README.md
57
README.md
@@ -11,14 +11,17 @@ What normally takes an SA days of work (structuring notes, designing architectur
|
|||||||
- **ECAL 3.1 native** — engagement RACI, artefact catalog, readiness scoring, and lessons learned per step baked into the workflow
|
- **ECAL 3.1 native** — engagement RACI, artefact catalog, readiness scoring, and lessons learned per step baked into the workflow
|
||||||
- **Field knowledge, beyond the docs** — built-in KB with real gotchas, workarounds, and sizing lessons from production OCI deployments
|
- **Field knowledge, beyond the docs** — built-in KB with real gotchas, workarounds, and sizing lessons from production OCI deployments
|
||||||
- **Honest about trade-offs** — flags OCI limitations and competitive gaps instead of overselling
|
- **Honest about trade-offs** — flags OCI limitations and competitive gaps instead of overselling
|
||||||
- **Multi-cloud aware** — supports hybrid/multi-cloud diagrams (AWS, Azure, GCP icons) and considers options like ADB Multicloud before recommending full migration
|
- **Multi-cloud aware** — supports hybrid/multi-cloud diagrams (AWS, Azure, GCP, plus external brand icons such as Slack and Jira) and considers options like ADB Multicloud before recommending full migration
|
||||||
|
- **MCP-tolerant inputs** — deck, diagram, BOM, and business-case generators accept flat MCP payloads and common field aliases instead of requiring one rigid schema
|
||||||
- **End-to-end coverage** — from discovery notes to go-live checklist, not just the architecture slide
|
- **End-to-end coverage** — from discovery notes to go-live checklist, not just the architecture slide
|
||||||
|
|
||||||
## New user? Start here
|
## New user? Start here
|
||||||
|
|
||||||
The skill runs as a hosted MCP server. Connect once from your LLM client, sign in with Oracle SSO, and the tools are ready.
|
The skill runs as a hosted MCP server. Connect once from your LLM client, sign in with Oracle SSO, and the tools are ready.
|
||||||
|
|
||||||
**1. Add the MCP server to your client:**
|
Fastest path: open AI Factory Hub at `https://ai-lad.com/`, search for `OCI Deal Accelerator`, and install it from there. If you prefer manual setup, use the MCP client instructions below.
|
||||||
|
|
||||||
|
**1. Add the MCP server to your client (manual setup):**
|
||||||
|
|
||||||
| Client | How |
|
| Client | How |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -45,6 +48,7 @@ The skill runs as a hosted MCP server. Connect once from your LLM client, sign i
|
|||||||
|
|
||||||
### Connection URLs
|
### Connection URLs
|
||||||
|
|
||||||
|
- **AI Factory Hub:** `https://ai-lad.com/`
|
||||||
- **MCP (HTTP streamable):** `https://mcp.tech-lad.com/deal-accelerator/mcp/`
|
- **MCP (HTTP streamable):** `https://mcp.tech-lad.com/deal-accelerator/mcp/`
|
||||||
- **SSE (Windsurf only):** `https://mcp.tech-lad.com/deal-accelerator/sse/`
|
- **SSE (Windsurf only):** `https://mcp.tech-lad.com/deal-accelerator/sse/`
|
||||||
|
|
||||||
@@ -61,10 +65,12 @@ From unstructured input (meeting notes, emails, Slack threads), the skill genera
|
|||||||
| **Business Case** — TCO, ROI, value drivers, risk assessment | .pptx | Define | Ideate |
|
| **Business Case** — TCO, ROI, value drivers, risk assessment | .pptx | Define | Ideate |
|
||||||
| **Joint Engagement Plan** — scope, resources, timeline | YAML | Define | Plan |
|
| **Joint Engagement Plan** — scope, resources, timeline | YAML | Define | Plan |
|
||||||
| **Discovery Questionnaire** — structured IT landscape collection | YAML | Design | Current |
|
| **Discovery Questionnaire** — structured IT landscape collection | YAML | Design | Current |
|
||||||
| **Architecture Diagram** — official Oracle visual style, multi-cloud support | .drawio | Design | Future |
|
| **Architecture Diagram** — Oracle reference-architecture geometry, multi-cloud + external brand support | .drawio / .pptx | Design | Future |
|
||||||
| **Slide Deck** — 6-15 slides scaled to engagement complexity | .pptx | Design | Confirm |
|
| **Slide Deck** — 6-15 slides scaled to engagement complexity | .pptx | Design | Confirm |
|
||||||
| **Customer PDF** — branded, no internal KB references | .pdf | Design | Confirm |
|
| **Customer PDF** — branded, no internal KB references | .pdf | Design | Confirm |
|
||||||
| **Cost Estimate** — BYOL vs PAYG breakdown with assumptions | YAML | Design | Future |
|
| **Cost Estimate** — BYOL vs PAYG breakdown with assumptions | YAML | Design | Future |
|
||||||
|
| **Bill of Materials (BOM)** — requested SKUs only, formula-driven totals, discount-aware workbook | .xlsx | Design | Future |
|
||||||
|
| **AppCA-ready BOM** — AppCA import sheet plus full BOM workbook detail | .xlsx | Design | Future |
|
||||||
| **Well-Architected Scorecard** — 5-pillar automated validation | YAML | Design | Future |
|
| **Well-Architected Scorecard** — 5-pillar automated validation | YAML | Design | Future |
|
||||||
| **Operations Model** — day-2 monitoring, patching, incident response | YAML | Design | Future |
|
| **Operations Model** — day-2 monitoring, patching, incident response | YAML | Design | Future |
|
||||||
| **ECAL Readiness Scorecard** — 60-artefact gap analysis per phase | Text | All | All |
|
| **ECAL Readiness Scorecard** — 60-artefact gap analysis per phase | Text | All | All |
|
||||||
@@ -100,9 +106,40 @@ deck + pdf ← + customer-facing PDF (branded, no internal refs)
|
|||||||
pdf ← customer PDF only
|
pdf ← customer PDF only
|
||||||
bizcase ← business case deck for customer approval
|
bizcase ← business case deck for customer approval
|
||||||
full ← everything (pptx + drawio + docx + xlsx + pdf)
|
full ← everything (pptx + drawio + docx + xlsx + pdf)
|
||||||
|
bom ← bill of materials (.xlsx)
|
||||||
deliver ← handover + go-live checklist + success criteria
|
deliver ← handover + go-live checklist + success criteria
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Standalone architecture diagrams can be generated as editable `.drawio` files or as native Oracle-style `.pptx` diagrams/slides, depending on the workflow you trigger.
|
||||||
|
|
||||||
|
## Example Prompts
|
||||||
|
|
||||||
|
These copy/paste prompts are good demo asks for the newest user-visible features, especially when using Codex or another MCP client.
|
||||||
|
|
||||||
|
### Native PowerPoint diagram (.pptx)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Generame un diagrama nativo en .pptx, simple y prolijo, de esta arquitectura: usuarios externos -> load balancer público -> 2 VMs de aplicación en subnet privada -> Autonomous Database Serverless. Quiero que quede presentable para mostrar directo en PowerPoint. No me pidas YAML ni setup adicional.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Brand icons + OIC + dual output
|
||||||
|
|
||||||
|
```text
|
||||||
|
Generate a simple high-level integration diagram for this flow: Slack -> Oracle Integration Cloud (OIC) -> Jira Service Management -> My Oracle Support. Add clear Client, Oracle, and External layers. Use brand icons for Slack and Jira Service Management, and deliver both .drawio and .pptx.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Real multicloud topology
|
||||||
|
|
||||||
|
```text
|
||||||
|
Generate a high-level architecture diagram for this scenario: 1 PostgreSQL in OCI Ashburn, accessed from GCP Virginia through interconnect. PostgreSQL connects internally to 1 Autonomous Database Serverless, which also has 1 refreshable clone in the same region but in a different AD. Deliver both .drawio and .pptx. Keep it simple, executive-friendly, and technically correct.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discount-aware BOM
|
||||||
|
|
||||||
|
```text
|
||||||
|
Gere um BOM em USD para este cenário: PostgreSQL no OCI com 4 OCPU e 500 GB, ADB-S com 200 ECPU e 1 TB BYOL, 1 refreshable clone com o mesmo sizing e FastConnect de 1 Gbps. Aplique 11% de desconto, 12 meses, 24 horas por dia. Quero o arquivo .xlsx e um resumo claro do custo mensal e anual.
|
||||||
|
```
|
||||||
|
|
||||||
## Knowledge Base
|
## Knowledge Base
|
||||||
|
|
||||||
The KB is the moat — field experience, not documentation regurgitation.
|
The KB is the moat — field experience, not documentation regurgitation.
|
||||||
@@ -159,6 +196,7 @@ kb/services/dbexpert-api-reference.yaml # API endpoints and refresh procedure
|
|||||||
Cached locally under `kb/diagram/assets/archcenter-refs/<slug>/`:
|
Cached locally under `kb/diagram/assets/archcenter-refs/<slug>/`:
|
||||||
- `_description.md` — Oracle's architecture rationale (used by both the lookup tool and SKILL.md option 10 "Reference architecture lookup")
|
- `_description.md` — Oracle's architecture rationale (used by both the lookup tool and SKILL.md option 10 "Reference architecture lookup")
|
||||||
- `*.drawio` — official editable source for ~110 references (where Oracle ships a zip)
|
- `*.drawio` — official editable source for ~110 references (where Oracle ships a zip)
|
||||||
|
- `_template.yaml` — extracted `absolute_layout` scaffold so new diagrams can start from Oracle geometry instead of from scratch
|
||||||
- `*.svg` / `*.png` — raster fallback for the rest
|
- `*.svg` / `*.png` — raster fallback for the rest
|
||||||
|
|
||||||
During **Phase 2 (DESIGN)**, the skill automatically matches the proposed architecture against the catalog:
|
During **Phase 2 (DESIGN)**, the skill automatically matches the proposed architecture against the catalog:
|
||||||
@@ -183,7 +221,7 @@ python tools/archcenter_description_fetcher.py --limit 200
|
|||||||
python tools/archcenter_zip_downloader.py # idempotent; skips slugs whose folder already has a .drawio
|
python tools/archcenter_zip_downloader.py # idempotent; skips slugs whose folder already has a .drawio
|
||||||
```
|
```
|
||||||
|
|
||||||
The cached assets under `kb/diagram/assets/archcenter-refs/` (~83MB) are committed so the skill works offline. Refresh quarterly or when `refresh_arch_catalog.py --whats-new` adds entries — both downloader and description fetcher are idempotent and only fetch what's missing.
|
The cached assets under `kb/diagram/assets/archcenter-refs/` (~83MB) are committed so the skill works offline. Lookup uses a local index for near-instant matches, and can be rebuilt manually when needed. Refresh quarterly or when `refresh_arch_catalog.py --whats-new` adds entries — both downloader and description fetcher are idempotent and only fetch what's missing.
|
||||||
|
|
||||||
### KB Health & Freshness
|
### KB Health & Freshness
|
||||||
|
|
||||||
@@ -362,6 +400,8 @@ If you paste discovery notes directly, the skill skips the menu and goes straigh
|
|||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
|
All major generators accept either the canonical nested YAML specs or the flatter MCP payload aliases used by hosted clients.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Slide deck generation (technical proposal)
|
# Slide deck generation (technical proposal)
|
||||||
python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposal.pptx
|
python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposal.pptx
|
||||||
@@ -377,6 +417,9 @@ python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case
|
|||||||
# § "Standard diagram-generation procedure (MANDATORY)" for the full
|
# § "Standard diagram-generation procedure (MANDATORY)" for the full
|
||||||
# workflow: ref-arch lookup → pre-generation review → spec authoring
|
# workflow: ref-arch lookup → pre-generation review → spec authoring
|
||||||
# → automatic spec validator → render → visual verification.
|
# → automatic spec validator → render → visual verification.
|
||||||
|
# Supports OCI fallback aliases for services whose official toolkit icons
|
||||||
|
# are still missing (for example PostgreSQL/Redis) plus `brand_icon` for
|
||||||
|
# third-party systems such as Slack and Jira in both drawio and PPTX.
|
||||||
python tools/archcenter_pattern_lookup.py "<topology keywords>" # 1. find canonical Oracle reference
|
python tools/archcenter_pattern_lookup.py "<topology keywords>" # 1. find canonical Oracle reference
|
||||||
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio # 2. render (validators run automatically)
|
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio # 2. render (validators run automatically)
|
||||||
python tools/oci_pptx_render.py --pptx arch.pptx --output arch.png --width 1600 # 3. rasterize PPTX for visual review
|
python tools/oci_pptx_render.py --pptx arch.pptx --output arch.png --width 1600 # 3. rasterize PPTX for visual review
|
||||||
@@ -389,11 +432,17 @@ python tools/drawio_visual_validator.py arch.drawio
|
|||||||
|
|
||||||
# Output orchestrator (multiple formats at once)
|
# Output orchestrator (multiple formats at once)
|
||||||
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
||||||
|
python tools/oci_output.py --spec examples/exacs-bom-spec.yaml --format bom --output-dir output/
|
||||||
|
|
||||||
|
# BOM / AppCA workbook
|
||||||
|
python tools/oci_bom_gen.py --spec examples/exacs-bom-spec.yaml --output customer-bom.xlsx
|
||||||
|
python tools/oci_bom_gen.py --spec examples/exacs-bom-spec.yaml --output customer-appca.xlsx --appca
|
||||||
|
|
||||||
# Architecture Center catalog
|
# Architecture Center catalog
|
||||||
python tools/refresh_arch_catalog.py --validate
|
python tools/refresh_arch_catalog.py --validate
|
||||||
python tools/refresh_arch_catalog.py --whats-new
|
python tools/refresh_arch_catalog.py --whats-new
|
||||||
python tools/refresh_arch_catalog.py --check-links
|
python tools/refresh_arch_catalog.py --check-links
|
||||||
|
python tools/archcenter_pattern_lookup.py "adb gcp fastconnect" --rebuild-index
|
||||||
|
|
||||||
# Feature compatibility
|
# Feature compatibility
|
||||||
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai
|
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai
|
||||||
|
|||||||
33
SKILL.md
33
SKILL.md
@@ -86,7 +86,7 @@ Present these options as a compact numbered list. Each option has a bold title f
|
|||||||
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 → .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,22 @@ 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.`
|
||||||
|
- `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)."
|
||||||
@@ -135,6 +144,7 @@ Pick a number, or just describe what you need.
|
|||||||
- **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.
|
||||||
- **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 +157,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
|
||||||
@@ -173,6 +192,7 @@ Pick a number, or just describe what you need.
|
|||||||
8. Generate implementation roadmap based on engagement tier
|
8. Generate implementation roadmap based on engagement tier
|
||||||
9. Produce a 8-10 slide deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), using a native OCI PowerPoint diagram when the architecture is structured enough
|
9. Produce a 8-10 slide deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), using a native OCI PowerPoint diagram when the architecture is structured enough
|
||||||
10. Output: business-case.pptx + business-case.yaml (reusable spec)
|
10. Output: business-case.pptx + business-case.yaml (reusable spec)
|
||||||
|
11. **Disclaimer + deck verification.** Append the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` as a final "Commercial Disclaimer" slide (verbatim — see option 4 step 7). Then run the same structural deck verification described in option 4 step 8 (slide count, titles, chart types, disclaimer-last) before reporting completion.
|
||||||
- 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 +238,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
|
||||||
@@ -459,9 +479,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 +589,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
45
config/brand-icons.yaml
Normal 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
|
||||||
@@ -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) =====
|
||||||
|
|||||||
1
kb/diagram/brand-icons/fallback/jirasoftware.svg
Normal file
1
kb/diagram/brand-icons/fallback/jirasoftware.svg
Normal 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 |
@@ -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 |
11
kb/diagram/brand-icons/official/jira/Jira_icon.svg
Normal file
11
kb/diagram/brand-icons/official/jira/Jira_icon.svg
Normal 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 |
BIN
kb/diagram/brand-icons/official/slack/slack_hash_256.png
Normal file
BIN
kb/diagram/brand-icons/official/slack/slack_hash_256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
@@ -1,4 +1,4 @@
|
|||||||
last_verified: '2026-03-27'
|
last_verified: '2026-05-01'
|
||||||
source: Oracle OCI Price List + internal BOM reference v1.73
|
source: Oracle OCI Price List + internal BOM reference v1.73
|
||||||
description: 'OCI SKU catalog for BOM generation. ~160 SKUs across 14+ categories. Prices are approximate and for estimation
|
description: 'OCI SKU catalog for BOM generation. ~160 SKUs across 14+ categories. Prices are approximate and for estimation
|
||||||
only. Always verify against https://www.oracle.com/cloud/pricing/
|
only. Always verify against https://www.oracle.com/cloud/pricing/
|
||||||
@@ -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:
|
||||||
@@ -563,7 +564,7 @@ categories:
|
|||||||
- sku: B95714
|
- sku: B95714
|
||||||
product: Oracle Autonomous AI Transaction Processing - Dedicated - ECPU
|
product: Oracle Autonomous AI Transaction Processing - Dedicated - ECPU
|
||||||
metric: ECPU Per Hour
|
metric: ECPU Per Hour
|
||||||
list_price_usd: 0.336
|
list_price_usd: 0.0807
|
||||||
default_hours_units: 730
|
default_hours_units: 730
|
||||||
billing_type: hourly
|
billing_type: hourly
|
||||||
- sku: B95715
|
- sku: B95715
|
||||||
@@ -1301,12 +1302,6 @@ categories:
|
|||||||
list_price_usd: 0.015
|
list_price_usd: 0.015
|
||||||
default_hours_units: 730
|
default_hours_units: 730
|
||||||
billing_type: hourly
|
billing_type: hourly
|
||||||
# OCI Data Science and Data Flow do not have dedicated SKUs in the Oracle
|
|
||||||
# public pricing API — they are billed via the underlying Compute + Block/Object
|
|
||||||
# Storage consumed by notebook sessions, model-deployment endpoints, and Spark
|
|
||||||
# runs. The entries below are placeholder estimates (VM.Standard3.Flex OCPU rate)
|
|
||||||
# so BOMs can include them as line items; always confirm against the customer's
|
|
||||||
# actual shape + usage before quoting.
|
|
||||||
- sku: EST-DS-NOTEBOOK
|
- sku: EST-DS-NOTEBOOK
|
||||||
product: '[ESTIMATE] OCI Data Science - Notebook Session (billed via underlying VM.Standard compute OCPU)'
|
product: '[ESTIMATE] OCI Data Science - Notebook Session (billed via underlying VM.Standard compute OCPU)'
|
||||||
metric: OCPU Per Hour
|
metric: OCPU Per Hour
|
||||||
|
|||||||
@@ -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
194
tools/brand_icon_catalog.py
Normal 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)
|
||||||
685
tools/build_bom_comparison_deck.py
Normal file
685
tools/build_bom_comparison_deck.py
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Build a short PowerPoint deck comparing multiple OCI BOM scenarios.
|
||||||
|
|
||||||
|
The input spec references one or more BOM YAML specs. The script resolves
|
||||||
|
monthly and 12-month totals from the live SKU catalog and produces a
|
||||||
|
customer-ready Oracle FY26 deck with native PowerPoint charts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pptx.chart.data import CategoryChartData
|
||||||
|
from pptx.dml.color import RGBColor
|
||||||
|
from pptx.enum.chart import XL_CHART_TYPE, XL_LABEL_POSITION, XL_LEGEND_POSITION
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE
|
||||||
|
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||||
|
from pptx.util import Inches, Pt
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CATALOG_PATH = PROJECT_ROOT / "kb" / "pricing" / "oci-sku-catalog.yaml"
|
||||||
|
DB_SERVER_SKU = "B110627"
|
||||||
|
STORAGE_SERVER_SKU = "B110629"
|
||||||
|
|
||||||
|
|
||||||
|
def _round_money(value: Decimal) -> Decimal:
|
||||||
|
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
def _money_text(value: Decimal) -> str:
|
||||||
|
return f"USD {value:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _pct_text(value: Decimal) -> str:
|
||||||
|
sign = "+" if value > 0 else ""
|
||||||
|
return f"{sign}{value:.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _pct_abs_text(value: Decimal) -> str:
|
||||||
|
return f"{abs(value):.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(path_str: str) -> Path:
|
||||||
|
path = Path(path_str)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path
|
||||||
|
return PROJECT_ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalog(path: Path) -> dict[str, dict]:
|
||||||
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
catalog: dict[str, dict] = {}
|
||||||
|
for category in raw.get("categories", {}).values():
|
||||||
|
for sku in category.get("skus", []):
|
||||||
|
catalog[str(sku["sku"])] = sku
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScenarioMetrics:
|
||||||
|
scenario_id: str
|
||||||
|
short_label: str
|
||||||
|
title: str
|
||||||
|
topology: str
|
||||||
|
tradeoff: str
|
||||||
|
accent: str
|
||||||
|
term_months: int
|
||||||
|
db_servers: int
|
||||||
|
storage_servers: int
|
||||||
|
ecpus: int
|
||||||
|
monthly_total: Decimal
|
||||||
|
term_total: Decimal
|
||||||
|
infra_monthly: Decimal
|
||||||
|
compute_monthly: Decimal
|
||||||
|
delta_vs_baseline: Decimal = Decimal("0")
|
||||||
|
delta_pct_vs_baseline: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_color(accent: str) -> RGBColor:
|
||||||
|
return {
|
||||||
|
"recommended": Colors.COPPER,
|
||||||
|
"balanced": Colors.TEAL,
|
||||||
|
"savings": Colors.FOREST,
|
||||||
|
}.get(accent, Colors.TEAL)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_bom_metrics(spec_path: Path, meta: dict, catalog: dict[str, dict]) -> ScenarioMetrics:
|
||||||
|
raw = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
|
||||||
|
bom = raw.get("bom", raw)
|
||||||
|
items = bom.get("line_items", [])
|
||||||
|
|
||||||
|
monthly_total = Decimal("0")
|
||||||
|
term_total = Decimal("0")
|
||||||
|
infra_monthly = Decimal("0")
|
||||||
|
compute_monthly = Decimal("0")
|
||||||
|
db_servers = Decimal("0")
|
||||||
|
storage_servers = Decimal("0")
|
||||||
|
ecpus = Decimal("0")
|
||||||
|
months_seen: set[int] = set()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
sku = str(item["sku"])
|
||||||
|
entry = catalog.get(sku)
|
||||||
|
if not entry:
|
||||||
|
raise KeyError(f"SKU {sku} not found in catalog while processing {spec_path}")
|
||||||
|
|
||||||
|
price = Decimal(str(entry.get("list_price_usd", 0)))
|
||||||
|
qty = Decimal(str(item.get("qty", 0)))
|
||||||
|
hours_units = Decimal(str(item.get("hours_units", entry.get("default_hours_units", 730))))
|
||||||
|
months = int(item.get("months", 12))
|
||||||
|
discount = Decimal(str(item.get("discount", 0)))
|
||||||
|
line_monthly = price * qty * hours_units * (Decimal("1") - discount)
|
||||||
|
line_term = line_monthly * Decimal(str(months))
|
||||||
|
|
||||||
|
monthly_total += line_monthly
|
||||||
|
term_total += line_term
|
||||||
|
months_seen.add(months)
|
||||||
|
|
||||||
|
if sku == DB_SERVER_SKU:
|
||||||
|
db_servers += qty
|
||||||
|
infra_monthly += line_monthly
|
||||||
|
elif sku == STORAGE_SERVER_SKU:
|
||||||
|
storage_servers += qty
|
||||||
|
infra_monthly += line_monthly
|
||||||
|
elif "ECPU" in str(entry.get("metric", "")).upper():
|
||||||
|
ecpus += qty
|
||||||
|
compute_monthly += line_monthly
|
||||||
|
else:
|
||||||
|
compute_monthly += line_monthly
|
||||||
|
|
||||||
|
term_months = max(months_seen) if months_seen else int(meta.get("term_months", 12))
|
||||||
|
return ScenarioMetrics(
|
||||||
|
scenario_id=meta["id"],
|
||||||
|
short_label=meta.get("short_label", meta["id"]),
|
||||||
|
title=meta["title"],
|
||||||
|
topology=meta["topology"],
|
||||||
|
tradeoff=meta["tradeoff"],
|
||||||
|
accent=meta.get("accent", "balanced"),
|
||||||
|
term_months=term_months,
|
||||||
|
db_servers=int(db_servers),
|
||||||
|
storage_servers=int(storage_servers),
|
||||||
|
ecpus=int(ecpus),
|
||||||
|
monthly_total=_round_money(monthly_total),
|
||||||
|
term_total=_round_money(term_total),
|
||||||
|
infra_monthly=_round_money(infra_monthly),
|
||||||
|
compute_monthly=_round_money(compute_monthly),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BOMComparisonDeck(OraclePresBase):
|
||||||
|
TITLE_ACCENT_COLOR = Colors.TEAL
|
||||||
|
MARGIN = Inches(0.55)
|
||||||
|
|
||||||
|
def __init__(self, spec: dict, scenarios: list[ScenarioMetrics]):
|
||||||
|
super().__init__()
|
||||||
|
self.spec = spec
|
||||||
|
self.deck_meta = spec["deck"]
|
||||||
|
self.scenarios = scenarios
|
||||||
|
self.term_months = int(self.deck_meta.get("term_months", 12))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _compute_is_constant(self) -> bool:
|
||||||
|
return len({scenario.compute_monthly for scenario in self.scenarios}) == 1
|
||||||
|
|
||||||
|
def _add_footer_note(self, slide, text: str) -> None:
|
||||||
|
box = self._add_textbox(
|
||||||
|
slide,
|
||||||
|
self.MARGIN,
|
||||||
|
Inches(7.0),
|
||||||
|
Inches(12.0),
|
||||||
|
Inches(0.25),
|
||||||
|
text=text,
|
||||||
|
font_size=9,
|
||||||
|
italic=True,
|
||||||
|
color=Colors.SECONDARY_TEXT,
|
||||||
|
)
|
||||||
|
box.text_frame.word_wrap = True
|
||||||
|
|
||||||
|
def _add_pill(self, slide, left, top, width, text, fill_color: RGBColor) -> None:
|
||||||
|
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, Inches(0.3))
|
||||||
|
shape.fill.solid()
|
||||||
|
shape.fill.fore_color.rgb = fill_color
|
||||||
|
shape.line.fill.background()
|
||||||
|
tf = shape.text_frame
|
||||||
|
tf.clear()
|
||||||
|
p = tf.paragraphs[0]
|
||||||
|
p.text = text
|
||||||
|
p.alignment = PP_ALIGN.CENTER
|
||||||
|
p.font.name = self.FONT
|
||||||
|
p.font.size = Pt(10)
|
||||||
|
p.font.bold = True
|
||||||
|
p.font.color.rgb = Colors.WHITE
|
||||||
|
shape.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||||
|
|
||||||
|
def add_cover(self) -> None:
|
||||||
|
slide = self._add_layout_slide(Layouts.COVER_DARK)
|
||||||
|
self._set_placeholder(slide, 0, self.deck_meta["title"])
|
||||||
|
self._set_placeholder(slide, 1, self.deck_meta["subtitle"])
|
||||||
|
disclaimer = self.deck_meta.get("commercial_disclaimer", {})
|
||||||
|
cover_tag = disclaimer.get("cover_tag")
|
||||||
|
if cover_tag:
|
||||||
|
self._add_pill(slide, Inches(8.7), Inches(0.45), Inches(3.9), cover_tag, Colors.COPPER)
|
||||||
|
|
||||||
|
def add_summary_slide(self) -> None:
|
||||||
|
slide = self._add_blank_slide()
|
||||||
|
self._add_title_bar(slide, "Scenario Summary", margin=self.MARGIN)
|
||||||
|
|
||||||
|
if self._compute_is_constant:
|
||||||
|
headline = (
|
||||||
|
"All three scenarios carry the same 830 ECPU allocation. "
|
||||||
|
"The cost delta comes from infrastructure footprint only."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
headline = (
|
||||||
|
"Recommended setup remains the architecture-led option. "
|
||||||
|
"Combined 6x4 trims infrastructure. Combined 5x3 is the strongest cost-down option."
|
||||||
|
)
|
||||||
|
self._add_textbox(
|
||||||
|
slide,
|
||||||
|
self.MARGIN,
|
||||||
|
Inches(1.2),
|
||||||
|
Inches(12.0),
|
||||||
|
Inches(0.5),
|
||||||
|
text=headline,
|
||||||
|
font_size=15,
|
||||||
|
bold=True,
|
||||||
|
color=Colors.PRIMARY_TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
card_lefts = [self.MARGIN, Inches(4.45), Inches(8.35)]
|
||||||
|
card_width = Inches(3.85)
|
||||||
|
card_top = Inches(1.9)
|
||||||
|
card_height = Inches(4.55)
|
||||||
|
baseline = next(s for s in self.scenarios if s.scenario_id == self.deck_meta["baseline_scenario_id"])
|
||||||
|
|
||||||
|
for left, scenario in zip(card_lefts, self.scenarios):
|
||||||
|
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, card_top, card_width, card_height)
|
||||||
|
card.fill.solid()
|
||||||
|
card.fill.fore_color.rgb = Colors.WARM_BG
|
||||||
|
card.line.color.rgb = _scenario_color(scenario.accent)
|
||||||
|
card.line.width = Pt(1.5)
|
||||||
|
|
||||||
|
accent = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, card_top, card_width, Inches(0.18))
|
||||||
|
accent.fill.solid()
|
||||||
|
accent.fill.fore_color.rgb = _scenario_color(scenario.accent)
|
||||||
|
accent.line.fill.background()
|
||||||
|
|
||||||
|
self._add_textbox(
|
||||||
|
slide, left + Inches(0.18), card_top + Inches(0.28), card_width - Inches(0.36), Inches(0.55),
|
||||||
|
text=scenario.title, font_size=18, bold=True, color=Colors.PRIMARY_TEXT, font_name=self.FONT_HEADING,
|
||||||
|
)
|
||||||
|
self._add_textbox(
|
||||||
|
slide, left + Inches(0.18), card_top + Inches(0.82), card_width - Inches(0.36), Inches(0.7),
|
||||||
|
text=scenario.topology, font_size=11, color=Colors.SECONDARY_TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
tag_text = "Recommended" if scenario.scenario_id == baseline.scenario_id else (
|
||||||
|
"Lowest cost" if scenario.monthly_total == min(s.monthly_total for s in self.scenarios) else "Alternative"
|
||||||
|
)
|
||||||
|
self._add_pill(slide, left + Inches(0.18), card_top + Inches(1.45), Inches(1.35), tag_text, _scenario_color(scenario.accent))
|
||||||
|
|
||||||
|
self._add_textbox(
|
||||||
|
slide, left + Inches(0.18), card_top + Inches(1.92), card_width - Inches(0.36), Inches(0.32),
|
||||||
|
text="Monthly", font_size=10, bold=True, color=Colors.SECONDARY_TEXT,
|
||||||
|
)
|
||||||
|
self._add_textbox(
|
||||||
|
slide, left + Inches(0.18), card_top + Inches(2.18), card_width - Inches(0.36), Inches(0.42),
|
||||||
|
text=_money_text(scenario.monthly_total), font_size=20, bold=True, color=_scenario_color(scenario.accent),
|
||||||
|
)
|
||||||
|
self._add_textbox(
|
||||||
|
slide, left + Inches(0.18), card_top + Inches(2.65), card_width - Inches(0.36), Inches(0.3),
|
||||||
|
text=f"{self.term_months}M total: {_money_text(scenario.term_total)}", font_size=10, color=Colors.SECONDARY_TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if scenario.scenario_id == baseline.scenario_id:
|
||||||
|
delta_text = "Baseline"
|
||||||
|
delta_color = Colors.PRIMARY_TEXT
|
||||||
|
elif scenario.delta_vs_baseline < 0:
|
||||||
|
delta_text = (
|
||||||
|
f"Savings vs {baseline.short_label}: "
|
||||||
|
f"{_money_text(-scenario.delta_vs_baseline)} / mo "
|
||||||
|
f"({_pct_abs_text(scenario.delta_pct_vs_baseline)} lower)"
|
||||||
|
)
|
||||||
|
delta_color = Colors.FOREST
|
||||||
|
else:
|
||||||
|
delta_text = (
|
||||||
|
f"Premium vs {baseline.short_label}: "
|
||||||
|
f"{_money_text(scenario.delta_vs_baseline)} / mo "
|
||||||
|
f"({_pct_text(scenario.delta_pct_vs_baseline)})"
|
||||||
|
)
|
||||||
|
delta_color = Colors.PRIMARY_TEXT
|
||||||
|
self._add_textbox(
|
||||||
|
slide, left + Inches(0.18), card_top + Inches(3.05), card_width - Inches(0.36), Inches(0.45),
|
||||||
|
text=delta_text, font_size=10, bold=True,
|
||||||
|
color=delta_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
footprint = (
|
||||||
|
f"DB servers: {scenario.db_servers}\n"
|
||||||
|
f"Storage servers: {scenario.storage_servers}\n"
|
||||||
|
f"Total ECPU: {scenario.ecpus:,}"
|
||||||
|
)
|
||||||
|
tf_box = self._add_textbox(
|
||||||
|
slide, left + Inches(0.18), card_top + Inches(3.55), card_width - Inches(0.36), Inches(0.72),
|
||||||
|
text=footprint, font_size=10, color=Colors.PRIMARY_TEXT,
|
||||||
|
)
|
||||||
|
tf_box.text_frame.word_wrap = True
|
||||||
|
|
||||||
|
self._add_textbox(
|
||||||
|
slide, left + Inches(0.18), card_top + Inches(4.15), card_width - Inches(0.36), Inches(0.32),
|
||||||
|
text=scenario.tradeoff, font_size=10, italic=True, color=Colors.SECONDARY_TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_footer_note(slide, "Source footprint: recommended and combined options from the reference deck. Pricing based on OCI list price, 730 hours/month, no commercial discount.")
|
||||||
|
|
||||||
|
def add_monthly_chart_slide(self) -> None:
|
||||||
|
slide = self._add_blank_slide()
|
||||||
|
self._add_title_bar(slide, "Monthly Cost Comparison", margin=self.MARGIN)
|
||||||
|
|
||||||
|
data = CategoryChartData()
|
||||||
|
data.categories = [s.short_label for s in self.scenarios]
|
||||||
|
data.add_series("Monthly USD", [float(s.monthly_total) for s in self.scenarios])
|
||||||
|
frame = slide.shapes.add_chart(
|
||||||
|
XL_CHART_TYPE.COLUMN_CLUSTERED,
|
||||||
|
self.MARGIN,
|
||||||
|
Inches(1.35),
|
||||||
|
Inches(7.55),
|
||||||
|
Inches(4.9),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
chart = frame.chart
|
||||||
|
chart.has_legend = False
|
||||||
|
chart.value_axis.has_major_gridlines = True
|
||||||
|
chart.value_axis.tick_labels.number_format = '$0,"K"'
|
||||||
|
chart.value_axis.format.line.color.rgb = Colors.SECONDARY_TEXT
|
||||||
|
chart.category_axis.format.line.color.rgb = Colors.SECONDARY_TEXT
|
||||||
|
|
||||||
|
plot = chart.plots[0]
|
||||||
|
plot.vary_by_categories = True
|
||||||
|
plot.has_data_labels = True
|
||||||
|
plot.data_labels.number_format = '$#,##0'
|
||||||
|
plot.data_labels.position = XL_LABEL_POSITION.OUTSIDE_END
|
||||||
|
|
||||||
|
series = chart.series[0]
|
||||||
|
for point, scenario in zip(series.points, self.scenarios):
|
||||||
|
point.format.fill.solid()
|
||||||
|
point.format.fill.fore_color.rgb = _scenario_color(scenario.accent)
|
||||||
|
point.format.line.color.rgb = _scenario_color(scenario.accent)
|
||||||
|
|
||||||
|
callout = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(8.2), Inches(1.45), Inches(4.5), Inches(4.6))
|
||||||
|
callout.fill.solid()
|
||||||
|
callout.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||||
|
callout.line.color.rgb = Colors.TEAL
|
||||||
|
callout.line.width = Pt(1.2)
|
||||||
|
|
||||||
|
baseline = next(s for s in self.scenarios if s.scenario_id == self.deck_meta["baseline_scenario_id"])
|
||||||
|
cheapest = min(self.scenarios, key=lambda s: s.monthly_total)
|
||||||
|
bullets = [
|
||||||
|
f"{baseline.short_label} is still the architecture-led recommendation.",
|
||||||
|
f"{self.scenarios[1].short_label} saves {_money_text(-self.scenarios[1].delta_vs_baseline)} per month by reducing storage footprint only.",
|
||||||
|
f"{cheapest.short_label} saves {_money_text(-cheapest.delta_vs_baseline)} per month and is {_pct_abs_text(cheapest.delta_pct_vs_baseline)} below the baseline.",
|
||||||
|
(
|
||||||
|
f"{cheapest.short_label} is cheaper because it cuts both infrastructure and compute footprint."
|
||||||
|
if not self._compute_is_constant
|
||||||
|
else f"{cheapest.short_label} is cheaper exclusively because it uses the smallest infrastructure footprint."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self._add_textbox(
|
||||||
|
slide, Inches(8.42), Inches(1.7), Inches(3.95), Inches(0.35),
|
||||||
|
text="Key takeaways", font_size=16, bold=True, color=Colors.PRIMARY_TEXT, font_name=self.FONT_HEADING,
|
||||||
|
)
|
||||||
|
notes_box = self._add_textbox(slide, Inches(8.35), Inches(2.15), Inches(3.95), Inches(3.4), text="", font_size=11)
|
||||||
|
tf = notes_box.text_frame
|
||||||
|
tf.clear()
|
||||||
|
for idx, bullet in enumerate(bullets):
|
||||||
|
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
||||||
|
p.text = f"• {bullet}"
|
||||||
|
p.font.name = self.FONT
|
||||||
|
p.font.size = Pt(11)
|
||||||
|
p.font.color.rgb = Colors.PRIMARY_TEXT
|
||||||
|
p.space_after = Pt(8)
|
||||||
|
|
||||||
|
self._add_footer_note(slide, "This chart shows monthly run-rate only. The same ranking holds for the 12-month view because all scenarios use the same 730-hour monthly convention and 12-month term.")
|
||||||
|
|
||||||
|
def add_composition_slide(self) -> None:
|
||||||
|
slide = self._add_blank_slide()
|
||||||
|
self._add_title_bar(slide, "Cost Composition: Infrastructure vs ECPU", margin=self.MARGIN)
|
||||||
|
|
||||||
|
data = CategoryChartData()
|
||||||
|
data.categories = [s.short_label for s in self.scenarios]
|
||||||
|
data.add_series("Infrastructure", [float(s.infra_monthly) for s in self.scenarios])
|
||||||
|
data.add_series("ADB Dedicated ECPU BYOL", [float(s.compute_monthly) for s in self.scenarios])
|
||||||
|
|
||||||
|
frame = slide.shapes.add_chart(
|
||||||
|
XL_CHART_TYPE.COLUMN_STACKED,
|
||||||
|
self.MARGIN,
|
||||||
|
Inches(1.35),
|
||||||
|
Inches(7.55),
|
||||||
|
Inches(4.8),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
chart = frame.chart
|
||||||
|
chart.has_legend = True
|
||||||
|
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
|
||||||
|
chart.legend.include_in_layout = False
|
||||||
|
chart.value_axis.has_major_gridlines = True
|
||||||
|
chart.value_axis.tick_labels.number_format = '$0,"K"'
|
||||||
|
|
||||||
|
series_infra = chart.series[0]
|
||||||
|
series_infra.format.fill.solid()
|
||||||
|
series_infra.format.fill.fore_color.rgb = Colors.MUTED_TEAL
|
||||||
|
series_infra.format.line.color.rgb = Colors.MUTED_TEAL
|
||||||
|
|
||||||
|
series_compute = chart.series[1]
|
||||||
|
series_compute.format.fill.solid()
|
||||||
|
series_compute.format.fill.fore_color.rgb = Colors.COPPER
|
||||||
|
series_compute.format.line.color.rgb = Colors.COPPER
|
||||||
|
|
||||||
|
table = self._add_table(
|
||||||
|
slide,
|
||||||
|
len(self.scenarios) + 1,
|
||||||
|
4,
|
||||||
|
Inches(8.2),
|
||||||
|
Inches(1.6),
|
||||||
|
Inches(4.4),
|
||||||
|
Inches(2.4),
|
||||||
|
)
|
||||||
|
headers = ["Scenario", "Infra / mo", "ECPU / mo", "Infra share"]
|
||||||
|
widths = [1.2, 1.05, 1.1, 1.0]
|
||||||
|
for idx, width in enumerate(widths):
|
||||||
|
table.columns[idx].width = Inches(width)
|
||||||
|
for col, header in enumerate(headers):
|
||||||
|
self._style_table_cell(
|
||||||
|
table.cell(0, col), header,
|
||||||
|
font_size=10, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||||
|
alignment=PP_ALIGN.CENTER if col > 0 else PP_ALIGN.LEFT,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row_idx, scenario in enumerate(self.scenarios, start=1):
|
||||||
|
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||||
|
infra_share = (scenario.infra_monthly / scenario.monthly_total * Decimal("100")) if scenario.monthly_total else Decimal("0")
|
||||||
|
self._style_table_cell(table.cell(row_idx, 0), scenario.short_label, font_size=10, bold=True, bg_color=bg)
|
||||||
|
self._style_table_cell(table.cell(row_idx, 1), _money_text(scenario.infra_monthly), font_size=9, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||||
|
self._style_table_cell(table.cell(row_idx, 2), _money_text(scenario.compute_monthly), font_size=9, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||||
|
self._style_table_cell(table.cell(row_idx, 3), f"{infra_share:.1f}%", font_size=9, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||||
|
|
||||||
|
insight = (
|
||||||
|
"The ECPU line is flat across all three scenarios: 830 total ECPU.\n"
|
||||||
|
"Only the DB server and storage server counts move the price."
|
||||||
|
if self._compute_is_constant
|
||||||
|
else
|
||||||
|
"Combined 6x4 changes only the infrastructure layer.\n"
|
||||||
|
"Combined 5x3 is materially cheaper because it also reduces the ECPU footprint."
|
||||||
|
)
|
||||||
|
callout = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(8.2), Inches(4.35), Inches(4.4), Inches(1.15))
|
||||||
|
callout.fill.solid()
|
||||||
|
callout.fill.fore_color.rgb = Colors.WARM_BG
|
||||||
|
callout.line.color.rgb = Colors.COPPER
|
||||||
|
self._add_textbox(
|
||||||
|
slide, Inches(8.42), Inches(4.58), Inches(4.0), Inches(0.7),
|
||||||
|
text=insight, font_size=11, bold=True, color=Colors.PRIMARY_TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_footer_note(slide, "Infrastructure is the sum of X11M DB servers plus X11M storage servers. Compute is the ADB Dedicated BYOL ECPU line.")
|
||||||
|
|
||||||
|
def add_detail_table_slide(self) -> None:
|
||||||
|
slide = self._add_blank_slide()
|
||||||
|
self._add_title_bar(slide, "Detailed BOM Comparison", margin=self.MARGIN)
|
||||||
|
|
||||||
|
table = self._add_table(
|
||||||
|
slide,
|
||||||
|
len(self.scenarios) + 1,
|
||||||
|
8,
|
||||||
|
self.MARGIN,
|
||||||
|
Inches(1.4),
|
||||||
|
Inches(12.2),
|
||||||
|
Inches(3.1),
|
||||||
|
)
|
||||||
|
widths = [1.5, 3.0, 0.9, 1.1, 1.1, 1.35, 1.35, 1.9]
|
||||||
|
for idx, width in enumerate(widths):
|
||||||
|
table.columns[idx].width = Inches(width)
|
||||||
|
headers = ["Scenario", "Topology", "DB", "Storage", "ECPU", "Monthly", f"{self.term_months}M total", "Trade-off"]
|
||||||
|
for col, header in enumerate(headers):
|
||||||
|
self._style_table_cell(
|
||||||
|
table.cell(0, col), header,
|
||||||
|
font_size=10, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||||
|
alignment=PP_ALIGN.CENTER if col not in (0, 1, 7) else PP_ALIGN.LEFT,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row_idx, scenario in enumerate(self.scenarios, start=1):
|
||||||
|
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||||
|
if scenario.scenario_id == self.deck_meta["baseline_scenario_id"]:
|
||||||
|
bg = Colors.TEAL
|
||||||
|
txt = Colors.WHITE
|
||||||
|
else:
|
||||||
|
txt = Colors.PRIMARY_TEXT
|
||||||
|
|
||||||
|
values = [
|
||||||
|
scenario.short_label,
|
||||||
|
scenario.topology,
|
||||||
|
str(scenario.db_servers),
|
||||||
|
str(scenario.storage_servers),
|
||||||
|
f"{scenario.ecpus:,}",
|
||||||
|
_money_text(scenario.monthly_total),
|
||||||
|
_money_text(scenario.term_total),
|
||||||
|
scenario.tradeoff,
|
||||||
|
]
|
||||||
|
aligns = [PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.CENTER, PP_ALIGN.CENTER, PP_ALIGN.RIGHT, PP_ALIGN.RIGHT, PP_ALIGN.RIGHT, PP_ALIGN.LEFT]
|
||||||
|
for col_idx, (value, align) in enumerate(zip(values, aligns)):
|
||||||
|
self._style_table_cell(
|
||||||
|
table.cell(row_idx, col_idx), value,
|
||||||
|
font_size=9, bold=(scenario.scenario_id == self.deck_meta["baseline_scenario_id"]),
|
||||||
|
color=txt, bg_color=bg, alignment=align,
|
||||||
|
)
|
||||||
|
|
||||||
|
deltas_box = self._add_textbox(slide, self.MARGIN, Inches(5.0), Inches(12.0), Inches(1.0), text="", font_size=10)
|
||||||
|
tf = deltas_box.text_frame
|
||||||
|
tf.clear()
|
||||||
|
baseline = next(s for s in self.scenarios if s.scenario_id == self.deck_meta["baseline_scenario_id"])
|
||||||
|
summary_lines = [
|
||||||
|
f"Baseline: {baseline.short_label} at {_money_text(baseline.monthly_total)} per month.",
|
||||||
|
f"{self.scenarios[1].short_label}: {_money_text(-self.scenarios[1].delta_vs_baseline)} per month lower than baseline.",
|
||||||
|
f"{self.scenarios[2].short_label}: {_money_text(-self.scenarios[2].delta_vs_baseline)} per month lower than baseline.",
|
||||||
|
]
|
||||||
|
for idx, line in enumerate(summary_lines):
|
||||||
|
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
||||||
|
p.text = line
|
||||||
|
p.font.name = self.FONT
|
||||||
|
p.font.size = Pt(10)
|
||||||
|
p.font.color.rgb = Colors.SECONDARY_TEXT
|
||||||
|
p.space_after = Pt(5)
|
||||||
|
|
||||||
|
self._add_footer_note(slide, "BOM workbook references are saved alongside this deck for traceability and recalculation.")
|
||||||
|
|
||||||
|
def add_disclaimer_slide(self) -> None:
|
||||||
|
disclaimer = self.deck_meta.get("commercial_disclaimer", {})
|
||||||
|
if not disclaimer:
|
||||||
|
return
|
||||||
|
|
||||||
|
slide = self._add_blank_slide()
|
||||||
|
self._add_title_bar(slide, disclaimer.get("title", "Commercial Disclaimer"), margin=self.MARGIN)
|
||||||
|
|
||||||
|
intro_box = slide.shapes.add_shape(
|
||||||
|
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||||
|
self.MARGIN,
|
||||||
|
Inches(1.35),
|
||||||
|
Inches(12.1),
|
||||||
|
Inches(2.15),
|
||||||
|
)
|
||||||
|
intro_box.fill.solid()
|
||||||
|
intro_box.fill.fore_color.rgb = Colors.WARM_BG
|
||||||
|
intro_box.line.color.rgb = Colors.COPPER
|
||||||
|
intro_box.line.width = Pt(1.2)
|
||||||
|
|
||||||
|
formal = disclaimer.get("formal_text", "")
|
||||||
|
formal_tb = self._add_textbox(
|
||||||
|
slide,
|
||||||
|
Inches(0.8),
|
||||||
|
Inches(1.58),
|
||||||
|
Inches(11.55),
|
||||||
|
Inches(1.7),
|
||||||
|
text="",
|
||||||
|
font_size=11,
|
||||||
|
color=Colors.PRIMARY_TEXT,
|
||||||
|
)
|
||||||
|
formal_tf = formal_tb.text_frame
|
||||||
|
formal_tf.clear()
|
||||||
|
p = formal_tf.paragraphs[0]
|
||||||
|
p.text = formal
|
||||||
|
p.font.name = self.FONT
|
||||||
|
p.font.size = Pt(11)
|
||||||
|
p.font.color.rgb = Colors.PRIMARY_TEXT
|
||||||
|
p.alignment = PP_ALIGN.LEFT
|
||||||
|
p.space_after = Pt(0)
|
||||||
|
formal_tf.word_wrap = True
|
||||||
|
|
||||||
|
note_box = slide.shapes.add_shape(
|
||||||
|
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||||
|
self.MARGIN,
|
||||||
|
Inches(3.95),
|
||||||
|
Inches(12.1),
|
||||||
|
Inches(2.15),
|
||||||
|
)
|
||||||
|
note_box.fill.solid()
|
||||||
|
note_box.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||||
|
note_box.line.color.rgb = Colors.TEAL
|
||||||
|
note_box.line.width = Pt(1.2)
|
||||||
|
|
||||||
|
self._add_textbox(
|
||||||
|
slide,
|
||||||
|
Inches(0.8),
|
||||||
|
Inches(4.18),
|
||||||
|
Inches(3.2),
|
||||||
|
Inches(0.35),
|
||||||
|
text="Practical notes",
|
||||||
|
font_size=16,
|
||||||
|
bold=True,
|
||||||
|
color=Colors.PRIMARY_TEXT,
|
||||||
|
font_name=self.FONT_HEADING,
|
||||||
|
)
|
||||||
|
bullets_tb = self._add_textbox(
|
||||||
|
slide,
|
||||||
|
Inches(0.8),
|
||||||
|
Inches(4.55),
|
||||||
|
Inches(11.2),
|
||||||
|
Inches(1.25),
|
||||||
|
text="",
|
||||||
|
font_size=11,
|
||||||
|
color=Colors.PRIMARY_TEXT,
|
||||||
|
)
|
||||||
|
bullets_tf = bullets_tb.text_frame
|
||||||
|
bullets_tf.clear()
|
||||||
|
for idx, bullet in enumerate(disclaimer.get("bullets", [])):
|
||||||
|
p = bullets_tf.paragraphs[0] if idx == 0 else bullets_tf.add_paragraph()
|
||||||
|
p.text = f"• {bullet}"
|
||||||
|
p.font.name = self.FONT
|
||||||
|
p.font.size = Pt(11)
|
||||||
|
p.font.color.rgb = Colors.PRIMARY_TEXT
|
||||||
|
p.space_after = Pt(10)
|
||||||
|
|
||||||
|
self._add_footer_note(
|
||||||
|
slide,
|
||||||
|
"Reference pricing only. Formal commercials must move through the Oracle quoting process and the applicable agreement path.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.add_cover()
|
||||||
|
self.add_summary_slide()
|
||||||
|
self.add_monthly_chart_slide()
|
||||||
|
self.add_composition_slide()
|
||||||
|
self.add_detail_table_slide()
|
||||||
|
self.add_disclaimer_slide()
|
||||||
|
|
||||||
|
|
||||||
|
def load_deck_inputs(spec_path: Path) -> tuple[dict, list[ScenarioMetrics]]:
|
||||||
|
spec = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
|
||||||
|
deck = spec["deck"]
|
||||||
|
catalog = _load_catalog(CATALOG_PATH)
|
||||||
|
scenarios = []
|
||||||
|
for scenario_meta in deck.get("scenarios", []):
|
||||||
|
bom_spec = _resolve_path(scenario_meta["bom_spec"])
|
||||||
|
scenarios.append(_load_bom_metrics(bom_spec, scenario_meta, catalog))
|
||||||
|
|
||||||
|
baseline_id = deck["baseline_scenario_id"]
|
||||||
|
baseline = next(s for s in scenarios if s.scenario_id == baseline_id)
|
||||||
|
for scenario in scenarios:
|
||||||
|
scenario.delta_vs_baseline = _round_money(scenario.monthly_total - baseline.monthly_total)
|
||||||
|
if baseline.monthly_total:
|
||||||
|
pct = (scenario.monthly_total - baseline.monthly_total) / baseline.monthly_total * Decimal("100")
|
||||||
|
scenario.delta_pct_vs_baseline = Decimal(str(pct)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
|
||||||
|
return spec, scenarios
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Build a comparison deck from multiple BOM specs.")
|
||||||
|
parser.add_argument("--spec", required=True, help="YAML deck spec path")
|
||||||
|
parser.add_argument("--output", required=True, help="Output .pptx path")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
spec_path = _resolve_path(args.spec)
|
||||||
|
output_path = _resolve_path(args.output)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
spec, scenarios = load_deck_inputs(spec_path)
|
||||||
|
deck = BOMComparisonDeck(spec, scenarios)
|
||||||
|
deck.build()
|
||||||
|
deck.save(str(output_path))
|
||||||
|
print(f"Deck saved: {output_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
437
tools/build_meli_fraud_from_adbsplusadbd.py
Normal file
437
tools/build_meli_fraud_from_adbsplusadbd.py
Normal 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()
|
||||||
238
tools/build_meli_fraud_from_crossregion.py
Normal file
238
tools/build_meli_fraud_from_crossregion.py
Normal 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()
|
||||||
283
tools/build_meli_fraud_from_templates.py
Normal file
283
tools/build_meli_fraud_from_templates.py
Normal 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()
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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}">
|
||||||
|
|||||||
Reference in New Issue
Block a user