forked from diegoecab/oci-deal-accelerator
Diagram generation: ref-arch-driven procedure + spec validator + KB enrichment
The diagram path now follows a documented standard procedure (lookup the closest Oracle Architecture Center reference → confirm components → author absolute_layout → spec validator → render → visually verify) and ships persistent guardrails so layout regressions can't recur. Persistent procedure changes (apply to all users, all sessions): - tools/diagram_spec_validator.py — geometry checks (CONTAINER_TOO_THIN, CONTAINER_PADDING_VIOLATION, LABEL_OVERFLOW_PARENT) run BEFORE either renderer (drawio + PPTX). Catches the subnet-collapse / label-overflow bugs that the post-render drawio validator missed. - tools/oci_diagram_gen.py + tools/oci_pptx_diagram_gen.py — call the spec validator before emitting any output. Adds mysql / mysql_heatwave type aliases. - tools/archcenter_pattern_lookup.py — scores against cached page descriptions (not just the 1-line summary), supports --queries for multi-fragment composition, and applies synonym expansion via kb/architecture-center/synonyms.yaml so "LB HA cross AD" matches "load balancer high availability availability domain". - kb/architecture-center/synonyms.yaml — canonical synonym table (load balancer, autonomous database, data guard, …) used by the lookup scorer. KB enrichment: - tools/archcenter_description_fetcher.py + 121 cached _description.md under kb/diagram/assets/archcenter-refs/<slug>/. Removes the runtime dependency on docs.oracle.com when authoring specs and feeds the pattern-lookup scorer. - 110+ cached .drawio / .svg / .png references for offline reuse, plus the OCI Toolkit v24.2 import (kb/diagram/assets/oci-toolkit-drawio). Documentation: - docs/skill/output-formats.md — new "Standard diagram-generation procedure (MANDATORY)" + geometry rules + the new validator entry. - SKILL.md option 2 — references the mandatory procedure. - README.md — describes the spec validator, archcenter_pattern_lookup and description fetcher, and updates the KB-health table. Tooling that backs the procedure (cumulative across recent sessions): tools/archcenter_case_runner.py, archcenter_batch_driver.py, archcenter_zip_downloader.py, drawio_visual_validator.py, drawio_fidelity_eval.py, harvest_drawio_icon.py, import_oci_library.py, oci_pptx_diagram_gen.py, oci_pptx_render.py, refresh_pptx_icon_index.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,10 @@ from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
|
||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
try:
|
||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
except ModuleNotFoundError:
|
||||
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -625,6 +628,7 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
self.project = project
|
||||
self.architect = architect
|
||||
self.firm = firm
|
||||
self._native_pptx_jobs = []
|
||||
|
||||
# ---- Architecture visual helpers ----
|
||||
|
||||
@@ -702,10 +706,11 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
self._set_placeholder(slide, 34, info)
|
||||
|
||||
def add_summary_slide(self, why: str, current_state: list,
|
||||
target_state: str, timeline: str):
|
||||
target_state: str, timeline: str,
|
||||
title: str = "Engagement Summary"):
|
||||
"""Slide 2: Engagement Summary."""
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, "Engagement Summary", margin=self.MARGIN)
|
||||
self._add_title_bar(slide, title, margin=self.MARGIN)
|
||||
|
||||
y = Inches(1.2)
|
||||
|
||||
@@ -753,16 +758,39 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
|
||||
def add_architecture_slide(self, diagram_path: Optional[str] = None,
|
||||
description: str = "",
|
||||
visual: Optional[dict] = None):
|
||||
visual: Optional[dict] = None,
|
||||
title: str = "Architecture Overview",
|
||||
footer: str = ""):
|
||||
"""Slide 3: Architecture Overview with diagram or visual layout.
|
||||
|
||||
visual: optional dict with structured architecture data for rendering
|
||||
as colored blocks. Keys: regions, on_prem, security_footer.
|
||||
"""
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, "Architecture Overview", margin=self.MARGIN)
|
||||
self._add_title_bar(slide, title, margin=self.MARGIN)
|
||||
native_requested = self._is_native_pptx_visual(visual)
|
||||
content_top = Inches(1.1)
|
||||
|
||||
if diagram_path:
|
||||
if native_requested:
|
||||
if description:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, Inches(1.1),
|
||||
Inches(12), Inches(0.4),
|
||||
text=description, font_size=11, italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
content_top = Inches(1.55)
|
||||
self._native_pptx_jobs.append({
|
||||
"slide_number": len(self.prs.slides),
|
||||
"visual": visual,
|
||||
"content_box": {
|
||||
"x": int(Inches(0.5)),
|
||||
"y": int(content_top),
|
||||
"cx": int(Inches(12.3)),
|
||||
"cy": int(Inches(5.2 if description else 5.65)),
|
||||
},
|
||||
})
|
||||
elif diagram_path:
|
||||
try:
|
||||
slide.shapes.add_picture(
|
||||
diagram_path,
|
||||
@@ -777,6 +805,13 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
font_size=14, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
elif visual:
|
||||
if description:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, Inches(1.1),
|
||||
Inches(12), Inches(0.4),
|
||||
text=description, font_size=11, italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
self._render_architecture_visual(slide, visual)
|
||||
else:
|
||||
self._add_textbox(
|
||||
@@ -787,6 +822,435 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
if footer:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, Inches(6.85),
|
||||
Inches(12.1), Inches(0.3),
|
||||
text=footer, font_size=9, italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_native_pptx_visual(visual: Optional[dict]) -> bool:
|
||||
if not isinstance(visual, dict):
|
||||
return False
|
||||
if visual.get("native_pptx") is True:
|
||||
return True
|
||||
return visual.get("render_mode") == "native_pptx"
|
||||
|
||||
def save(self, filepath: str, validate: bool = True) -> dict:
|
||||
super().save(filepath)
|
||||
if self._native_pptx_jobs:
|
||||
try:
|
||||
from oci_pptx_diagram_gen import NativePPTXDiagramRenderer
|
||||
except ModuleNotFoundError:
|
||||
from tools.oci_pptx_diagram_gen import NativePPTXDiagramRenderer
|
||||
renderer = NativePPTXDiagramRenderer()
|
||||
renderer.apply_jobs(filepath, self._native_pptx_jobs)
|
||||
|
||||
report: dict = {"path": filepath}
|
||||
if validate:
|
||||
report["pptx"] = self._validate_pptx(filepath)
|
||||
return report
|
||||
|
||||
@staticmethod
|
||||
def _validate_pptx(filepath: str) -> dict:
|
||||
"""Pre-delivery sanity check on the generated .pptx.
|
||||
|
||||
Catches the bugs that historically made PowerPoint prompt to
|
||||
"repair" the file:
|
||||
- Missing Content_Types Default for any embedded media extension.
|
||||
- Slide rels pointing to absent media.
|
||||
- Duplicate cNvPr ids.
|
||||
|
||||
Always non-fatal; prints diagnostics to stderr.
|
||||
"""
|
||||
import sys as _sys
|
||||
import zipfile as _zf
|
||||
from posixpath import normpath as _np
|
||||
try:
|
||||
from lxml import etree as _et
|
||||
except Exception as exc:
|
||||
print(f"[pptx-validator] WARN: lxml not available ({exc})", file=_sys.stderr)
|
||||
return {"status": "skipped", "error": str(exc)}
|
||||
try:
|
||||
with _zf.ZipFile(filepath) as z:
|
||||
names = z.namelist()
|
||||
CT = "http://schemas.openxmlformats.org/package/2006/content-types"
|
||||
PKG = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
ct_root = _et.fromstring(z.read("[Content_Types].xml"))
|
||||
defaults = {d.get("Extension","").lower(): d.get("ContentType")
|
||||
for d in ct_root.findall(f"{{{CT}}}Default")}
|
||||
missing_default = []
|
||||
for n in names:
|
||||
if not n.startswith("ppt/media/"):
|
||||
continue
|
||||
ext = n.rsplit(".", 1)[-1].lower()
|
||||
if ext and ext not in defaults:
|
||||
missing_default.append(ext)
|
||||
missing_default = sorted(set(missing_default))
|
||||
broken_rels: list[str] = []
|
||||
for n in names:
|
||||
if "slides/_rels" not in n or not n.endswith(".rels"):
|
||||
continue
|
||||
slide_dir = "/".join(n.split("/")[:-2])
|
||||
rels = _et.fromstring(z.read(n))
|
||||
for rel in rels.findall(f"{{{PKG}}}Relationship"):
|
||||
tgt = rel.get("Target") or ""
|
||||
if tgt.startswith("http"):
|
||||
continue
|
||||
resolved = _np(f"{slide_dir}/{tgt}") if not tgt.startswith("/") else tgt.lstrip("/")
|
||||
if resolved not in names:
|
||||
broken_rels.append(f"{n}: rId {rel.get('Id')} → {resolved}")
|
||||
dup_ids = []
|
||||
for n in names:
|
||||
if not (n.startswith("ppt/slides/slide") and n.endswith(".xml")):
|
||||
continue
|
||||
root = _et.fromstring(z.read(n))
|
||||
seen = {}
|
||||
for cn in root.findall(f".//{{{P_NS}}}cNvPr"):
|
||||
cid = cn.get("id") or ""
|
||||
if cid:
|
||||
seen[cid] = seen.get(cid, 0) + 1
|
||||
dup_ids.extend(f"{n}:{c}" for c, k in seen.items() if k > 1)
|
||||
issues = []
|
||||
if missing_default:
|
||||
issues.append(("MISSING_CONTENT_TYPE",
|
||||
f"Content_Types.xml missing Default for: {missing_default}. "
|
||||
"PowerPoint will prompt to 'repair' the file."))
|
||||
if broken_rels:
|
||||
issues.append(("BROKEN_REL", f"{len(broken_rels)} broken relationship target(s)"))
|
||||
if dup_ids:
|
||||
issues.append(("DUP_CNVPR", f"{len(dup_ids)} duplicate cNvPr ids"))
|
||||
status = "fail" if issues else "pass"
|
||||
if issues:
|
||||
print(f"[pptx-validator] FAIL on {filepath}: {len(issues)} issue(s)", file=_sys.stderr)
|
||||
for code, msg in issues:
|
||||
print(f" - {code}: {msg}", file=_sys.stderr)
|
||||
else:
|
||||
print(f"[pptx-validator] OK on {filepath}", file=_sys.stderr)
|
||||
return {"status": status, "issues": [{"code": c, "message": m} for c, m in issues]}
|
||||
except Exception as exc:
|
||||
print(f"[pptx-validator] WARN: could not validate ({exc})", file=_sys.stderr)
|
||||
return {"status": "error", "error": str(exc)}
|
||||
|
||||
def add_bullets_slide(self, title: str, sections: list,
|
||||
subtitle: str = "", footer: str = ""):
|
||||
"""Generic sectioned bullets slide for executive content."""
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, title, margin=self.MARGIN)
|
||||
|
||||
y = Inches(1.12)
|
||||
if subtitle:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, y,
|
||||
Inches(12.1), Inches(0.35),
|
||||
text=subtitle, font_size=11, italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
y += Inches(0.42)
|
||||
|
||||
for section in sections:
|
||||
heading = section.get("heading", "")
|
||||
body = section.get("body", "")
|
||||
bullets = coerce_list(section.get("bullets"))
|
||||
|
||||
if heading:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, y,
|
||||
Inches(12.1), Inches(0.32),
|
||||
text=heading, font_size=13, bold=True,
|
||||
color=Colors.TEAL,
|
||||
)
|
||||
y += Inches(0.34)
|
||||
|
||||
if body:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN + Inches(0.05), y,
|
||||
Inches(11.9), Inches(0.42),
|
||||
text=body, font_size=11,
|
||||
)
|
||||
y += Inches(0.42)
|
||||
|
||||
for bullet in bullets:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.7), y,
|
||||
Inches(11.2), Inches(0.32),
|
||||
text=f"• {bullet}", font_size=11,
|
||||
)
|
||||
y += Inches(0.31)
|
||||
|
||||
y += Inches(0.12)
|
||||
if y > Inches(6.35):
|
||||
break
|
||||
|
||||
if footer:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, Inches(6.82),
|
||||
Inches(12.1), Inches(0.28),
|
||||
text=footer, font_size=9, italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_table_slide(self, title: str, columns: list, rows: list,
|
||||
subtitle: str = "", footer: str = ""):
|
||||
"""Generic table slide.
|
||||
|
||||
columns: list of {"header": str, "key": str, "width": float}
|
||||
rows: list of dicts or lists
|
||||
"""
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, title, margin=self.MARGIN)
|
||||
|
||||
y = Inches(1.12)
|
||||
if subtitle:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, y,
|
||||
Inches(12.1), Inches(0.34),
|
||||
text=subtitle, font_size=11, italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
y += Inches(0.4)
|
||||
|
||||
col_defs = []
|
||||
for idx, column in enumerate(columns):
|
||||
if isinstance(column, dict):
|
||||
col_defs.append({
|
||||
"header": column.get("header", column.get("key", f"Column {idx + 1}")),
|
||||
"key": column.get("key", f"col_{idx}"),
|
||||
"width": column.get("width"),
|
||||
})
|
||||
else:
|
||||
col_defs.append({
|
||||
"header": str(column),
|
||||
"key": f"col_{idx}",
|
||||
"width": None,
|
||||
})
|
||||
|
||||
num_rows = len(rows) + 1
|
||||
table_height = min(5.4, 0.42 * max(num_rows, 2))
|
||||
table = self._add_table(
|
||||
slide, num_rows, len(col_defs),
|
||||
self.MARGIN, y,
|
||||
Inches(12.1), Inches(table_height),
|
||||
)
|
||||
|
||||
explicit_widths = [c.get("width") for c in col_defs]
|
||||
if all(w is None for w in explicit_widths):
|
||||
even_width = 12.1 / max(len(col_defs), 1)
|
||||
explicit_widths = [even_width] * len(col_defs)
|
||||
else:
|
||||
remaining = 12.1 - sum(w for w in explicit_widths if w is not None)
|
||||
auto_count = sum(1 for w in explicit_widths if w is None)
|
||||
fallback = remaining / auto_count if auto_count else 0
|
||||
explicit_widths = [w if w is not None else fallback for w in explicit_widths]
|
||||
|
||||
for idx, width in enumerate(explicit_widths):
|
||||
table.columns[idx].width = Inches(width)
|
||||
|
||||
for idx, column in enumerate(col_defs):
|
||||
self._style_table_cell(
|
||||
table.cell(0, idx), column["header"],
|
||||
font_size=10, bold=True,
|
||||
color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
for row_idx, row in enumerate(rows, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||
for col_idx, column in enumerate(col_defs):
|
||||
if isinstance(row, dict):
|
||||
value = row.get(column["key"], "")
|
||||
elif isinstance(row, list):
|
||||
value = row[col_idx] if col_idx < len(row) else ""
|
||||
else:
|
||||
value = str(row)
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, col_idx), str(value),
|
||||
font_size=9, bg_color=bg,
|
||||
)
|
||||
|
||||
if footer:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, Inches(6.82),
|
||||
Inches(12.1), Inches(0.28),
|
||||
text=footer, font_size=9, italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_flow_architecture_slide(self, title: str, columns: list,
|
||||
connections: list = None,
|
||||
subtitle: str = "", notes: list = None,
|
||||
footer: str = ""):
|
||||
"""Executive architecture slide with columns, nodes, and flow arrows."""
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, title, margin=self.MARGIN)
|
||||
|
||||
y_top = Inches(1.1)
|
||||
if subtitle:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, y_top,
|
||||
Inches(12.1), Inches(0.32),
|
||||
text=subtitle, font_size=11, italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
y_top += Inches(0.36)
|
||||
|
||||
notes = coerce_list(notes)
|
||||
notes_h = Inches(0.9 + 0.2 * min(len(notes), 4)) if notes else Inches(0)
|
||||
columns_top = y_top
|
||||
columns_height = Inches(5.55) - notes_h
|
||||
columns_left = self.MARGIN
|
||||
total_width = Inches(12.1)
|
||||
spacing = Inches(0.12)
|
||||
col_width = (total_width - spacing * max(len(columns) - 1, 0)) / max(len(columns), 1)
|
||||
|
||||
tone_fill = {
|
||||
"primary": RGBColor(0xF5, 0xF4, 0xF2),
|
||||
"secondary": RGBColor(0xFC, 0xFB, 0xFA),
|
||||
"standby": RGBColor(0xF1, 0xEF, 0xEC),
|
||||
"bridge": RGBColor(0xF8, 0xF5, 0xF2),
|
||||
}
|
||||
tone_border = {
|
||||
"primary": Colors.TEAL,
|
||||
"secondary": Colors.BURNT_ORANGE,
|
||||
"standby": Colors.SECONDARY_TEXT,
|
||||
"bridge": Colors.PURPLE,
|
||||
}
|
||||
node_colors = {
|
||||
"database": Colors.COPPER,
|
||||
"bridge": Colors.PURPLE,
|
||||
"integration": Colors.PURPLE,
|
||||
"service": Colors.TEAL,
|
||||
"standby": RGBColor(0x70, 0x66, 0x5E),
|
||||
"external": Colors.SECONDARY_TEXT,
|
||||
}
|
||||
|
||||
node_centers = {}
|
||||
ratios = [max(float(column.get("width_ratio", 1.0)), 0.4) for column in columns]
|
||||
ratio_total = sum(ratios) or 1.0
|
||||
left = columns_left
|
||||
|
||||
for idx, column in enumerate(columns):
|
||||
col_width = (
|
||||
(total_width - spacing * max(len(columns) - 1, 0))
|
||||
* (ratios[idx] / ratio_total)
|
||||
)
|
||||
tone = column.get("tone", "secondary")
|
||||
border = tone_border.get(tone, Colors.TEAL)
|
||||
fill = tone_fill.get(tone, RGBColor(0xF5, 0xF4, 0xF2))
|
||||
label = column.get("title", f"Column {idx + 1}")
|
||||
subtitle_text = column.get("subtitle", "")
|
||||
full_label = label if not subtitle_text else f"{label} | {subtitle_text}"
|
||||
self._add_container(
|
||||
slide, left, columns_top, col_width, columns_height,
|
||||
label=full_label, border_color=border,
|
||||
fill_color=fill, dashed=False, label_size=10,
|
||||
)
|
||||
|
||||
nodes = coerce_list(column.get("nodes"))
|
||||
node_gap = Inches(0.12)
|
||||
node_left = left + Inches(0.15)
|
||||
node_width = col_width - Inches(0.3)
|
||||
node_y = columns_top + Inches(0.45)
|
||||
|
||||
for node in nodes:
|
||||
node_id = node.get("id") or f"node_{idx}_{len(node_centers)}"
|
||||
node_kind = node.get("kind", "service")
|
||||
node_color = node_colors.get(node_kind, Colors.TEAL)
|
||||
label = node.get("label", "")
|
||||
label_lines = max(1, str(label).count("\n") + 1)
|
||||
node_h = Inches(max(0.62, 0.17 * label_lines + 0.18))
|
||||
|
||||
card = self._add_rect(
|
||||
slide, node_left, node_y, node_width, node_h,
|
||||
fill_color=Colors.WHITE, border_color=node_color,
|
||||
)
|
||||
card.line.width = Pt(1.6)
|
||||
if node_kind == "standby":
|
||||
card.line.dash_style = 2
|
||||
|
||||
accent = self._add_rect(
|
||||
slide, node_left, node_y, Inches(0.08), node_h,
|
||||
fill_color=node_color, border_color=node_color,
|
||||
)
|
||||
accent.line.fill.background()
|
||||
|
||||
text_box = self._add_textbox(
|
||||
slide, node_left + Inches(0.16), node_y + Inches(0.08),
|
||||
node_width - Inches(0.24), node_h - Inches(0.12),
|
||||
text=label, font_size=10,
|
||||
color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
if text_box.text_frame.paragraphs:
|
||||
text_box.text_frame.paragraphs[0].font.bold = True
|
||||
|
||||
node_centers[node_id] = (
|
||||
node_left + node_width / 2,
|
||||
node_y + node_h / 2,
|
||||
node_left,
|
||||
node_y,
|
||||
node_width,
|
||||
node_h,
|
||||
)
|
||||
node_y += node_h + node_gap
|
||||
if node_y + node_h > columns_top + columns_height - Inches(0.2):
|
||||
break
|
||||
|
||||
left += col_width + spacing
|
||||
|
||||
for conn in coerce_list(connections):
|
||||
source = node_centers.get(conn.get("from"))
|
||||
target = node_centers.get(conn.get("to"))
|
||||
if not source or not target:
|
||||
continue
|
||||
sx = source[2] + source[4]
|
||||
sy = source[1]
|
||||
tx = target[2]
|
||||
ty = target[1]
|
||||
dashed = str(conn.get("style", "")).lower() in {"dashed", "bridge", "replication"}
|
||||
color = Colors.PURPLE if dashed else Colors.TEAL
|
||||
self._add_arrow(slide, sx, sy, tx, ty, color=color, dashed=dashed)
|
||||
label = conn.get("label", "")
|
||||
if label:
|
||||
label_x = min(max((sx + tx) / 2 - Inches(0.9), self.MARGIN), Inches(11.3))
|
||||
label_y = min(sy, ty) - Inches(0.14)
|
||||
self._add_textbox(
|
||||
slide, label_x, label_y,
|
||||
Inches(1.8), Inches(0.24),
|
||||
text=label, font_size=8,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
if notes:
|
||||
note_top = columns_top + columns_height + Inches(0.14)
|
||||
self._add_container(
|
||||
slide, self.MARGIN, note_top,
|
||||
Inches(12.1), notes_h,
|
||||
label="Notes", border_color=Colors.BURNT_ORANGE,
|
||||
fill_color=RGBColor(0xFC, 0xFB, 0xFA),
|
||||
dashed=False, label_size=10,
|
||||
)
|
||||
note_y = note_top + Inches(0.22)
|
||||
for note in notes[:4]:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.75), note_y,
|
||||
Inches(11.2), Inches(0.22),
|
||||
text=f"• {note}", font_size=9,
|
||||
)
|
||||
note_y += Inches(0.2)
|
||||
|
||||
if footer:
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, Inches(6.85),
|
||||
Inches(12.1), Inches(0.25),
|
||||
text=footer, font_size=9, italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def _render_architecture_visual(self, slide, visual: dict):
|
||||
"""Render a visual architecture diagram from structured data."""
|
||||
regions = visual.get("regions", [])
|
||||
@@ -1470,14 +1934,15 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
)
|
||||
|
||||
def add_migration_slide(self, phases: list, tools: list = None,
|
||||
downtime: str = ""):
|
||||
downtime: str = "",
|
||||
title: str = "Migration Approach"):
|
||||
"""Migration Approach slide.
|
||||
|
||||
phases: list of {"name": str, "duration"|"weeks": str,
|
||||
"milestones"|"tasks": [...]}
|
||||
"""
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, "Migration Approach", margin=self.MARGIN)
|
||||
self._add_title_bar(slide, title, margin=self.MARGIN)
|
||||
|
||||
phase_colors = [Colors.TEAL, Colors.BURNT_ORANGE, Colors.FOREST, Colors.PURPLE]
|
||||
|
||||
@@ -1549,28 +2014,47 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
)
|
||||
|
||||
def add_operational_raci_slide(self, raci_items: list,
|
||||
model: str = "co_managed"):
|
||||
model: str = "co_managed",
|
||||
customer_label: str = "Customer",
|
||||
oracle_label: str = "Oracle / Partner",
|
||||
owner_order: str = "customer_first",
|
||||
title: str | None = None):
|
||||
"""Operational RACI slide — responsibility matrix.
|
||||
|
||||
raci_items: list of {"activity": str, "customer": str, "oracle": str}
|
||||
raci_items: list of {"activity": str, "customer": str, "oracle": str, "notes": str}
|
||||
model: "fully_managed", "co_managed", or "self_managed"
|
||||
"""
|
||||
slide = self._add_blank_slide()
|
||||
model_label = model.replace("_", "-").title()
|
||||
self._add_title_bar(slide, f"Operational Responsibilities ({model_label})", margin=self.MARGIN)
|
||||
self._add_title_bar(slide, title or f"Operational Responsibilities ({model_label})", margin=self.MARGIN)
|
||||
|
||||
include_notes = any(str(item.get("notes", "")).strip() for item in raci_items)
|
||||
headers = ["Activity"]
|
||||
value_keys = ["activity"]
|
||||
if owner_order == "oracle_first":
|
||||
headers.extend([oracle_label, customer_label])
|
||||
value_keys.extend(["oracle", "customer"])
|
||||
else:
|
||||
headers.extend([customer_label, oracle_label])
|
||||
value_keys.extend(["customer", "oracle"])
|
||||
if include_notes:
|
||||
headers.append("Notes")
|
||||
value_keys.append("notes")
|
||||
|
||||
rows = len(raci_items) + 1
|
||||
table = self._add_table(
|
||||
slide, rows, 3,
|
||||
slide, rows, len(headers),
|
||||
self.MARGIN, Inches(1.2),
|
||||
Inches(10), Inches(0.38 * rows),
|
||||
Inches(12.1), Inches(0.38 * rows),
|
||||
)
|
||||
|
||||
table.columns[0].width = Inches(5.0)
|
||||
table.columns[1].width = Inches(2.5)
|
||||
table.columns[2].width = Inches(2.5)
|
||||
if include_notes:
|
||||
widths = [4.4, 1.4, 1.4, 4.9]
|
||||
else:
|
||||
widths = [5.0, 2.5, 2.5]
|
||||
for idx, width in enumerate(widths[:len(headers)]):
|
||||
table.columns[idx].width = Inches(width)
|
||||
|
||||
headers = ["Activity", "Customer", "Oracle / Partner"]
|
||||
for j, h in enumerate(headers):
|
||||
self._style_table_cell(
|
||||
table.cell(0, j), h, font_size=11, bold=True,
|
||||
@@ -1581,9 +2065,13 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
for i, item in enumerate(raci_items):
|
||||
row_idx = i + 1
|
||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||
self._style_table_cell(table.cell(row_idx, 0), item.get("activity", ""), font_size=10, bg_color=bg)
|
||||
self._style_table_cell(table.cell(row_idx, 1), item.get("customer", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||
self._style_table_cell(table.cell(row_idx, 2), item.get("oracle", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||
for col_idx, key in enumerate(value_keys):
|
||||
align = PP_ALIGN.CENTER if key in {"customer", "oracle"} else PP_ALIGN.LEFT
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, col_idx), item.get(key, ""),
|
||||
font_size=9 if key == "notes" else 10,
|
||||
bg_color=bg, alignment=align,
|
||||
)
|
||||
|
||||
# Legend
|
||||
legend_y = Inches(1.2) + Inches(0.38 * rows) + Inches(0.2)
|
||||
@@ -1806,8 +2294,80 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
current_state=coerce_list(s.get("current_state")),
|
||||
target_state=pick(s, "target_state"),
|
||||
timeline=pick(s, "timeline"),
|
||||
title=pick(s, "title", default="Engagement Summary"),
|
||||
)
|
||||
|
||||
render_standard_sections = spec.get("render_standard_sections", True)
|
||||
|
||||
def render_custom_slide(item: dict):
|
||||
slide_type = item.get("type")
|
||||
if slide_type == "bullets":
|
||||
gen.add_bullets_slide(
|
||||
title=item.get("title", "Executive Summary"),
|
||||
sections=pick_list(item, "sections"),
|
||||
subtitle=item.get("subtitle", ""),
|
||||
footer=item.get("footer", ""),
|
||||
)
|
||||
elif slide_type == "table":
|
||||
gen.add_table_slide(
|
||||
title=item.get("title", "Table"),
|
||||
columns=pick_list(item, "columns"),
|
||||
rows=pick_list(item, "rows"),
|
||||
subtitle=item.get("subtitle", ""),
|
||||
footer=item.get("footer", ""),
|
||||
)
|
||||
elif slide_type == "flow_architecture":
|
||||
gen.add_flow_architecture_slide(
|
||||
title=item.get("title", "Architecture"),
|
||||
columns=pick_list(item, "columns"),
|
||||
connections=pick_list(item, "connections"),
|
||||
subtitle=item.get("subtitle", ""),
|
||||
notes=pick_list(item, "notes"),
|
||||
footer=item.get("footer", ""),
|
||||
)
|
||||
elif slide_type == "migration":
|
||||
gen.add_migration_slide(
|
||||
phases=pick_list(item, "phases"),
|
||||
tools=pick_list(item, "tools"),
|
||||
downtime=item.get("downtime", ""),
|
||||
title=item.get("title", "Migration Approach"),
|
||||
)
|
||||
elif slide_type == "environment_catalogue":
|
||||
gen.add_environment_catalogue_slide(
|
||||
environments=pick_list(item, "environments"),
|
||||
cost_notes=item.get("cost_notes"),
|
||||
)
|
||||
elif slide_type == "raci":
|
||||
labels = item.get("labels", {}) if isinstance(item.get("labels"), dict) else {}
|
||||
gen.add_operational_raci_slide(
|
||||
raci_items=pick_list(item, "raci_items", "items", "activities"),
|
||||
model=item.get("model", "co_managed"),
|
||||
customer_label=labels.get("customer", "Customer"),
|
||||
oracle_label=labels.get("oracle", "Oracle / Partner"),
|
||||
owner_order=item.get("owner_order", "customer_first"),
|
||||
title=item.get("title"),
|
||||
)
|
||||
elif slide_type == "diagram":
|
||||
gen.add_architecture_slide(
|
||||
diagram_path=item.get("diagram_path"),
|
||||
description=item.get("description", ""),
|
||||
visual=item.get("visual"),
|
||||
title=item.get("title", "Architecture Overview"),
|
||||
footer=item.get("footer", ""),
|
||||
)
|
||||
|
||||
if not render_standard_sections:
|
||||
for custom_slide in pick_list(spec, "custom_slides"):
|
||||
if isinstance(custom_slide, dict):
|
||||
render_custom_slide(custom_slide)
|
||||
if "next_steps" in spec:
|
||||
ns = spec["next_steps"]
|
||||
gen.add_next_steps_slide(
|
||||
steps=pick_list(ns, "steps"),
|
||||
contact_info=pick(ns, "contact_info"),
|
||||
)
|
||||
return gen
|
||||
|
||||
# Slide 3: Service Tiering (ECAL)
|
||||
if "service_tiering" in spec:
|
||||
gen.add_service_tiering_slide(spec["service_tiering"])
|
||||
@@ -1850,12 +2410,23 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
if dr_region.lower() not in names:
|
||||
regions.append({"name": dr_region, "primary": False, "label": "DR STANDBY"})
|
||||
visual["regions"] = regions
|
||||
# Single-region fallback: if we know the primary region but no
|
||||
# visual or diagram was supplied, render one big PRIMARY block
|
||||
# so the slide isn't just a lonely description line.
|
||||
if not visual and not a.get("diagram_path") and primary_region:
|
||||
visual = {
|
||||
"regions": [
|
||||
{"name": primary_region, "primary": True, "label": "PRIMARY"},
|
||||
],
|
||||
}
|
||||
if dr_region and description and dr_region.lower() not in description.lower():
|
||||
description = f"{description} • DR region: {dr_region}"
|
||||
gen.add_architecture_slide(
|
||||
diagram_path=a.get("diagram_path"),
|
||||
description=description,
|
||||
visual=visual,
|
||||
title=pick(a, "title", default="Architecture Overview"),
|
||||
footer=a.get("footer", ""),
|
||||
)
|
||||
|
||||
# Slide 6: Decisions
|
||||
@@ -1911,6 +2482,7 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
phases=pick_list(m, "phases"),
|
||||
tools=pick_list(m, "tools"),
|
||||
downtime=pick(m, "downtime"),
|
||||
title=pick(m, "title", default="Migration Approach"),
|
||||
)
|
||||
|
||||
# Slide 13: Operational RACI (ECAL)
|
||||
@@ -1924,8 +2496,14 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
raci_items = []
|
||||
if isinstance(raci_payload, str):
|
||||
model = raci_payload
|
||||
raci_labels = {}
|
||||
owner_order = "customer_first"
|
||||
title = None
|
||||
elif isinstance(raci_payload, list):
|
||||
raci_items = [r for r in raci_payload if isinstance(r, dict)]
|
||||
raci_labels = {}
|
||||
owner_order = "customer_first"
|
||||
title = None
|
||||
elif isinstance(raci_payload, dict):
|
||||
model = raci_payload.get("model") or raci_payload.get("engagement_model") or "co_managed"
|
||||
items = (
|
||||
@@ -1935,13 +2513,24 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
or []
|
||||
)
|
||||
raci_items = [r for r in items if isinstance(r, dict)]
|
||||
raci_labels = raci_payload.get("labels") if isinstance(raci_payload.get("labels"), dict) else {}
|
||||
owner_order = raci_payload.get("owner_order", "customer_first")
|
||||
title = raci_payload.get("title")
|
||||
if not raci_items:
|
||||
raci_items = _default_operational_raci(model)
|
||||
gen.add_operational_raci_slide(
|
||||
raci_items=raci_items,
|
||||
model=model,
|
||||
customer_label=raci_labels.get("customer", "Customer"),
|
||||
oracle_label=raci_labels.get("oracle", "Oracle / Partner"),
|
||||
owner_order=owner_order,
|
||||
title=title,
|
||||
)
|
||||
|
||||
for custom_slide in pick_list(spec, "custom_slides"):
|
||||
if isinstance(custom_slide, dict):
|
||||
render_custom_slide(custom_slide)
|
||||
|
||||
# Slide 14: Risks
|
||||
if "risks" in spec:
|
||||
gen.add_risk_slide(spec["risks"])
|
||||
|
||||
Reference in New Issue
Block a user