# P4: Presentation Deck Generator Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** From a Solution Brief, produce an editable Google Slides deck customized with the prospect's logo and accent colors, containing only the selected solution sections. **Architecture:** A master deck (Linx plus Anritsu) carries placeholder tokens. A generator copies the master, deletes unselected section slides, rebuilds the agenda, replaces logo and text tokens, recolors the brand-bearing shapes to the prospect accent, strips the editing rails, and exports a PDF. Output is an editable Slides file shared with the owner. **Tech Stack:** Python 3, Google Drive API (`files.copy`), Google Slides API (`presentations.get`, `presentations.batchUpdate`), Pillow (logo palette), requests (website palette), n8n (form intake). **Depends on:** P1 (routing and `deal_map`). **Spec:** `../specs/2026-09-24-sales-pipeline-hermes-design.md` (SP4, section 5.4, decision D11). --- ## Prerequisites - A Google Cloud service account with the Drive and Slides APIs enabled. - The **canonical master deck** designated by the client, shared with the service account email (Editor). - Service-account JSON at a secret path; export `GOOGLE_APPLICATION_CREDENTIALS` and `MASTER_DECK_ID`. - `python3` with `google-api-python-client`, `google-auth`, `pillow`, `requests`, `pyyaml`. --- ## File Structure | Path | Responsibility | |---|---| | `deck/deck_map.yaml` | Slide index map and section selection rules | | `deck/select_slides.py` | Compute slides to keep or delete | | `deck/brand_extract.py` | Prospect palette from logo or website | | `deck/generate_deck.py` | Drive copy plus Slides batchUpdate plus PDF export | | `deck/verify_master.py` | Assert the master carries the required placeholders | | `deck/test_*.py` | Tests | | `n8n/pipeline-solution-brief.json` | Form submit to deck generation | --- ## Task 1: Slide map and selection engine **Files:** - Create: `deck/deck_map.yaml` - Create: `deck/select_slides.py` - Test: `deck/test_select_slides.py` - [ ] **Step 1: Write the failing test** Create `deck/test_select_slides.py`: ```python import importlib.util import pathlib HERE = pathlib.Path(__file__).parent def load(): spec = importlib.util.spec_from_file_location("ss", HERE / "select_slides.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def test_fixed_slides_always_kept(): mod = load() plan = mod.plan([]) for s in [1, 3, 4, 6, 8, 9, 40, 41]: assert s in plan["keep"], s def test_laser_pulls_linx_intro(): mod = load() plan = mod.plan(["laser"]) assert 5 in plan["keep"] # Linx intro assert set(range(10, 17)).issubset(plan["keep"]) def test_xray_pulls_anritsu_intro_and_divider(): mod = load() plan = mod.plan(["xray"]) assert 7 in plan["keep"] # Anritsu intro assert 29 in plan["keep"] # detection divider assert 30 in plan["keep"] # xray divider assert set(range(31, 33)).issubset(plan["keep"]) def test_unselected_are_deleted(): mod = load() plan = mod.plan(["laser"]) assert 17 not in plan["keep"] and 17 in plan["delete"] assert 31 not in plan["keep"] def test_unknown_solution_raises(): mod = load() try: mod.plan(["teleportation"]) assert False except KeyError: pass ``` - [ ] **Step 2: Run test to verify it fails** Run: `python3 -m pytest deck/test_select_slides.py -v` Expected: FAIL (module missing). - [ ] **Step 3: Create the deck map** Create `deck/deck_map.yaml`: ```yaml # deck_map.yaml — 1-based slide indices of the master deck, and selection rules. version: 1 fixed: [1, 3, 4, 6, 8, 9, 40, 41] agenda: 2 brand_slides: [1, 40, 41] supplier_intros: linx: 5 anritsu: 7 sections: laser: {slides: [10, 11, 12, 13, 14, 15, 16], supplier: linx} inkjet: {slides: [17, 18], supplier: linx} tto: {slides: [19,20,21,22,23,24,25,26,27,28], supplier: linx} xray: {slides: [30, 31, 32], supplier: anritsu, divider: 30} combination: {slides: [33, 34, 35, 36], supplier: anritsu, divider: 33} metal_detector: {slides: [37, 38, 39], supplier: anritsu, divider: 37} detection_divider: 29 # shared divider required by any anritsu detection section rail_text_markers: ["OJO", "IMAGENES A USAR", "AGREGAR FONDO ACORDE"] logo_alt_text: "PROSPECT_LOGO" accent_alt_text: "BRAND_ACCENT" ``` - [ ] **Step 4: Write the selection engine** Create `deck/select_slides.py`: ```python """select_slides.py — decide which master slides to keep or delete.""" import pathlib import yaml HERE = pathlib.Path(__file__).parent ALL_SLIDES = list(range(1, 42)) def _map() -> dict: return yaml.safe_load((HERE / "deck_map.yaml").read_text()) def plan(selected: list) -> dict: m = _map() unknown = [s for s in selected if s not in m["sections"]] if unknown: raise KeyError(unknown) keep = set(m["fixed"]) | {m["agenda"]} suppliers = set() for name in selected: sec = m["sections"][name] keep.update(sec["slides"]) suppliers.add(sec["supplier"]) if "divider" in sec: keep.add(sec["divider"]) if "linx" in suppliers: keep.add(m["supplier_intros"]["linx"]) if "anritsu" in suppliers: keep.add(m["supplier_intros"]["anritsu"]) keep.add(m["detection_divider"]) delete = [s for s in ALL_SLIDES if s not in keep] return {"keep": sorted(keep), "delete": delete} if __name__ == "__main__": import sys print(plan(sys.argv[1:])) ``` - [ ] **Step 5: Run test to verify it passes** Run: `python3 -m pytest deck/test_select_slides.py -v` Expected: 5 passed. - [ ] **Step 6: Commit** ```bash git add deck/deck_map.yaml deck/select_slides.py deck/test_select_slides.py git commit -m "feat(deck): slide map and selection engine" ``` --- ## Task 2: Prospect brand extraction **Files:** - Create: `deck/brand_extract.py` - Test: `deck/test_brand_extract.py` - [ ] **Step 1: Write the failing test** Create `deck/test_brand_extract.py`: ```python import importlib.util import pathlib from PIL import Image HERE = pathlib.Path(__file__).parent def load(): spec = importlib.util.spec_from_file_location("be", HERE / "brand_extract.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def test_dominant_from_logo(tmp_path): mod = load() img = Image.new("RGB", (40, 40), (13, 112, 204)) p = tmp_path / "logo.png" img.save(p) hexes = mod.palette_from_logo(str(p)) assert "#0d70cc" in [h.lower() for h in hexes] def test_returns_at_most_n(tmp_path): mod = load() img = Image.new("RGB", (40, 40), (0, 0, 0)) p = tmp_path / "logo.png" img.save(p) assert len(mod.palette_from_logo(str(p), n=3)) <= 3 ``` - [ ] **Step 2: Run test to verify it fails** Run: `python3 -m pytest deck/test_brand_extract.py -v` Expected: FAIL. - [ ] **Step 3: Write the extractor** Create `deck/brand_extract.py`: ```python """brand_extract.py — prospect palette from a logo image or a website.""" from collections import Counter from PIL import Image import requests import re def _hex(rgb): return "#{:02x}{:02x}{:02x}".format(*rgb[:3]) def palette_from_logo(path: str, n: int = 5) -> list: img = Image.open(path).convert("RGB").resize((80, 80)) counts = Counter(img.getdata()) ordered = [c for c, _ in counts.most_common(64) if not _is_near_grey(c)] out = [] for c in ordered: h = _hex(c) if h not in out: out.append(h) if len(out) == n: break if not out: out = [_hex(counts.most_common(1)[0][0])] return out def _is_near_grey(rgb) -> bool: return max(rgb) - min(rgb) < 16 def palette_from_website(url: str, n: int = 5) -> list: html = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"}).text hexes = re.findall(r"#[0-9a-fA-F]{6}", html) counts = Counter(h.lower() for h in hexes) out = [] for h, _ in counts.most_common(64): if len({h[1:3], h[3:5], h[5:7]}) == 1: continue # skip pure greys if h not in out: out.append(h) if len(out) == n: break return out def best_accent(url: str | None, logo_path: str | None) -> str: candidates = [] if logo_path: candidates += palette_from_logo(logo_path) if url: candidates += palette_from_website(url) for c in candidates: r, g, b = int(c[1:3], 16), int(c[3:5], 16), int(c[5:7], 16) if max(r, g, b) > 40 and (max(r, g, b) - min(r, g, b)) > 30: return c return candidates[0] if candidates else "#0d70cc" ``` - [ ] **Step 4: Run test to verify it passes** Run: `python3 -m pytest deck/test_brand_extract.py -v` Expected: 2 passed (the website test is optional and not required offline). - [ ] **Step 5: Commit** ```bash git add deck/brand_extract.py deck/test_brand_extract.py git commit -m "feat(deck): prospect brand extraction" ``` --- ## Task 3: Master preparation and verification **Files:** - Create: `deck/verify_master.py` - Test: `deck/test_verify_master.py` - [ ] **Step 1: Define the master convention (document in `deck/README.md`)** The master must carry: - an image placeholder whose **alt text is `PROSPECT_LOGO`** (title and thank-you), - shapes whose **alt text is `BRAND_ACCENT`** on the brand-bearing slides (1, 40, 41) that should take the prospect color, - a text token `{{PROSPECT_NAME}}` where the prospect name goes, - the agenda as a single text box that the generator rewrites. - [ ] **Step 2: Write the failing test** Create `deck/test_verify_master.py`: ```python import importlib.util import pathlib HERE = pathlib.Path(__file__).parent def load(): spec = importlib.util.spec_from_file_location("vm", HERE / "verify_master.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def test_missing_placeholders_reported(): mod = load() deck = {"slides": [{"objectId": "s1", "pageElements": [ {"image": {"description": "PROSPECT_LOGO"}}]}]} report = mod.check(deck) assert report["has_logo"] is True assert report["has_accent"] is False assert report["ok"] is False def test_all_present_ok(): mod = load() deck = {"slides": [{"objectId": "s1", "pageElements": [ {"image": {"description": "PROSPECT_LOGO"}}, {"shape": {"description": "BRAND_ACCENT"}}]}]} assert mod.check(deck)["ok"] is True ``` - [ ] **Step 3: Write the verifier** Create `deck/verify_master.py` that walks `deck["slides"][*]["pageElements"]` looking for `image.description == "PROSPECT_LOGO"` and `shape.description == "BRAND_ACCENT"` and returns `{has_logo, has_accent, ok}`. It also fetches the live deck via `presentations.get` when run as a CLI with `MASTER_DECK_ID`. - [ ] **Step 4: Run test to verify it passes** Run: `python3 -m pytest deck/test_verify_master.py -v` Expected: 2 passed. - [ ] **Step 5: Commit** ```bash git add deck/verify_master.py deck/test_verify_master.py deck/README.md git commit -m "feat(deck): master placeholder convention and verifier" ``` --- ## Task 4: Deck generator **Files:** - Create: `deck/generate_deck.py` - Test: `deck/test_generate_deck.py` - [ ] **Step 1: Write the failing test** Create `deck/test_generate_deck.py`: ```python import importlib.util import pathlib from unittest import mock HERE = pathlib.Path(__file__).parent def load(): spec = importlib.util.spec_from_file_location("gd", HERE / "generate_deck.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def test_build_requests_deletes_unselected_and_recolors(): mod = load() plan = {"keep": [1, 40, 41], "delete": [17, 31]} slide_ids = {str(i): f"obj{i}" for i in range(1, 42)} accent_ids = ["obj1", "obj41"] reqs = mod.build_batch_requests(plan, slide_ids, accent_ids, prospect_name="Acme", accent_hex="#ff0000") kinds = [list(r.keys())[0] for r in reqs] assert "deleteObject" in kinds assert "replaceAllText" in kinds assert "updateShapeProperties" in kinds def test_delete_targets_only_marked_slides(): mod = load() plan = {"keep": [1], "delete": [17, 31]} slide_ids = {str(i): f"obj{i}" for i in range(1, 42)} reqs = mod.build_batch_requests(plan, slide_ids, [], "Acme", "#ff0000") deleted = [r["deleteObject"]["objectId"] for r in reqs if "deleteObject" in r] assert set(deleted) == {"obj17", "obj31"} ``` - [ ] **Step 2: Run test to verify it fails** Run: `python3 -m pytest deck/test_generate_deck.py -v` Expected: FAIL. - [ ] **Step 3: Write the generator** Create `deck/generate_deck.py` implementing: ```python def build_batch_requests(plan, slide_ids, accent_ids, prospect_name, accent_hex): reqs = [] for n in plan["delete"]: reqs.append({"deleteObject": {"objectId": slide_ids[str(n)]}}) reqs.append({"replaceAllText": { "containsText": {"text": "{{PROSPECT_NAME}}", "matchCase": True}, "replaceText": prospect_name}}) r = int(accent_hex[1:3], 16); g = int(accent_hex[3:5], 16); b = int(accent_hex[5:7], 16) for oid in accent_ids: reqs.append({"updateShapeProperties": { "objectId": oid, "fields": "shapeProperties.solidFill", "shapeProperties": {"solidFill": {"rgbColor": { "red": r / 255.0, "green": g / 255.0, "blue": b / 255.0}}}}}) return reqs def generate(master_id, solution_brief, credentials_path, out_dir): """Copy master, delete unselected slides, replace logo and name, recolor accents, strip editing rails (delete pageElements whose text matches rail_text_markers), export a PDF, and return {deck_id, deck_url, pdf_path}.""" ``` The `generate()` function uses: - `files.copy` to duplicate the master into the prospect folder, - `presentations.get` to read slide and element objectId maps, - `presentations.batchUpdate` with `build_batch_requests(...)` plus rail deletions from `deck_map.yaml`, - `files.export` (or `files.get(alt="media")`) to produce the PDF. - [ ] **Step 4: Run test to verify it passes** Run: `python3 -m pytest deck/test_generate_deck.py -v` Expected: 2 passed. - [ ] **Step 5: Commit** ```bash git add deck/generate_deck.py deck/test_generate_deck.py git commit -m "feat(deck): deck generator via Drive and Slides APIs" ``` --- ## Task 5: Solution Brief intake and wiring **Files:** - Create: `n8n/pipeline-solution-brief.json` - Test: `n8n/test_pipeline_solution_brief.py` - [ ] **Step 1: Write the failing test** Create `n8n/test_pipeline_solution_brief.py`: ```python import json import pathlib WF = json.loads((pathlib.Path(__file__).parent / "pipeline-solution-brief.json").read_text()) NAMES = [n["name"] for n in WF["nodes"]] def test_has_form_trigger_and_generate_and_email(): assert any("Form" in n for n in NAMES) assert any("Generate" in n for n in NAMES) assert any(n.startswith("Gmail") for n in NAMES) assert any("Attach" in n for n in NAMES) ``` - [ ] **Step 2: Run test to verify it fails** Run: `python3 -m pytest n8n/test_pipeline_solution_brief.py -v` Expected: FAIL. - [ ] **Step 3: Build the workflow** Create `n8n/pipeline-solution-brief.json`: 1. **Form Trigger** `Solution Brief` with fields: prospect name, contact email, logo (file), website (optional), accent color (optional), and solution checkboxes (laser, inkjet, tto, xray, combination, metal_detector), optional case study. 2. **Code** `Normalize Brief` to the `solution_brief` JSON consumed by `generate_deck`. 3. **Execute Command** or **HTTP** `Generate Deck` calling `python3 deck/generate_deck.py --brief ` on the worker host. 4. **Gmail** `Email owner deck` with the deck link and PDF attached, Jovan copied. 5. **HubSpot** `Attach to deal` (note with the deck link) and `Update deal`. - [ ] **Step 4: Validate and run the test** ``` n8n-mcp validate_workflow({ workflow: }) -> errorCount: 0 python3 -m pytest n8n/test_pipeline_solution_brief.py -v ``` Expected: 1 passed. - [ ] **Step 5: Commit** ```bash git add n8n/pipeline-solution-brief.json n8n/test_pipeline_solution_brief.py git commit -m "feat(deck): solution brief intake and generation wiring" ``` --- ## Task 6: Docs and end-to-end verification - [ ] **Step 1:** Document the master convention, service-account setup, and the canonical `MASTER_DECK_ID` in `deck/README.md`. - [ ] **Step 2:** End-to-end: submit a Solution Brief with solutions `[laser, xray]`, confirm the generated deck keeps slides 1 to 9, 10 to 16, 29 to 32, 40 to 41, contains the prospect logo and accent, has no rail text, and that a PDF is emailed and attached. - [ ] **Step 3:** Commit (`git commit -m "docs(deck): master convention and verification"`). --- ## Definition of done - A Solution Brief produces an editable deck with only the selected sections. - Prospect logo and accent colors are applied; editing rails are stripped. - A PDF is exported and attached to the deal; the owner is emailed. - `verify_master.py` passes against the canonical master.