diff --git a/.skillware.yaml.example b/.skillware.yaml.example index 5ca88ba..6541631 100644 --- a/.skillware.yaml.example +++ b/.skillware.yaml.example @@ -102,3 +102,35 @@ legacy: # task_id: host.task_id # current_token_count: host.current_token_count # max_allowed_tokens: host.max_allowed_tokens +# deck_build_pipeline: +# description: Validate, lint, and render a deck_spec from host variables. +# when: Automated pipeline generating and quality-checking presentations. +# steps: +# - id: validate +# skill: creative/deck_builder +# params: +# action: validate_spec +# strict: true +# input_from: +# deck_spec: host.deck_spec +# - id: lint +# skill: creative/deck_builder +# when: +# prior_step: validate +# field: valid +# equals: true +# params: +# action: lint_deck +# min_score: 70 +# input_from: +# deck_spec: host.deck_spec +# - skill: creative/deck_builder +# when: +# prior_step: lint +# field: passed +# equals: true +# params: +# action: render +# input_from: +# deck_spec: host.deck_spec +# output_path: host.output_path diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb3243..ee679f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta - **Core:** `SkillLoader.to_bedrock_tool()` for AWS Bedrock Converse tool-use API — maps manifest parameters to `toolSpec` / `inputSchema.json` (#262). - **Docs:** Enterprise cloud usage guides — [enterprise_cloud.md](docs/usage/enterprise_cloud.md) (hosting + adapter routing), [bedrock.md](docs/usage/bedrock.md), [azure_openai.md](docs/usage/azure_openai.md), [vertex.md](docs/usage/vertex.md) (#262). - **Examples:** [`bedrock_tos_evaluator.py`](examples/bedrock_tos_evaluator.py) — Bedrock Converse loop with `to_bedrock_tool()` (#262). +- **Skill (`creative/deck_builder` v0.2.0):** Enterprise presentation platform baseline — procedural Pillow placeholders, image fit policies, layouts (`timeline`, `metrics`, `comparison`), `lint_deck` quality gates, `suggest_outline` archetypes, governance ribbons/footers, and [`deck_builder_chain_demo.py`](examples/deck_builder_chain_demo.py) (#336, #337). +- **Examples:** [`deck_builder_chain_demo.py`](examples/deck_builder_chain_demo.py) — `suggest_outline` → `bg_remover` → `lint_deck` → `render` via `SkillContext` (#337). ### Changed diff --git a/docs/skills/README.md b/docs/skills/README.md index 85d2de0..1a4d879 100644 --- a/docs/skills/README.md +++ b/docs/skills/README.md @@ -18,7 +18,7 @@ Skills for image processing, media editing, and creative utilities. | Skill | ID | Version | Issuer | Description | | :--- | :--- | :--- | :--- | :--- | | **[Background Remover](bg_remover.md)** | `creative/bg_remover` | `0.2.0` (2 Aug 2026) | [@AyushSrivastava1818](https://github.com/AyushSrivastava1818) ([@ARPAHLS](https://github.com/ARPAHLS)) | Removes image backgrounds locally using rembg and returns transparent PNGs. | -| **[Deck Builder](deck_builder.md)** | `creative/deck_builder` | `0.1.0` (3 Sep 2026) | [@tusharjamunkar](https://github.com/tusharjamunkar) ([@ARPAHLS](https://github.com/ARPAHLS)) | Deterministic PowerPoint (.pptx) presentation assembly from structured JSON deck specs. | +| **[Deck Builder](deck_builder.md)** | `creative/deck_builder` | `0.2.0` (16 Sep 2026) | [@tusharjamunkar](https://github.com/tusharjamunkar) ([@ARPAHLS](https://github.com/ARPAHLS)) | Offline PPTX assembly from JSON deck specs — placeholders, lint_deck, suggest_outline, and 13 layouts. | ## Finance Tools for financial analysis, blockchain interaction, and regulatory compliance. diff --git a/docs/skills/deck_builder.md b/docs/skills/deck_builder.md index 04182ad..9f29ccc 100644 --- a/docs/skills/deck_builder.md +++ b/docs/skills/deck_builder.md @@ -3,7 +3,7 @@ **ID**: `creative/deck_builder` **Issuer**: [@tusharjamunkar](https://github.com/tusharjamunkar) ([@ARPAHLS](https://github.com/ARPAHLS)) -**Version**: `0.1.0` +**Version**: `0.2.0` — 16 Sep 2026 **Recommended install:** `pip install "skillware[creative_deck_builder]"`. See [Install extras](../usage/install_extras.md). @@ -11,16 +11,18 @@ [Skill Library](README.md) · [Testing](../TESTING.md) -Deterministic, offline assembly of Microsoft PowerPoint (`.pptx`) presentations from structured JSON deck specifications. Supports multi-slide layouts (title, section, bullets, two-column, image, image with caption, quote, table, chart, blank), custom theme token overrides, speaker notes, and pre-flight validation. +Deterministic, offline assembly of Microsoft PowerPoint (`.pptx`) presentations from structured JSON deck specifications. Supports 13 slide layouts (title, section, bullets, two-column, image, image with caption, quote, table, chart, timeline, metrics, comparison, blank), smart image placeholders, `fit` policies, presentation quality linting (`lint_deck`), archetype outline generation (`suggest_outline`), custom theme token overrides, speaker notes, and pre-flight validation. ## Capabilities - **Deterministic Assembly**: Generates standard editable `.pptx` documents without remote network calls or image generation APIs. -- **10 Layout Types**: Supports cover titles, section headers, bullet lists, two-column comparisons, images, image captions, pull-quotes, tables, native OpenXML charts (bar, line, pie), and blank canvases. +- **13 Layout Types**: Supports cover titles, section headers, bullet lists, two-column comparisons, images, image captions, pull-quotes, tables, native OpenXML charts (bar, line, pie), milestone timelines, KPI metric cards, comparative matrices, and blank canvases. +- **Smart Image Engine & Placeholders**: Normalizes images via Pillow (EXIF orientation, CMYK to RGB) with configurable `fit` policies (`contain`, `cover`, `crop_center`, `stretch`) and neutral offline placeholders (`hero`, `logo`, `icon`, `chart_backdrop`, `headshot`). +- **Quality Gates (`lint_deck`)**: Heuristic deck scoring (0–100) assessing bullet density, text length caps, image alt tags, orphan bullets, and visual monotony. +- **Archetype Outlines (`suggest_outline`)**: Offline template outline generator for `investor_pitch`, `technical_brief`, `quarterly_review`, `product_launch`, and `training_workshop`. - **Pre-flight Validation (`validate_spec`)**: Validates JSON specifications against strict JSON Schema and flags soft-limit warnings (e.g. text truncations, missing assets) before writing to disk. - **Widescreen 16:9 Templates**: Bundles 3 distinct master templates (`pitch_v1`, `corporate_v1`, `minimal_v1`) with configurable font and accent color tokens. - **Inspection (`inspect`)**: Examines existing `.pptx` files and extracts slide counts, layout hints, titles, and speaker notes presence. -- **Asset Normalization**: Ingests local file paths or Base64 image payloads with Pillow validation and directory traversal defenses. ## Actions @@ -30,6 +32,8 @@ Deterministic, offline assembly of Microsoft PowerPoint (`.pptx`) presentations | `render` | `deck_spec`, `output_path`, `template_id` *(optional)*, `theme` *(optional)*, `strict` *(optional)* | Assembles slides, applies theme tokens, inserts images/charts, writes `.pptx` to disk. | | `inspect` | `input_path` | Reads an existing `.pptx` presentation and returns slide counts, titles, layout names, and notes presence. | | `list_templates` | *(none)* | Enumerates bundled template IDs, names, descriptions, and aspect ratios. | +| `lint_deck` | `deck_spec`, `min_score` *(optional)*, `strict_a11y` *(optional)* | Analyzes deck specification for presentation design and accessibility best practices. | +| `suggest_outline` | `archetype`, `topic` *(optional)*, `constraints` *(optional)* | Generates a structured slide outline and skeleton `deck_spec` for an archetype. | ## Slide Layouts @@ -39,11 +43,14 @@ Deterministic, offline assembly of Microsoft PowerPoint (`.pptx`) presentations | `section` | Section divider | `title`, optional `subtitle`, optional `speaker_notes` | | `bullets` | Bulleted takeaways | `title`, `bullets` (array of strings; >120 chars emits warning), `speaker_notes` | | `two_column` | Comparison / two-panel layout | `title`, `left` (text/bullets), `right` (text/bullets), `speaker_notes` | -| `image` | Visual showcase | `title`, `image` (path or base64), optional `caption`, `speaker_notes` | +| `image` | Visual showcase | `title`, `image` (path, base64, or placeholder_id), optional `caption`, `speaker_notes` | | `image_caption` | Image with side text | `title`, `image`, `body` (explanatory text), `speaker_notes` | | `quote` | Pull quote | `quote`, `attribution`, `speaker_notes` | | `table` | Tabular data grid | `title`, `columns`, `rows`, `speaker_notes` | | `chart` | Data visualization | `title`, `chart` (`kind`: `bar`/`line`/`pie`, `categories`, `series`), `speaker_notes` | +| `timeline` | Milestone roadmap | `title`, `items` (array of `{date, title, description, status}`), `speaker_notes` | +| `metrics` | KPI big numbers | `title`, `metrics` (array of `{value, label, delta, trend}`), `speaker_notes` | +| `comparison` | Comparative cards | `title`, `left`, `right` (or `columns`), `speaker_notes` | | `blank` | Clean canvas | optional `speaker_notes` | ## Usage Examples @@ -218,39 +225,43 @@ print(json.dumps(result, indent=2)) ### Skill Chaining (with `creative/bg_remover`) -Compose with other skills using host orchestration or `SkillContext` (see [Skill chaining](../usage/skill_chaining.md)). For example, remove backgrounds from brand logos or product photos with [`creative/bg_remover`](bg_remover.md) before passing the transparent PNG into `creative/deck_builder`: +Compose with other skills using host orchestration or `SkillContext` (see [Skill chaining](../usage/skill_chaining.md)). For example, bootstrap an archetype outline, remove backgrounds from brand logos with [`creative/bg_remover`](bg_remover.md), evaluate deck quality gates (`lint_deck`), and assemble the final presentation (see full runnable script in [`examples/deck_builder_chain_demo.py`](../../examples/deck_builder_chain_demo.py)): ```python from skillware import SkillContext ctx = SkillContext(skills=["creative/bg_remover", "creative/deck_builder"]) -# Step 1: Strip background from raw logo or product image -bg_res = ctx.execute("creative/bg_remover", {"input_path": "assets/raw_logo.jpg"}) - -# Step 2: Assemble presentation using the transparent PNG -deck_spec = { - "title": "Product Launch", - "template_id": "pitch_v1", - "slides": [ - { - "type": "title", - "title": "Autonomous Infrastructure", - "subtitle": "Q4 Executive Review", - "image": {"base64": bg_res["image_base64"], "mime_type": "image/png"}, - }, - { - "type": "bullets", - "title": "Highlights", - "bullets": ["100% offline assembly", "Deterministic slide layout"], - }, - ], +# Step 1: Suggest outline via archetype +outline = ctx.execute( + "creative/deck_builder", + { + "action": "suggest_outline", + "archetype": "product_launch", + "topic": "Skillware Autonomous Runtime", + "constraints": ["no pricing"], + }, +) +deck_spec = outline["deck_spec_skeleton"] + +# Step 2: Strip background from brand mark +bg_res = ctx.execute("creative/bg_remover", {"input_path": "assets/raw_logo.png"}) +deck_spec["slides"][0]["image"] = { + "base64": bg_res["image_base64"], + "mime_type": "image/png", + "fit": "contain", } + +# Step 3: Run presentation quality linting +lint_res = ctx.execute("creative/deck_builder", {"action": "lint_deck", "deck_spec": deck_spec, "min_score": 80}) +assert lint_res["passed"] is True, f"Quality gate failed: score={lint_res['score']}" + +# Step 4: Render final presentation render_res = ctx.execute( "creative/deck_builder", {"action": "render", "deck_spec": deck_spec, "output_path": "launch_deck.pptx"}, ) -print("Rendered:", render_res["output_path"], "with", render_res["slide_count"], "slides") +print("Rendered:", render_res["output_path"], "with", render_res["slide_count"], "slides (score:", lint_res["score"], ")") ``` --- @@ -262,6 +273,7 @@ Commits that touched this skill bundle or its catalog page ([`creative/deck_buil | Commit | Description | Date | Version | Contributors | | :--- | :--- | :--- | :--- | :--- | +| _#337_ | feat(creative): deck_builder v0.2.0 baseline — placeholders, quality linting, archetypes, and new layouts (#336) | 16 Sep 2026 | `0.2.0` | [@tusharjamunkar](https://github.com/tusharjamunkar) | | [`790787d`](https://github.com/ARPAHLS/skillware/commit/790787d0e72262ddfeb26f747f880012ca2b1ca6) | docs: five-provider Usage Examples guard and catalog loop backfill (#347) | 10 Sep 2026 | `0.1.0` | [@rosspeili](https://github.com/rosspeili) | | [`1903f30`](https://github.com/ARPAHLS/skillware/commit/1903f30f32bd75567270058f3b255452d8bddd97) | feat(creative): add deck_builder skill for deterministic PPTX assembly (#276) (#331) | 4 Sep 2026 | `0.1.0` | [@tusharjamunkar](https://github.com/tusharjamunkar) | diff --git a/docs/usage/agent_loops.md b/docs/usage/agent_loops.md index a134f58..1c1147f 100644 --- a/docs/usage/agent_loops.md +++ b/docs/usage/agent_loops.md @@ -159,7 +159,7 @@ skills in one harness. | `security/prompt_injection_firewall` | `prompt_injection_firewall_demo.py`, `sanitize_input_chain_demo.py` (local execute) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | | `security/deceptive_ui_guard` | `deceptive_ui_guard_demo.py` (local execute) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | | `creative/bg_remover` | `bg_remover_demo.py` (local execute) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | -| `creative/deck_builder` | `deck_builder_demo.py` (local execute) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | +| `creative/deck_builder` | `deck_builder_demo.py`, `deck_builder_chain_demo.py` (local execute) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | | `optimization/prompt_rewriter` | `prompt_compression_demo.py`, `sanitize_input_chain_demo.py` (local execute) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | `ollama_skills_test.py` (multi-skill) | | `data_engineering/synthetic_generator` | `build_dataset_demo.py` (local execute, Gemini backend) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | | `data_engineering/novelty_extractor` | `novelty_extractor_demo.py` (local execute) | `gemini_novelty_extractor.py` | (catalog page) | (catalog page) | (catalog page) | `ollama_novelty_extractor.py` | diff --git a/examples/README.md b/examples/README.md index 15810d5..a451fce 100644 --- a/examples/README.md +++ b/examples/README.md @@ -73,6 +73,7 @@ pip install -e ".[dev,all,agents]" | `uk_companies_house_handler_demo.py` | `finance/uk_companies_house_handler` | Local execute | `[finance_uk_companies_house_handler]` | None | Mocked v1.2.1 flows: composite, turn-by-turn run_pipeline, disambiguation resume, record truncation limits. | | `bg_remover_demo.py` | `creative/bg_remover` | Local execute | `[creative_bg_remover]` | None | Demonstrates offline background removal from a local image and optionally writes a transparent PNG. | | `deck_builder_demo.py` | `creative/deck_builder` | Local execute | `[creative_deck_builder]` | None | Demonstrates offline presentation assembly from JSON deck specs with charts, tables, bullets, and speaker notes. | +| `deck_builder_chain_demo.py` | `creative/bg_remover`, `creative/deck_builder` | Local execute | `[creative_bg_remover]`, `[creative_deck_builder]` | None | Demonstrates SkillContext chaining for outline suggestion, transparent logo generation, deck linting, and final rendering. | | `semantic_web_proxy_demo.py` | `data_engineering/semantic_web_proxy` | Local execute | `[data_engineering_semantic_web_proxy]` | None | Demonstrates offline boilerplate stripping, opt-in comments, the JavaScript-render warning, and the SSRF guard using bundled fixture HTML. | | `gmail_handler_demo.py` | `office/gmail_handler` | Local execute | `[office_gmail_handler]` | None | Mocked resolve, preview/send gate, search, and read flow (no Gmail credentials). | | `gmail_signature_test_send.py` | `office/gmail_handler` | Local execute | `[office_gmail_handler]` | `GMAIL_ADDRESS`, `GMAIL_APP_PASSWORD`; run `skillware mail signature init` first | Preview or send one test message to verify plain + HTML signature. | diff --git a/examples/deck_builder_chain_demo.py b/examples/deck_builder_chain_demo.py new file mode 100644 index 0000000..1988041 --- /dev/null +++ b/examples/deck_builder_chain_demo.py @@ -0,0 +1,100 @@ +"""Chain execution demo for creative/bg_remover and creative/deck_builder. + +Demonstrates host orchestration via SkillContext: +1. Suggest deck outline via archetype (creative/deck_builder) +2. Remove background from raw brand mark to produce transparent PNG (creative/bg_remover) +3. Quality lint the presentation specification (creative/deck_builder) +4. Render the final presentation to an editable .pptx (creative/deck_builder) +Runs entirely offline without remote API keys. +""" + +from pathlib import Path +import tempfile +from PIL import Image +import io +import base64 + +from skillware import SkillContext + + +def run_demo(): + print("Loading SkillContext with creative/bg_remover and creative/deck_builder...") + ctx = SkillContext(skills=["creative/bg_remover", "creative/deck_builder"]) + + # Step 1: Suggest outline via archetype + print("\n=== Step 1: Suggest Outline via Archetype ===") + outline_res = ctx.execute( + "creative/deck_builder", + { + "action": "suggest_outline", + "archetype": "product_launch", + "topic": "Skillware Autonomous Runtime", + "constraints": ["no pricing"], + }, + ) + print(f" archetype: {outline_res.get('archetype')}") + print(f" recommended_slides: {outline_res.get('recommended_slide_count')}") + deck_spec = outline_res["deck_spec_skeleton"] + + # Step 2: Background removal on brand mark + print("\n=== Step 2: Background Removal on Brand Mark ===") + sample_img_path = Path("examples/sample_input.png") + if sample_img_path.exists(): + bg_res = ctx.execute( + "creative/bg_remover", {"input_path": str(sample_img_path)} + ) + transparent_logo_b64 = bg_res.get("image_base64") + print( + f" bg_remover processed {sample_img_path}: success={bg_res.get('success')}" + ) + else: + print(" sample_input.png not present; using synthetic transparent brand asset") + logo_img = Image.new("RGBA", (120, 120), color=(110, 87, 224, 255)) + buf = io.BytesIO() + logo_img.save(buf, format="PNG") + transparent_logo_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + print(f" transparent_logo length: {len(transparent_logo_b64)} chars") + + # Embed transparent logo into cover slide + deck_spec["slides"][0]["image"] = { + "base64": transparent_logo_b64, + "mime_type": "image/png", + "fit": "contain", + } + + # Step 3: Lint deck specification + print("\n=== Step 3: Lint Deck Specification ===") + lint_res = ctx.execute( + "creative/deck_builder", + { + "action": "lint_deck", + "deck_spec": deck_spec, + "min_score": 70, + }, + ) + print(f" lint_score: {lint_res.get('score')}/100") + print(f" passed: {lint_res.get('passed')}") + print(f" findings_count: {lint_res.get('findings_count')}") + + # Step 4: Render final presentation + print("\n=== Step 4: Render Final Presentation ===") + with tempfile.TemporaryDirectory() as tmp_dir: + output_pptx = Path(tmp_dir) / "skillware_launch.pptx" + render_res = ctx.execute( + "creative/deck_builder", + { + "action": "render", + "deck_spec": deck_spec, + "output_path": str(output_pptx), + }, + ) + print(f" render success: {render_res.get('success')}") + print(f" output_path: {render_res.get('output_path')}") + print(f" file_size_bytes: {render_res.get('file_size_bytes')}") + print(f" rendered_slides: {len(render_res.get('slides', []))}") + + print("\nChain demo complete.") + + +if __name__ == "__main__": + run_demo() diff --git a/skills/creative/deck_builder/archetypes.py b/skills/creative/deck_builder/archetypes.py new file mode 100644 index 0000000..18cbd36 --- /dev/null +++ b/skills/creative/deck_builder/archetypes.py @@ -0,0 +1,520 @@ +"""Deck archetypes and outline suggestions for creative/deck_builder.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +ARCHETYPES = { + "investor_pitch": { + "name": "Investor Pitch Deck", + "description": ( + "Seed through Series B investor overview focusing on problem, solution, market size, traction, and team." + ), + "recommended_slides": 11, + "default_template": "pitch_v1", + "skeleton": [ + { + "type": "title", + "title": "{topic}", + "subtitle": "Investor Presentation", + "speaker_notes": "Introduce the vision and founding story.", + }, + { + "type": "bullets", + "title": "The Problem", + "bullets": [ + "Inefficient manual workflows stall growth", + "Existing tooling lacks deterministic guarantees", + "High infrastructure and operational costs", + ], + }, + { + "type": "bullets", + "title": "The Solution", + "bullets": [ + "Autonomous, policy-governed architecture", + "Sub-millisecond local execution", + "Universal interoperability across providers", + ], + }, + { + "type": "section", + "title": "Market Opportunity", + "subtitle": "Expanding total addressable market", + }, + { + "type": "metrics", + "title": "Key Traction Metrics", + "metrics": [ + { + "value": "$1.2M", + "label": "ARR", + "trend": "up", + "delta": "+140% YoY", + }, + { + "value": "45k", + "label": "Active Users", + "trend": "up", + "delta": "+85%", + }, + {"value": "99.9%", "label": "Uptime", "trend": "neutral"}, + ], + }, + { + "type": "two_column", + "title": "Competitive Advantage", + "left": ["Legacy Solutions", "Fragile JSON parsing", "Vendor lock-in"], + "right": [ + "Our Platform", + "Deterministic contracts", + "Universal host runtime", + ], + }, + { + "type": "timeline", + "title": "Execution Milestones", + "items": [ + { + "date": "Q1 2026", + "title": "Core Engine Launch", + "status": "completed", + }, + { + "date": "Q2 2026", + "title": "Enterprise Pilot", + "status": "in_progress", + }, + { + "date": "Q3 2026", + "title": "Global Registry", + "status": "planned", + }, + ], + }, + { + "type": "quote", + "quote": "This platform transformed our operational throughput within days.", + "attribution": "VP of Technology", + }, + { + "type": "bullets", + "title": "Business Model", + "bullets": [ + "Open-source developer adoption", + "Enterprise cloud subscription tiers", + "Dedicated SLA and customization support", + ], + }, + { + "type": "bullets", + "title": "The Ask & Growth Runway", + "bullets": [ + "$10M Series A financing", + "18-month execution runway", + "Scale engineering and enterprise go-to-market", + ], + }, + { + "type": "blank", + "speaker_notes": "Thank the partners and open for discussion.", + }, + ], + }, + "technical_brief": { + "name": "Technical Architecture Brief", + "description": "Engineering and architectural deep dive for technical stakeholders, reviews, or RFCs.", + "recommended_slides": 7, + "default_template": "corporate_v1", + "skeleton": [ + { + "type": "title", + "title": "{topic}", + "subtitle": "System Architecture & Engineering Specification", + }, + { + "type": "section", + "title": "Part 1: Architectural Foundations", + "subtitle": "Design principles and boundaries", + }, + { + "type": "bullets", + "title": "Core Principles", + "bullets": [ + "Deterministic, offline-first execution", + "Strict JSON Schema validation at boundary", + "Fail-closed safety guarantees", + ], + }, + { + "type": "two_column", + "title": "Subsystem Topology", + "left": ["Host Runtime", "Context assembly", "Tool dispatch"], + "right": ["Skill Boundary", "Isolated execution", "Auditable results"], + }, + { + "type": "timeline", + "title": "Implementation Roadmap", + "items": [ + { + "date": "Sprint 1", + "title": "Core Contracts", + "status": "completed", + }, + { + "date": "Sprint 2", + "title": "Integration Testing", + "status": "in_progress", + }, + { + "date": "Sprint 3", + "title": "Production Deployment", + "status": "planned", + }, + ], + }, + { + "type": "table", + "title": "Benchmark Latency", + "columns": ["Module", "v0.1 (Legacy)", "v0.2 (Optimized)", "Delta"], + "rows": [ + ["Validation", "4.2 ms", "0.8 ms", "-81%"], + ["Assembly", "18.1 ms", "3.4 ms", "-81%"], + ["Total Pipeline", "22.3 ms", "4.2 ms", "-81%"], + ], + }, + { + "type": "blank", + "speaker_notes": "Technical Q&A with engineering leadership.", + }, + ], + }, + "quarterly_review": { + "name": "Quarterly Executive Review", + "description": "Executive review of OKRs, KPIs, financial metrics, and operational highlights.", + "recommended_slides": 8, + "default_template": "corporate_v1", + "skeleton": [ + { + "type": "title", + "title": "{topic}", + "subtitle": "Quarterly Performance Review", + }, + { + "type": "section", + "title": "Quarterly Highlights", + "subtitle": "Key achievements and milestones", + }, + { + "type": "metrics", + "title": "Topline Performance", + "metrics": [ + { + "value": "$4.8M", + "label": "Quarterly Revenue", + "trend": "up", + "delta": "+24%", + }, + { + "value": "94%", + "label": "CSAT Score", + "trend": "up", + "delta": "+3%", + }, + { + "value": "< 1 hr", + "label": "Time to Resolution", + "trend": "down", + "delta": "-45%", + }, + ], + }, + { + "type": "bullets", + "title": "Operational Wins", + "bullets": [ + "Exceeded retention targets across all customer cohorts", + "Reduced cloud infrastructure spend by 18%", + "Shipped three major customer-requested capabilities", + ], + }, + { + "type": "timeline", + "title": "Key Project Milestones", + "items": [ + { + "date": "Month 1", + "title": "Architecture Sign-off", + "status": "completed", + }, + { + "date": "Month 2", + "title": "Beta Availability", + "status": "completed", + }, + { + "date": "Month 3", + "title": "General Release", + "status": "completed", + }, + ], + }, + { + "type": "two_column", + "title": "Challenges & Mitigations", + "left": [ + "Identified Headwinds", + "Extended enterprise sales cycles", + "Third-party dependency latency", + ], + "right": [ + "Mitigation Strategies", + "Standardized proof-of-value pilots", + "In-memory response caching", + ], + }, + { + "type": "bullets", + "title": "Next Quarter Priorities", + "bullets": [ + "Accelerate international channel expansion", + "Deepen enterprise security audit posture", + "Expand developer advocacy footprint", + ], + }, + {"type": "blank", "speaker_notes": "Adjourn to executive discussion."}, + ], + }, + "product_launch": { + "name": "Product Launch Announcement", + "description": "Feature announcements, market positioning, architecture, and go-to-market rollout.", + "recommended_slides": 7, + "default_template": "pitch_v1", + "skeleton": [ + { + "type": "title", + "title": "{topic}", + "subtitle": "Product Launch Announcement", + }, + { + "type": "section", + "title": "Introducing the Next Generation", + "subtitle": "Built for scale and reliability", + }, + { + "type": "bullets", + "title": "Why Now?", + "bullets": [ + "Demands on autonomous systems are accelerating", + "Reliability requires deterministic tooling", + "Developers need seamless provider interoperability", + ], + }, + { + "type": "two_column", + "title": "Before vs After", + "left": [ + "Before", + "Manual brittle pipelines", + "High maintenance overhead", + ], + "right": [ + "With New Product", + "Automated deterministic workflows", + "Zero maintenance overhead", + ], + }, + { + "type": "quote", + "quote": "This capability unlocked complete operational confidence for our team.", + "attribution": "Lead Product Architect", + }, + { + "type": "timeline", + "title": "Rollout Schedule", + "items": [ + { + "date": "Day 1", + "title": "Developer Preview", + "status": "completed", + }, + {"date": "Day 14", "title": "Public Beta", "status": "in_progress"}, + { + "date": "Day 30", + "title": "General Availability", + "status": "planned", + }, + ], + }, + { + "type": "bullets", + "title": "Get Started Today", + "bullets": [ + "Install via standard pip extra", + "Review documentation and examples", + "Join the developer community", + ], + }, + ], + }, + "training_workshop": { + "name": "Training & Workshop Curriculum", + "description": "Structured instructional slides for workshops, onboarding, or training courses.", + "recommended_slides": 8, + "default_template": "minimal_v1", + "skeleton": [ + { + "type": "title", + "title": "{topic}", + "subtitle": "Hands-on Technical Training", + }, + { + "type": "bullets", + "title": "Workshop Agenda", + "bullets": [ + "Module 1: Foundations & Architecture", + "Module 2: Hands-on Implementation", + "Module 3: Best Practices & Verification", + "Module 4: Production Deployment", + ], + }, + { + "type": "section", + "title": "Module 1: Foundations", + "subtitle": "Core concepts and invariants", + }, + { + "type": "bullets", + "title": "Key Invariants", + "bullets": [ + "Always validate inputs before execution", + "Enforce strict schema conformance", + "Preserve audit logs and metadata", + ], + }, + { + "type": "two_column", + "title": "Lab Exercise: Comparison", + "left": [ + "Anti-Pattern", + "Calling external APIs directly", + "Ignoring error states", + ], + "right": [ + "Recommended Pattern", + "Wrap in deterministic skill", + "Handle error envelopes gracefully", + ], + }, + { + "type": "section", + "title": "Module 2: Production Readiness", + "subtitle": "Testing and CI gates", + }, + { + "type": "bullets", + "title": "Summary & Key Takeaways", + "bullets": [ + "Deterministic testing catches regressions early", + "Contract separation simplifies provider swaps", + "Review documentation for advanced patterns", + ], + }, + { + "type": "blank", + "speaker_notes": "Conclude workshop and open for questions.", + }, + ], + }, +} + + +def suggest_outline( + archetype: str, + topic: Optional[str] = None, + constraints: Optional[List[str]] = None, +) -> Dict[str, Any]: + """ + Generates a structured deck outline and skeleton deck_spec from an archetype. + Executes entirely offline. + """ + key = (archetype or "investor_pitch").lower().strip() + if key not in ARCHETYPES: + available = list(ARCHETYPES.keys()) + return { + "success": False, + "error_code": "UNKNOWN_ARCHETYPE", + "message": f"Archetype '{archetype}' is not recognized. Available archetypes: {available}", + "available_archetypes": available, + } + + arch = ARCHETYPES[key] + title_text = topic.strip() if topic and topic.strip() else "Executive Presentation" + constraints = [c.lower().strip() for c in (constraints or [])] + + slides = [] + for slide_tpl in arch["skeleton"]: + slide = dict(slide_tpl) + if "title" in slide and "{topic}" in slide["title"]: + slide["title"] = slide["title"].replace("{topic}", title_text) + + # Check negative constraints (e.g., 'no pricing', 'no business model', 'no team', 'no financial') + slide_title = slide.get("title", "").lower() + skip_slide = False + for c in constraints: + c_clean = c.lower().strip() + for prefix in ("no ", "without ", "exclude ", "omit ", "skip "): + if c_clean.startswith(prefix): + c_clean = c_clean[len(prefix) :].strip() + break + if c_clean and c_clean in slide_title: + skip_slide = True + break + if c_clean in ( + "pricing", + "price", + "financial", + "financials", + "cost", + "costs", + ) and any( + term in slide_title + for term in ("pricing", "cost", "financial", "ask", "fundraise") + ): + skip_slide = True + break + if c_clean in ("business model", "monetization", "revenue model") and ( + "business model" in slide_title or "revenue" in slide_title + ): + skip_slide = True + break + + if skip_slide: + continue + + slides.append(slide) + + deck_spec_skeleton = { + "title": title_text, + "template_id": arch["default_template"], + "theme": { + "accent_color": ( + "#6E57E0" if arch["default_template"] == "pitch_v1" else "#1E3A8A" + ), + "font_heading": "Calibri", + "font_body": "Calibri", + }, + "slides": slides, + } + + return { + "success": True, + "action": "suggest_outline", + "archetype": key, + "name": arch["name"], + "description": arch["description"], + "recommended_slide_count": len(slides), + "constraints_applied": constraints, + "deck_spec": deck_spec_skeleton, + "deck_spec_skeleton": deck_spec_skeleton, + } diff --git a/skills/creative/deck_builder/builder.py b/skills/creative/deck_builder/builder.py index 8804571..9899a48 100644 --- a/skills/creative/deck_builder/builder.py +++ b/skills/creative/deck_builder/builder.py @@ -1,4 +1,4 @@ -"""Core PowerPoint (.pptx) builder, validator, and inspector for creative/deck_builder.""" +"""Core PowerPoint (.pptx) builder, validator, and inspector for creative/deck_builder v0.2.""" from __future__ import annotations @@ -7,16 +7,25 @@ import json import os from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import jsonschema -from PIL import Image +from PIL import Image, ImageOps import pptx from pptx.chart.data import CategoryChartData from pptx.dml.color import RGBColor from pptx.enum.chart import XL_CHART_TYPE +from pptx.enum.shapes import MSO_SHAPE from pptx.util import Inches, Pt +try: + from .placeholders import generate_placeholder +except ImportError: + import sys + + sys.path.insert(0, os.path.dirname(__file__)) + from placeholders import generate_placeholder + _HERE = Path(__file__).resolve().parent _TEMPLATES_DIR = _HERE / "templates" _SCHEMA_PATH = _HERE / "schemas" / "deck_spec.schema.json" @@ -64,6 +73,9 @@ "quote", "table", "chart", + "timeline", + "metrics", + "comparison", "blank", ] @@ -103,52 +115,142 @@ def list_templates() -> Dict[str, Any]: "success": True, "action": "list_templates", "templates": template_list, - "error_code": None, + "count": len(template_list), } -def validate_spec(deck_spec: Any, strict: bool = False) -> Dict[str, Any]: - """Validate deck specification against schema and business rules.""" - errors: List[Dict[str, Any]] = [] - warnings: List[Dict[str, Any]] = [] +def _validate_output_path(output_path: str) -> str: + if not output_path or not output_path.strip(): + raise ValueError("output_path must be a non-empty string.") + + raw_parts = output_path.replace("\\", "/").split("/") + if ".." in raw_parts: + raise ValueError( + "output_path contains prohibited path traversal sequences ('..')." + ) + + norm = os.path.normpath(output_path) + if "\x00" in norm: + raise ValueError("output_path contains invalid characters.") + + if not norm.lower().endswith(".pptx"): + raise ValueError("output_path must have a .pptx extension.") + + parent = os.path.dirname(os.path.abspath(norm)) + os.makedirs(parent, exist_ok=True) + return os.path.abspath(norm) + + +def _load_raw_image_bytes( + img_obj: Dict[str, Any], fallback_kind: str = "hero" +) -> Optional[bytes]: + if not img_obj or not isinstance(img_obj, dict): + return None + if img_obj.get("path"): + p = img_obj["path"] + if os.path.exists(p): + with open(p, "rb") as f: + return f.read() + elif img_obj.get("base64"): + try: + return base64.b64decode(img_obj["base64"]) + except Exception: + return None + elif img_obj.get("placeholder_id") or img_obj.get("placeholder_prompt"): + p_stream = generate_placeholder( + kind=img_obj.get("placeholder_id", fallback_kind), + prompt=img_obj.get("placeholder_prompt"), + ) + return p_stream.getvalue() + return None - if not isinstance(deck_spec, dict): - return { - "success": False, - "action": "validate_spec", - "valid": False, - "template_id": "unknown", - "slide_count": 0, - "warnings": [], - "errors": [ - { - "code": "INVALID_SPEC", - "slide_index": -1, - "message": "deck_spec must be a JSON object", - } - ], - "error_code": "INVALID_SPEC", - } - # 1. JSON Schema validation +def _normalize_and_fit_image( + raw_bytes: bytes, + box_width: float, + box_height: float, + fit: str = "contain", +) -> Tuple[io.BytesIO, float, float, float, float]: + """ + Normalizes image with Pillow and computes fitting offsets. + Returns: (img_stream, offset_x, offset_y, draw_w, draw_h) + """ + fit = (fit or "contain").lower().strip() + with Image.open(io.BytesIO(raw_bytes)) as pil_img: + pil_img = ImageOps.exif_transpose(pil_img) or pil_img + if pil_img.mode in ("CMYK", "P"): + pil_img = pil_img.convert( + "RGBA" + if "transparency" in pil_img.info or pil_img.mode == "P" + else "RGB" + ) + + w, h = pil_img.size + img_aspect = float(w) / float(h) if h > 0 else 1.0 + box_aspect = float(box_width) / float(box_height) if box_height > 0 else 1.0 + + if fit in ("cover", "crop_center"): + if img_aspect > box_aspect: + new_w = int(h * box_aspect) + ox = (w - new_w) // 2 + pil_img = pil_img.crop((ox, 0, ox + new_w, h)) + elif img_aspect < box_aspect: + new_h = int(w / box_aspect) + oy = (h - new_h) // 2 + pil_img = pil_img.crop((0, oy, w, oy + new_h)) + draw_w = box_width + draw_h = box_height + offset_x = 0.0 + offset_y = 0.0 + elif fit == "contain": + if img_aspect > box_aspect: + draw_w = box_width + draw_h = box_width / img_aspect + offset_x = 0.0 + offset_y = (box_height - draw_h) / 2.0 + else: + draw_h = box_height + draw_w = box_height * img_aspect + offset_y = 0.0 + offset_x = (box_width - draw_w) / 2.0 + else: # stretch or native + draw_w = box_width + draw_h = box_height + offset_x = 0.0 + offset_y = 0.0 + + buf = io.BytesIO() + pil_img.save(buf, format="PNG") + buf.seek(0) + return buf, offset_x, offset_y, draw_w, draw_h + + +def validate_spec(deck_spec: Dict[str, Any], strict: bool = False) -> Dict[str, Any]: + """Validate deck specification against schema and business rules.""" schema = _load_schema() validator = jsonschema.Draft202012Validator(schema) schema_errors = list(validator.iter_errors(deck_spec)) - if schema_errors: - for err in schema_errors: - errors.append( - { - "code": "INVALID_SPEC", - "slide_index": -1, - "message": f"Schema violation at '{err.json_path}': {err.message}", - } - ) + + errors: List[Dict[str, Any]] = [] + warnings: List[Dict[str, Any]] = [] + + for err in schema_errors: + path = ".".join(str(p) for p in err.path) or "root" + errors.append( + { + "code": "SCHEMA_VALIDATION_ERROR", + "slide_index": -1, + "message": f"[{path}] {err.message}", + } + ) + + if errors: return { "success": False, "action": "validate_spec", "valid": False, - "template_id": deck_spec.get("template_id", "pitch_v1"), - "slide_count": len(deck_spec.get("slides") or []), + "template_id": deck_spec.get("template_id", "unknown"), + "slide_count": len(deck_spec.get("slides", [])), "warnings": [], "errors": errors, "error_code": "INVALID_SPEC", @@ -158,55 +260,51 @@ def validate_spec(deck_spec: Any, strict: bool = False) -> Dict[str, Any]: if template_id not in TEMPLATES: warnings.append( { - "code": "TEMPLATE_NOT_FOUND", + "code": "UNKNOWN_TEMPLATE_FALLBACK", "slide_index": -1, - "message": f"Unknown template_id '{template_id}'; will fall back to pitch_v1.", + "message": f"Template '{template_id}' is not bundled. Falling back to 'pitch_v1'.", } ) - slides = deck_spec.get("slides") or [] + slides = deck_spec.get("slides", []) for idx, slide in enumerate(slides): stype = slide.get("type") + if stype not in SUPPORTED_LAYOUT_TYPES: + errors.append( + { + "code": "UNSUPPORTED_LAYOUT_TYPE", + "slide_index": idx, + "message": f"Layout type '{stype}' is not supported.", + } + ) + continue - # Soft character limit on bullets + # Soft cap check on bullets length if stype == "bullets": - bullets = slide.get("bullets") or [] + bullets = slide.get("bullets", []) for b_idx, bullet in enumerate(bullets): - if len(bullet) > 120: + if len(str(bullet)) > 120: warnings.append( { "code": "BULLET_TRUNCATED", "slide_index": idx, + "bullet_index": b_idx, + "character_count": len(str(bullet)), "message": ( - f"Bullet {b_idx + 1} exceeded 120 chars ({len(bullet)} chars); " - "will wrap or truncate on render." + f"Bullet {b_idx + 1} exceeds 120 characters " + f"({len(str(bullet))} chars). Text may wrap excessively." ), } ) - elif stype == "two_column": - for col_key in ("left", "right"): - col_content = slide.get(col_key) - if isinstance(col_content, list): - for c_idx, item in enumerate(col_content): - if len(item) > 120: - warnings.append( - { - "code": "BULLET_TRUNCATED", - "slide_index": idx, - "message": ( - f"Column {col_key} item {c_idx + 1} exceeded 120 chars; " - "will wrap or truncate." - ), - } - ) - # Asset verification for images if stype in {"image", "image_caption", "title"}: img_obj = slide.get("image") - if img_obj: + if img_obj and isinstance(img_obj, dict): img_path = img_obj.get("path") b64_data = img_obj.get("base64") + p_id = img_obj.get("placeholder_id") + p_prompt = img_obj.get("placeholder_prompt") if img_path: if not os.path.exists(img_path): warnings.append( @@ -229,6 +327,14 @@ def validate_spec(deck_spec: Any, strict: bool = False) -> Dict[str, Any]: "message": f"Invalid base64 image data: {exc}", } ) + elif not p_id and not p_prompt: + warnings.append( + { + "code": "ASSET_SPEC_EMPTY", + "slide_index": idx, + "message": "Image object specified but lacks 'path', 'base64', or 'placeholder_id'.", + } + ) # Chart verification if stype == "chart": @@ -266,6 +372,48 @@ def validate_spec(deck_spec: Any, strict: bool = False) -> Dict[str, Any]: } ) + # Timeline verification + if stype == "timeline": + items = slide.get("items") or [] + if not items: + errors.append( + { + "code": "MISSING_REQUIRED_FIELD", + "slide_index": idx, + "message": "Timeline slide requires at least one milestone in 'items'.", + } + ) + for it_idx, item in enumerate(items): + if not item.get("date") or not item.get("title"): + errors.append( + { + "code": "INVALID_TIMELINE_ITEM", + "slide_index": idx, + "message": f"Timeline milestone {it_idx + 1} must include 'date' and 'title'.", + } + ) + + # Metrics verification + if stype == "metrics": + m_list = slide.get("metrics") or [] + if not m_list: + errors.append( + { + "code": "MISSING_REQUIRED_FIELD", + "slide_index": idx, + "message": "Metrics slide requires at least one metric in 'metrics'.", + } + ) + for m_idx, m in enumerate(m_list): + if not m.get("value") or not m.get("label"): + errors.append( + { + "code": "INVALID_METRIC_ITEM", + "slide_index": idx, + "message": f"Metric {m_idx + 1} must include 'value' and 'label'.", + } + ) + if strict and warnings: for w in warnings: errors.append( @@ -291,45 +439,6 @@ def validate_spec(deck_spec: Any, strict: bool = False) -> Dict[str, Any]: } -def _validate_output_path(output_path: str) -> str: - if not output_path or not output_path.strip(): - raise ValueError("output_path must be a non-empty string.") - - raw_parts = output_path.replace("\\", "/").split("/") - if ".." in raw_parts: - raise ValueError( - "output_path contains prohibited path traversal sequences ('..')." - ) - - norm = os.path.normpath(output_path) - if "\x00" in norm: - raise ValueError("output_path contains invalid characters.") - - if not norm.lower().endswith(".pptx"): - raise ValueError("output_path must have a .pptx extension.") - - parent = os.path.dirname(os.path.abspath(norm)) - os.makedirs(parent, exist_ok=True) - return os.path.abspath(norm) - - -def _load_image_stream(img_obj: Dict[str, Any]) -> Optional[io.BytesIO]: - if not img_obj: - return None - if img_obj.get("path"): - p = img_obj["path"] - if os.path.exists(p): - with open(p, "rb") as f: - return io.BytesIO(f.read()) - elif img_obj.get("base64"): - try: - data = base64.b64decode(img_obj["base64"]) - return io.BytesIO(data) - except Exception: - return None - return None - - def render_deck( deck_spec: Dict[str, Any], output_path: str, @@ -368,54 +477,44 @@ def render_deck( "slides": [], "warnings": val_res["warnings"], "errors": val_res["errors"], - "error_code": val_res["error_code"] or "INVALID_SPEC", + "error_code": val_res["error_code"] or "VALIDATION_FAILED", } - effective_template_id = template_id or deck_spec.get("template_id", "pitch_v1") + effective_template_id = template_id or deck_spec.get("template_id") or "pitch_v1" if effective_template_id not in TEMPLATES: effective_template_id = "pitch_v1" - template_meta = TEMPLATES[effective_template_id] - template_file = _TEMPLATES_DIR / template_meta["filename"] - - try: - if template_file.is_file(): - prs = pptx.Presentation(str(template_file)) - else: - prs = pptx.Presentation() - prs.slide_width = Inches(13.333) - prs.slide_height = Inches(7.5) - except Exception as exc: - return { - "success": False, - "action": "render", - "output_path": safe_output_path, - "template_id": effective_template_id, - "slide_count": 0, - "file_size_bytes": 0, - "slides": [], - "warnings": val_res["warnings"], - "errors": [ - {"code": "RENDER_FAILED", "slide_index": -1, "message": str(exc)} - ], - "error_code": "RENDER_FAILED", - } - - theme_spec = deck_spec.get("theme") or {} - if theme: - theme_spec.update(theme) - - accent_hex = theme_spec.get("accent_color", template_meta["default_accent"]) + template_info = TEMPLATES[effective_template_id] + template_path = _TEMPLATES_DIR / template_info["filename"] + + if not template_path.exists(): + prs = pptx.Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + else: + prs = pptx.Presentation(str(template_path)) + + # Determine theme tokens + theme_spec = theme or deck_spec.get("theme") or {} + accent_hex = theme_spec.get("accent_color") or template_info["default_accent"] + font_heading = ( + theme_spec.get("font_heading") or template_info["default_heading_font"] + ) + font_body = theme_spec.get("font_body") or template_info["default_body_font"] accent_rgb = _hex_to_rgb(accent_hex) - font_heading = theme_spec.get("font_heading", template_meta["default_heading_font"]) - font_body = theme_spec.get("font_body", template_meta["default_body_font"]) - slides = deck_spec.get("slides") or [] + # Document governance metadata (prefer metadata, fallback to root) + metadata = deck_spec.get("metadata") or {} + classification = metadata.get("classification") or deck_spec.get("classification") + legal_footer = metadata.get("legal_footer") or deck_spec.get("legal_footer") + + slides = deck_spec.get("slides", []) rendered_slides_summary: List[Dict[str, Any]] = [] for s_idx, slide_data in enumerate(slides): - stype = slide_data.get("type", "blank") + stype = slide_data.get("type") title_text = slide_data.get("title", "") + slide = None # 1. Title Slide if stype == "title": @@ -425,23 +524,34 @@ def render_deck( for p in slide.shapes.title.text_frame.paragraphs: p.font.name = font_heading p.font.color.rgb = accent_rgb - p.font.bold = True + subtitle_text = slide_data.get("subtitle", "") - if len(slide.placeholders) > 1 and subtitle_text: - slide.placeholders[1].text = subtitle_text - for p in slide.placeholders[1].text_frame.paragraphs: + if subtitle_text and len(slide.placeholders) > 1: + sub_ph = slide.placeholders[1] + sub_ph.text = subtitle_text + for p in sub_ph.text_frame.paragraphs: p.font.name = font_body - img_stream = _load_image_stream(slide_data.get("image")) - if img_stream: + raw_bytes = _load_raw_image_bytes( + slide_data.get("image"), fallback_kind="logo" + ) + if raw_bytes: + fit_mode = slide_data.get("image", {}).get("fit", "contain") + stream, ox, oy, dw, dh = _normalize_and_fit_image( + raw_bytes, 2.0, 2.0, fit=fit_mode + ) try: slide.shapes.add_picture( - img_stream, Inches(9.5), Inches(2.0), width=Inches(3.0) + stream, + Inches(1.2 + ox), + Inches(1.0 + oy), + width=Inches(dw), + height=Inches(dh), ) except Exception: pass - # 2. Section Divider + # 2. Section Header elif stype == "section": slide = prs.slides.add_slide(prs.slide_layouts[2]) if slide.shapes.title: @@ -449,13 +559,15 @@ def render_deck( for p in slide.shapes.title.text_frame.paragraphs: p.font.name = font_heading p.font.color.rgb = accent_rgb + subtitle_text = slide_data.get("subtitle", "") - if len(slide.placeholders) > 1 and subtitle_text: - slide.placeholders[1].text = subtitle_text - for p in slide.placeholders[1].text_frame.paragraphs: + if subtitle_text and len(slide.placeholders) > 1: + sub_ph = slide.placeholders[1] + sub_ph.text = subtitle_text + for p in sub_ph.text_frame.paragraphs: p.font.name = font_body - # 3. Bullets + # 3. Bullets Slide elif stype == "bullets": slide = prs.slides.add_slide(prs.slide_layouts[1]) if slide.shapes.title: @@ -463,17 +575,22 @@ def render_deck( for p in slide.shapes.title.text_frame.paragraphs: p.font.name = font_heading p.font.color.rgb = accent_rgb - bullets = slide_data.get("bullets") or [] - if len(slide.placeholders) > 1 and bullets: - tf = slide.placeholders[1].text_frame - tf.clear() + + bullets = slide_data.get("bullets", []) + if len(slide.placeholders) > 1: + body_ph = slide.placeholders[1] + tf = body_ph.text_frame + tf.word_wrap = True for b_idx, bullet in enumerate(bullets): - p = tf.paragraphs[0] if b_idx == 0 else tf.add_paragraph() - p.text = bullet + if b_idx == 0: + p = tf.paragraphs[0] + else: + p = tf.add_paragraph() + p.text = str(bullet) p.font.name = font_body p.level = 0 - # 4. Two Column + # 4. Two Column Slide elif stype == "two_column": slide = prs.slides.add_slide(prs.slide_layouts[3]) if slide.shapes.title: @@ -481,25 +598,44 @@ def render_deck( for p in slide.shapes.title.text_frame.paragraphs: p.font.name = font_heading p.font.color.rgb = accent_rgb - for p_idx, col_key in enumerate(("left", "right")): - col_content = slide_data.get(col_key) - if len(slide.placeholders) > (p_idx + 1) and col_content: - tf = slide.placeholders[p_idx + 1].text_frame - tf.clear() - if isinstance(col_content, list): - for item_idx, item in enumerate(col_content): - p = ( - tf.paragraphs[0] - if item_idx == 0 - else tf.add_paragraph() - ) - p.text = item - p.font.name = font_body - else: - tf.paragraphs[0].text = str(col_content) - tf.paragraphs[0].font.name = font_body - # 5. Image + left_content = slide_data.get("left", []) + if len(slide.placeholders) > 1: + left_ph = slide.placeholders[1] + tf_l = left_ph.text_frame + tf_l.word_wrap = True + if isinstance(left_content, list): + for idx_item, item in enumerate(left_content): + p = ( + tf_l.paragraphs[0] + if idx_item == 0 + else tf_l.add_paragraph() + ) + p.text = str(item) + p.font.name = font_body + else: + tf_l.paragraphs[0].text = str(left_content) + tf_l.paragraphs[0].font.name = font_body + + right_content = slide_data.get("right", []) + if len(slide.placeholders) > 2: + right_ph = slide.placeholders[2] + tf_r = right_ph.text_frame + tf_r.word_wrap = True + if isinstance(right_content, list): + for idx_item, item in enumerate(right_content): + p = ( + tf_r.paragraphs[0] + if idx_item == 0 + else tf_r.add_paragraph() + ) + p.text = str(item) + p.font.name = font_body + else: + tf_r.paragraphs[0].text = str(right_content) + tf_r.paragraphs[0].font.name = font_body + + # 5. Image Slide elif stype == "image": slide = prs.slides.add_slide(prs.slide_layouts[5]) if slide.shapes.title and title_text: @@ -507,18 +643,29 @@ def render_deck( for p in slide.shapes.title.text_frame.paragraphs: p.font.name = font_heading p.font.color.rgb = accent_rgb - img_stream = _load_image_stream(slide_data.get("image")) - if img_stream: + + raw_bytes = _load_raw_image_bytes( + slide_data.get("image"), fallback_kind="hero" + ) + if raw_bytes: + fit_mode = slide_data.get("image", {}).get("fit", "contain") + box_w = 9.333 + box_h = 4.2 if title_text else 5.0 + stream, ox, oy, dw, dh = _normalize_and_fit_image( + raw_bytes, box_w, box_h, fit=fit_mode + ) + top_offset = 1.8 if title_text else 1.2 try: - top_offset = Inches(1.8) if title_text else Inches(1.0) slide.shapes.add_picture( - img_stream, - Inches(2.0), - top_offset, - width=Inches(9.333), + stream, + Inches(2.0 + ox), + Inches(top_offset + oy), + width=Inches(dw), + height=Inches(dh), ) except Exception: pass + caption = slide_data.get("caption") if caption: tb = slide.shapes.add_textbox( @@ -537,17 +684,26 @@ def render_deck( for p in slide.shapes.title.text_frame.paragraphs: p.font.name = font_heading p.font.color.rgb = accent_rgb - img_stream = _load_image_stream(slide_data.get("image")) - if img_stream: + + raw_bytes = _load_raw_image_bytes( + slide_data.get("image"), fallback_kind="hero" + ) + if raw_bytes: + fit_mode = slide_data.get("image", {}).get("fit", "contain") + stream, ox, oy, dw, dh = _normalize_and_fit_image( + raw_bytes, 5.5, 4.5, fit=fit_mode + ) try: slide.shapes.add_picture( - img_stream, - Inches(1.0), - Inches(1.8), - width=Inches(5.5), + stream, + Inches(1.0 + ox), + Inches(1.8 + oy), + width=Inches(dw), + height=Inches(dh), ) except Exception: pass + body_text = slide_data.get("body", "") if body_text: tb = slide.shapes.add_textbox( @@ -580,9 +736,9 @@ def render_deck( p_a.text = f"— {attrib_text}" p_a.font.name = font_body p_a.font.size = Pt(18) - p_a.font.italic = True + p_a.font.color.rgb = RGBColor(100, 116, 139) - # 8. Table + # 8. Table Slide elif stype == "table": slide = prs.slides.add_slide(prs.slide_layouts[5]) if slide.shapes.title: @@ -590,34 +746,37 @@ def render_deck( for p in slide.shapes.title.text_frame.paragraphs: p.font.name = font_heading p.font.color.rgb = accent_rgb - cols = slide_data.get("columns") or [] - rows = slide_data.get("rows") or [] + + cols = slide_data.get("columns", []) + rows = slide_data.get("rows", []) num_rows = len(rows) + 1 num_cols = len(cols) - if num_cols > 0 and num_rows > 1: + if num_cols > 0: table_shape = slide.shapes.add_table( num_rows, num_cols, - Inches(1.0), + Inches(1.2), Inches(1.8), - Inches(11.333), + Inches(10.933), Inches(0.6 * num_rows), ) tbl = table_shape.table - for c_idx, c_name in enumerate(cols): + for c_idx, col_name in enumerate(cols): cell = tbl.cell(0, c_idx) - cell.text = str(c_name) + cell.text = str(col_name) for p in cell.text_frame.paragraphs: p.font.name = font_heading p.font.bold = True - for r_idx, row_items in enumerate(rows): - for c_idx, val in enumerate(row_items[:num_cols]): - cell = tbl.cell(r_idx + 1, c_idx) - cell.text = str(val) - for p in cell.text_frame.paragraphs: - p.font.name = font_body - - # 9. Chart + p.font.color.rgb = accent_rgb + for r_idx, row in enumerate(rows): + for c_idx, val in enumerate(row): + if c_idx < num_cols: + cell = tbl.cell(r_idx + 1, c_idx) + cell.text = str(val) + for p in cell.text_frame.paragraphs: + p.font.name = font_body + + # 9. Chart Slide elif stype == "chart": slide = prs.slides.add_slide(prs.slide_layouts[5]) if slide.shapes.title: @@ -625,15 +784,11 @@ def render_deck( for p in slide.shapes.title.text_frame.paragraphs: p.font.name = font_heading p.font.color.rgb = accent_rgb - chart_spec = slide_data.get("chart") or {} - kind = chart_spec.get("kind", "bar") - cats = chart_spec.get("categories") or [] - series_list = chart_spec.get("series") or [] - chart_data = CategoryChartData() - chart_data.categories = cats - for s in series_list: - chart_data.add_series(s.get("name", ""), s.get("values", [])) + chart_obj = slide_data.get("chart", {}) + kind = chart_obj.get("kind", "bar").lower() + categories = chart_obj.get("categories", []) + series_list = chart_obj.get("series", []) chart_type_map = { "bar": XL_CHART_TYPE.COLUMN_CLUSTERED, @@ -641,24 +796,257 @@ def render_deck( "pie": XL_CHART_TYPE.PIE, } xl_type = chart_type_map.get(kind, XL_CHART_TYPE.COLUMN_CLUSTERED) + + chart_data = CategoryChartData() + chart_data.categories = [str(c) for c in categories] + for s in series_list: + chart_data.add_series(str(s.get("name", "")), s.get("values", [])) + slide.shapes.add_chart( xl_type, Inches(1.5), Inches(1.8), Inches(10.333), - Inches(5.0), + Inches(4.8), chart_data, ) - # 10. Blank + # 10. Timeline Slide (NEW v0.2) + elif stype == "timeline": + slide = prs.slides.add_slide(prs.slide_layouts[5]) + if slide.shapes.title: + slide.shapes.title.text = title_text + for p in slide.shapes.title.text_frame.paragraphs: + p.font.name = font_heading + p.font.color.rgb = accent_rgb + + items = slide_data.get("items", []) + n_items = len(items) + if n_items > 0: + avail_w = 10.933 + step_w = avail_w / n_items + line_y = 3.3 + + # Horizontal bar + connector = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(1.2), + Inches(line_y), + Inches(avail_w), + Inches(0.04), + ) + connector.fill.solid() + connector.fill.fore_color.rgb = RGBColor(226, 232, 240) + connector.line.fill.background() + + for i, it in enumerate(items): + item_x = 1.2 + i * step_w + card_w = step_w - 0.2 + + # Status dot/pill + dot = slide.shapes.add_shape( + MSO_SHAPE.OVAL, + Inches(item_x + card_w / 2 - 0.12), + Inches(line_y - 0.1), + Inches(0.24), + Inches(0.24), + ) + dot.fill.solid() + status = it.get("status", "planned").lower() + if status == "completed": + dot.fill.fore_color.rgb = RGBColor(16, 185, 129) + elif status == "in_progress": + dot.fill.fore_color.rgb = accent_rgb + else: + dot.fill.fore_color.rgb = RGBColor(148, 163, 184) + dot.line.fill.background() + + # Date text (above) + tb_date = slide.shapes.add_textbox( + Inches(item_x), Inches(2.2), Inches(card_w), Inches(0.8) + ) + p_d = tb_date.text_frame.paragraphs[0] + p_d.text = str(it.get("date", "")) + p_d.font.name = font_heading + p_d.font.bold = True + p_d.font.size = Pt(14) + p_d.font.color.rgb = accent_rgb + + # Title & description (below) + tb_desc = slide.shapes.add_textbox( + Inches(item_x), Inches(3.7), Inches(card_w), Inches(2.5) + ) + tf_d = tb_desc.text_frame + tf_d.word_wrap = True + p_t = tf_d.paragraphs[0] + p_t.text = str(it.get("title", "")) + p_t.font.name = font_heading + p_t.font.bold = True + p_t.font.size = Pt(14) + if it.get("description"): + p_sub = tf_d.add_paragraph() + p_sub.text = str(it.get("description")) + p_sub.font.name = font_body + p_sub.font.size = Pt(11) + p_sub.font.color.rgb = RGBColor(100, 116, 139) + + # 11. Metrics Slide (NEW v0.2) + elif stype == "metrics": + slide = prs.slides.add_slide(prs.slide_layouts[5]) + if slide.shapes.title: + slide.shapes.title.text = title_text + for p in slide.shapes.title.text_frame.paragraphs: + p.font.name = font_heading + p.font.color.rgb = accent_rgb + + metrics_list = slide_data.get("metrics", []) + n_metrics = min(len(metrics_list), 4) + if n_metrics > 0: + avail_w = 11.333 + gap = 0.35 + card_w = (avail_w - (n_metrics - 1) * gap) / n_metrics + for i, m in enumerate(metrics_list[:n_metrics]): + cx = 1.0 + i * (card_w + gap) + + # Card frame + card = slide.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE, + Inches(cx), + Inches(2.2), + Inches(card_w), + Inches(4.2), + ) + card.fill.solid() + card.fill.fore_color.rgb = RGBColor(248, 250, 252) + card.line.color.rgb = RGBColor(226, 232, 240) + card.line.width = Pt(1) + + # Content + tb = slide.shapes.add_textbox( + Inches(cx + 0.15), + Inches(2.5), + Inches(card_w - 0.3), + Inches(3.6), + ) + tf = tb.text_frame + tf.word_wrap = True + + # Big number + p_val = tf.paragraphs[0] + val_str = str(m.get("value", "")) + if m.get("unit"): + val_str = f"{val_str} {m.get('unit')}" + p_val.text = val_str + p_val.font.name = font_heading + p_val.font.size = Pt(36) + p_val.font.bold = True + p_val.font.color.rgb = accent_rgb + + # Trend indicator + delta = m.get("delta") + trend = m.get("trend", "neutral") + if delta: + p_t = tf.add_paragraph() + symbol = ( + "↑ " + if trend == "up" + else ("↓ " if trend == "down" else "→ ") + ) + p_t.text = f"{symbol}{delta}" + p_t.font.name = font_heading + p_t.font.size = Pt(13) + p_t.font.bold = True + if trend == "up": + p_t.font.color.rgb = RGBColor(16, 185, 129) + elif trend == "down": + p_t.font.color.rgb = RGBColor(239, 68, 68) + else: + p_t.font.color.rgb = RGBColor(100, 116, 139) + + # Label + p_lbl = tf.add_paragraph() + p_lbl.text = str(m.get("label", "")) + p_lbl.font.name = font_body + p_lbl.font.size = Pt(14) + p_lbl.font.bold = True + p_lbl.font.color.rgb = RGBColor(71, 85, 105) + + # 12. Comparison Slide (NEW v0.2) + elif stype == "comparison": + slide = prs.slides.add_slide(prs.slide_layouts[3]) + if slide.shapes.title: + slide.shapes.title.text = title_text + for p in slide.shapes.title.text_frame.paragraphs: + p.font.name = font_heading + p.font.color.rgb = accent_rgb + + def _populate_column(tf, content): + tf.word_wrap = True + if isinstance(content, dict): + col_title = content.get("title") + items = content.get("items", []) + started = False + if col_title: + p = tf.paragraphs[0] + p.text = str(col_title) + p.font.name = font_heading + p.font.bold = True + p.font.size = Pt(16) + p.font.color.rgb = accent_rgb + started = True + for item in items: + p = tf.paragraphs[0] if not started else tf.add_paragraph() + p.text = str(item) + p.font.name = font_body + started = True + elif isinstance(content, list): + for idx_item, item in enumerate(content): + p = tf.paragraphs[0] if idx_item == 0 else tf.add_paragraph() + p.text = str(item) + p.font.name = font_body + else: + tf.paragraphs[0].text = str(content) + tf.paragraphs[0].font.name = font_body + + left_content = slide_data.get("left", []) + if len(slide.placeholders) > 1: + _populate_column(slide.placeholders[1].text_frame, left_content) + + right_content = slide_data.get("right", []) + if len(slide.placeholders) > 2: + _populate_column(slide.placeholders[2].text_frame, right_content) + + # 13. Blank else: slide = prs.slides.add_slide(prs.slide_layouts[6]) # Attach speaker notes if present speaker_notes = slide_data.get("speaker_notes") - if speaker_notes: + if speaker_notes and slide: slide.notes_slide.notes_text_frame.text = speaker_notes + # Governance classification ribbon & legal footer + if slide: + if classification: + tb_c = slide.shapes.add_textbox( + Inches(4.5), Inches(0.1), Inches(4.333), Inches(0.3) + ) + p_c = tb_c.text_frame.paragraphs[0] + p_c.text = f"[{classification.upper()}]" + p_c.font.name = font_heading + p_c.font.size = Pt(8) + p_c.font.bold = True + p_c.font.color.rgb = RGBColor(148, 163, 184) + if legal_footer: + tb_f = slide.shapes.add_textbox( + Inches(0.8), Inches(7.1), Inches(11.7), Inches(0.3) + ) + p_f = tb_f.text_frame.paragraphs[0] + p_f.text = str(legal_footer) + p_f.font.name = font_body + p_f.font.size = Pt(8) + p_f.font.color.rgb = RGBColor(148, 163, 184) + rendered_slides_summary.append( { "index": s_idx, diff --git a/skills/creative/deck_builder/instructions.md b/skills/creative/deck_builder/instructions.md index e9deeea..a153868 100644 --- a/skills/creative/deck_builder/instructions.md +++ b/skills/creative/deck_builder/instructions.md @@ -20,6 +20,8 @@ Use this tool when a user or upstream workflow requests an editable slide deck, | `render` | `deck_spec`, `output_path`, `template_id` *(optional)*, `theme` *(optional)*, `strict` *(optional)* | Assembles slides, applies theme tokens, inserts images/charts, writes `.pptx` to disk. | | `inspect` | `input_path` | Reads an existing `.pptx` presentation and returns slide counts, titles, layout names, and notes presence. | | `list_templates` | *(none)* | Enumerates bundled template IDs, names, descriptions, and aspect ratios. | +| `lint_deck` | `deck_spec`, `min_score` *(optional)*, `strict_a11y` *(optional)* | Analyzes deck specification for presentation quality, text density, and accessibility best practices. Returns score (0–100), issue counts, and rule findings. | +| `suggest_outline` | `archetype`, `topic` *(optional)*, `constraints` *(optional)* | Generates a structured slide outline, recommendations, and skeleton `deck_spec` for an archetype (`investor_pitch`, `technical_brief`, `quarterly_review`, `product_launch`, `training_workshop`). | --- @@ -37,7 +39,7 @@ Use this tool when a user or upstream workflow requests an editable slide deck, 4. **`two_column`**: Comparative or two-panel text/bullets. - Keys: `title` (required), `left` (string or array), `right` (string or array), `speaker_notes` (optional). 5. **`image`**: Visual showcase. - - Keys: `title` (optional), `image` (required `{path: ...}` or `{base64: ..., mime_type: ...}`), `caption` (optional), `speaker_notes` (optional). + - Keys: `title` (optional), `image` (required `{path: ...}`, `{base64: ...}`, or `{placeholder_id: ...}`), `caption` (optional), `speaker_notes` (optional). 6. **`image_caption`**: Side-by-side graphic and detailed explanation. - Keys: `title` (required), `image` (required), `body` (required explanatory copy), `speaker_notes` (optional). 7. **`quote`**: Pull-quote or executive testimony. @@ -46,20 +48,35 @@ Use this tool when a user or upstream workflow requests an editable slide deck, - Keys: `title` (required), `columns` (array of header names), `rows` (array of row arrays), `speaker_notes` (optional). 9. **`chart`**: Data visualization. - Keys: `title` (required), `chart` (object with `kind` (`bar`, `line`, `pie`), `categories` (array of labels), `series` (array of `{name: ..., values: [...]}`)), `speaker_notes` (optional). -10. **`blank`**: Clean canvas for freeform editing. +10. **`timeline`**: Milestone roadmap with status badges. + - Keys: `title` (required), `items` (required array of `{date: ..., title: ..., description: ..., status?: "completed"|"in_progress"|"planned"}`), `speaker_notes` (optional). +11. **`metrics`**: KPI big number cards with trend indicators. + - Keys: `title` (required), `metrics` (required array of `{value: ..., label: ..., delta?: ..., trend?: "up"|"down"|"neutral"}`), `speaker_notes` (optional). +12. **`comparison`**: Structured comparison cards or pros/cons matrix. + - Keys: `title` (required), `left` (object with `title` and `items`), `right` (object with `title` and `items`), `speaker_notes` (optional). +13. **`blank`**: Clean canvas for freeform editing. - Keys: `speaker_notes` (optional). --- -## Image Handling & Asset Strategy - +## Image Handling, Placeholders & Fit Policies + +- **Offline-First & Transposition**: + Images are processed locally using Pillow. EXIF orientation tags are automatically normalized and non-RGB color spaces (CMYK, Grayscale, RGBA) are converted cleanly. +- **Image Fit Policies**: + Each image can specify `"fit"`: + - `"contain"` *(default)*: Scales proportionally to fit within bounding box, centered with letterboxing/pillarboxing. + - `"cover"` or `"crop_center"`: Crops image symmetrically from center to fill target aspect ratio without distortion. + - `"stretch"`: Scales directly to bounding box dimensions. + - `"native"`: Keeps native dimensions bounded by slide boundaries. +- **Offline Placeholders**: + When an asset is not yet available, specify `"placeholder_id"`: + - Supported IDs: `"hero"`, `"logo"`, `"icon"`, `"chart_backdrop"`, `"headshot"`. + - Placeholders are procedurally generated via Pillow with neutral monochrome branding, subtle grid lines, and an icon badge. No network connection is required. + - Optionally provide `"placeholder_prompt"` to describe the planned image for later substitution by an image generation pipeline. - **Paths and Base64 Only (No Remote URLs)**: - `image.path` must point to an existing local file on the filesystem (e.g. `/tmp/logo.png`), and `image.base64` must contain valid base64-encoded image bytes. -- **Do NOT pass `http://` or `https://` URLs in `image.path`**: - `creative/deck_builder` is strictly offline and will **not** fetch network URLs. Passing a URL will trigger an `ASSET_NOT_FOUND` warning and the slide will render text-only. -- **Host Agent Responsibility**: - If the user supplies a remote image URL, your host agent loop or toolchain must download the image to a local temporary file (or encode it to Base64) before calling `validate_spec` or `render`. -- **Supported Formats**: PNG, JPEG, WEBP (validated locally via Pillow). + `image.path` must point to an existing local file on the filesystem, and `image.base64` must contain valid base64-encoded image bytes. Remote HTTP/HTTPS URLs are rejected with `ASSET_NOT_FOUND`. +- **Supported Formats**: PNG, JPEG, WEBP. --- @@ -71,13 +88,57 @@ Use this tool when a user or upstream workflow requests an editable slide deck, --- +## Quality Gates & Linting (`lint_deck`) + +Autonomous agent decks often suffer from text bloat, missing titles, or monotonous slides. Call `action="lint_deck"` to run deterministic heuristic checks across the `deck_spec`: + +- **Score Calculation**: Starts at `100`. Warnings deduct 5 points, errors deduct 15 points. +- **Rule Codes**: + - `WALL_OF_TEXT`: A bullet exceeds 200 characters or slide exceeds 600 total text characters. + - `DECK_TOO_LONG`: Slide count exceeds 30 slides. + - `MISSING_ALT`: Image without an `alt` tag (evaluated when `strict_a11y: true`). + - `ORPHAN_BULLET`: Slide has exactly 1 bullet point (recommend >= 2 or convert to statement). + - `EMPTY_TITLE`: Slide title is missing, empty, or whitespace. + - `CHART_NO_TITLE`: Chart slide missing title. + - `METRIC_WITHOUT_LABEL`: Metric entry contains a value but no descriptive label. + - `LOW_SLIDE_COUNT`: Deck contains fewer than 3 slides. + - `MONOTONOUS_LAYOUT`: 4 or more consecutive slides share the exact same layout type. +- **Strict Enforcement**: Pass `min_score` (e.g. `80`) to automatically fail `passed: false` if score falls below threshold. + +--- + +## Archetype Outlines (`suggest_outline`) + +Use `action="suggest_outline"` to bootstrap structured presentations: + +- **Supported Archetypes**: + - `investor_pitch`: Problem, Solution, Market Size, Product Demo, Business Model, Traction, Team, Ask. + - `technical_brief`: Executive Summary, Architecture Overview, Deep Dive, Benchmarks, Security & Compliance, Deployment Roadmap. + - `quarterly_review`: Executive Summary, KPI Scorecard, Quarterly Achievements, Roadmap Timeline, Challenges & Learnings, Next Quarter Priorities. + - `product_launch`: Vision & Value Prop, Feature Highlights, Architecture & Metrics, Customer Testimonial, Availability Timeline, Call to Action. + - `training_workshop`: Agenda & Objectives, Core Concept Deep Dive, Architecture Walkthrough, Comparative Analysis, Key Takeaways, Hands-on Next Steps. +- **Constraint Filtering**: Pass `constraints` (e.g. `["no pricing", "confidential"]`) to filter out sensitive sections (e.g., pricing or financial tiers). +- **Output**: Returns recommended slide types, narrative guidance, and a pre-structured `deck_spec` skeleton ready for agent population. + +--- + +## Enterprise Governance + +For enterprise compliance and corporate identity: + +- **Document Classification**: Pass `"classification": "CONFIDENTIAL" | "INTERNAL" | "PUBLIC" | "RESTRICTED"` under `deck_spec.metadata` (or at root for compatibility). Renders a standardized classification ribbon header on each slide. +- **Legal Footer**: Pass `"legal_footer"` under `deck_spec.metadata` (or at root) to stamp a legal disclaimer across the footer of all slides. + +--- + ## Recommended Agent Workflow -1. **Plan & Draft**: Generate the deck narrative and structure in your conversation context. -2. **Pre-flight Validation**: Call `creative/deck_builder` with `action="validate_spec"`. -3. **Review Warnings**: If warnings are emitted (e.g. `BULLET_TRUNCATED`), adjust copy lengths if desired. -4. **Render**: Call `action="render"` specifying `output_path` (e.g. `/tmp/quarterly_review.pptx`). -5. **Report to User**: Return the file path, slide count, and slide titles. +1. **Bootstrap Outline**: Call `creative/deck_builder` with `action="suggest_outline"`, selecting an `archetype` and specifying constraints. +2. **Draft Content**: Populate slide copy, metrics, comparison points, and timeline items into the skeleton `deck_spec`. +3. **Pre-flight Validation**: Call `action="validate_spec"` to catch schema errors and text truncations. +4. **Quality Linting**: Call `action="lint_deck"` with `min_score=80` to verify text density, readability, and visual variety. +5. **Render Presentation**: Call `action="render"` specifying `output_path` (e.g. `/tmp/briefing.pptx`). +6. **Report to User**: Return file path, slide count, quality score, and slide titles. --- diff --git a/skills/creative/deck_builder/lint.py b/skills/creative/deck_builder/lint.py new file mode 100644 index 0000000..0aa5f1c --- /dev/null +++ b/skills/creative/deck_builder/lint.py @@ -0,0 +1,213 @@ +"""Presentation quality heuristics and linting for creative/deck_builder.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +def lint_deck( + deck_spec: Dict[str, Any], + options: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Evaluates presentation quality heuristics beyond basic schema validation. + Returns a score (0-100), boolean passed status, and actionable findings. + Executes entirely offline. + """ + options = options or {} + min_score = options.get("min_score", 70) + strict_a11y = options.get("strict_a11y", False) + + findings: List[Dict[str, Any]] = [] + slides = deck_spec.get("slides", []) + total_slides = len(slides) + + # Deck-level checks + if total_slides < 3: + findings.append( + { + "code": "LOW_SLIDE_COUNT", + "rule": "Minimum Deck Length", + "severity": "warning", + "slide_index": -1, + "message": f"Deck has only {total_slides} slide(s). Complete decks typically have at least 3 slides.", + "suggestion": "Add cover title, body content, and conclusion or Q&A slide.", + } + ) + elif total_slides > 25: + findings.append( + { + "code": "DECK_TOO_LONG", + "rule": "Maximum Deck Length", + "severity": "warning", + "slide_index": -1, + "message": f"Deck contains {total_slides} slides, which may exceed standard audience attention spans.", + "suggestion": "Condense secondary points into an appendix or combine related slides.", + } + ) + + # Visual monotony: check consecutive layout repetition + consecutive_layout = None + consecutive_count = 0 + for idx, slide in enumerate(slides): + stype = slide.get("type", "unknown") + if stype == consecutive_layout and stype not in ("blank", "section"): + consecutive_count += 1 + if consecutive_count == 3: + findings.append( + { + "code": "MONOTONOUS_LAYOUT", + "rule": "Layout Variety", + "severity": "warning", + "slide_index": idx, + "message": f"Three consecutive slides use layout '{stype}'.", + "suggestion": ( + "Vary slide presentation by introducing a chart, comparison, quote, or metrics slide." + ), + } + ) + else: + consecutive_layout = stype + consecutive_count = 1 + + # Slide-by-slide checks + for idx, slide in enumerate(slides): + stype = slide.get("type", "blank") + title = slide.get("title", "") + + # Check empty title on slides requiring headers + if stype not in ("blank", "quote") and (not title or not str(title).strip()): + findings.append( + { + "code": "EMPTY_TITLE", + "rule": "Slide Title Requirement", + "severity": "warning", + "slide_index": idx, + "message": f"Slide {idx + 1} ({stype}) lacks a descriptive title.", + "suggestion": "Add a clear, action-oriented headline to orient the audience.", + } + ) + + # Bullets checks + if stype == "bullets": + bullets = slide.get("bullets", []) + if len(bullets) == 1: + findings.append( + { + "code": "ORPHAN_BULLET", + "rule": "Bullet List Density", + "severity": "warning", + "slide_index": idx, + "message": f"Slide {idx + 1} contains only a single bullet point.", + "suggestion": ( + "Convert single bullet to subtitle or callout quote, or add supporting bullet points." + ), + } + ) + elif len(bullets) > 6: + findings.append( + { + "code": "WALL_OF_TEXT", + "rule": "Bullet Overload", + "severity": "warning", + "slide_index": idx, + "message": f"Slide {idx + 1} has {len(bullets)} bullets, exceeding recommended limit of 6.", + "suggestion": "Trim to top 4-5 takeaways or split into a two-column or multi-slide layout.", + } + ) + + for b_idx, bullet in enumerate(bullets): + if len(str(bullet)) > 140: + findings.append( + { + "code": "WALL_OF_TEXT", + "rule": "Bullet Character Cap", + "severity": "warning", + "slide_index": idx, + "message": ( + f"Slide {idx + 1} bullet {b_idx + 1} has {len(str(bullet))} chars (exceeds 140)." + ), + "suggestion": ( + "Shorten bullet text to concise phrases; move narrative detail to speaker_notes." + ), + } + ) + + # Chart checks + elif stype == "chart": + chart = slide.get("chart", {}) + if not chart.get("categories"): + findings.append( + { + "code": "CHART_NO_TITLE", + "rule": "Chart Completeness", + "severity": "warning", + "slide_index": idx, + "message": f"Slide {idx + 1} chart has no category labels.", + "suggestion": "Specify categories for all data points.", + } + ) + for s_idx, s in enumerate(chart.get("series", [])): + if not s.get("name"): + findings.append( + { + "code": "CHART_NO_TITLE", + "rule": "Series Naming", + "severity": "warning", + "slide_index": idx, + "message": f"Slide {idx + 1} chart series {s_idx + 1} is missing a series name.", + "suggestion": "Assign a legend label to every data series.", + } + ) + + # Metrics checks + elif stype == "metrics": + metrics = slide.get("metrics", []) + for m_idx, m in enumerate(metrics): + if not m.get("label"): + findings.append( + { + "code": "METRIC_WITHOUT_LABEL", + "rule": "Metric Labeling", + "severity": "warning", + "slide_index": idx, + "message": f"Slide {idx + 1} metric {m_idx + 1} lacks a contextual label.", + "suggestion": "Provide a descriptive label clarifying what the metric represents.", + } + ) + + # Image accessibility checks + if "image" in slide and isinstance(slide["image"], dict): + img_obj = slide["image"] + if strict_a11y and not img_obj.get("alt") and not img_obj.get("caption"): + findings.append( + { + "code": "MISSING_ALT", + "rule": "Accessibility Alt-Text", + "severity": "warning", + "slide_index": idx, + "message": f"Slide {idx + 1} image is missing alt text or caption.", + "suggestion": "Add an 'alt' description for screen readers and accessibility audits.", + } + ) + + # Calculate quality score: start from 100, deduct points per finding + deductions = sum(10 if f["severity"] == "error" else 5 for f in findings) + score = max(0, min(100, 100 - deductions)) + + errors = [f for f in findings if f.get("severity") == "error"] + warnings = [f for f in findings if f.get("severity") == "warning"] + + return { + "success": True, + "action": "lint_deck", + "score": score, + "passed": score >= min_score, + "min_score": min_score, + "slide_count": total_slides, + "findings_count": len(findings), + "findings": findings, + "issues": findings, + "errors": errors, + "warnings": warnings, + } diff --git a/skills/creative/deck_builder/manifest.yaml b/skills/creative/deck_builder/manifest.yaml index 236ed55..0aeadb9 100644 --- a/skills/creative/deck_builder/manifest.yaml +++ b/skills/creative/deck_builder/manifest.yaml @@ -1,5 +1,5 @@ name: "creative/deck_builder" -version: "0.1.0" +version: "0.2.0" description: "Deterministic PowerPoint (.pptx) assembly from structured deck specs." short_description: "Assemble editable PPTX presentations from structured JSON deck specs." @@ -20,12 +20,14 @@ parameters: properties: action: type: string - description: "Operation to perform: validate_spec (default), render, inspect, list_templates." + description: "Operation to perform: validate_spec (default), render, inspect, list_templates, lint_deck, suggest_outline." enum: - validate_spec - render - inspect - list_templates + - lint_deck + - suggest_outline default: validate_spec deck_spec: @@ -62,6 +64,36 @@ parameters: type: boolean description: "When true, warnings are treated as validation errors (default: false)." default: false + + min_score: + type: integer + description: "Minimum quality score threshold (0-100) for action='lint_deck'." + default: 70 + + strict_a11y: + type: boolean + description: "Enforce strict accessibility rules (e.g. require image alt text) for action='lint_deck'." + default: false + + archetype: + type: string + description: "Presentation archetype for action='suggest_outline' (investor_pitch, technical_brief, quarterly_review, product_launch, training_workshop)." + enum: + - investor_pitch + - technical_brief + - quarterly_review + - product_launch + - training_workshop + + topic: + type: string + description: "Topic or subject line used to customize the suggested outline skeleton for action='suggest_outline'." + + constraints: + type: array + description: "Negative constraints or topics to omit for action='suggest_outline' (e.g. ['no pricing', 'no financial'])." + items: + type: string required: [] outputs: @@ -135,6 +167,56 @@ outputs: items: type: object + score: + type: integer + description: "Calculated presentation quality score (0-100) for action='lint_deck'." + + passed: + type: boolean + description: "True when quality score meets or exceeds min_score for action='lint_deck'." + + findings: + type: array + description: "List of heuristic rule findings and improvement suggestions for action='lint_deck'." + items: + type: object + + issues: + type: array + description: "Alias for findings for action='lint_deck'." + items: + type: object + + archetype: + type: string + description: "Archetype identifier used for action='suggest_outline'." + + name: + type: string + description: "Display name of the suggested archetype outline." + + description: + type: string + description: "Summary description of the archetype narrative structure." + + recommended_slide_count: + type: integer + description: "Recommended number of slides in the suggested outline." + + constraints_applied: + type: array + description: "List of negative constraints applied during outline suggestion." + items: + type: string + + deck_spec: + type: object + description: "Generated skeleton deck_spec for action='suggest_outline'." + + deck_spec_skeleton: + type: object + description: "Alias for deck_spec skeleton for action='suggest_outline'." + constitution: LOCAL_ASSEMBLY: "Never call network APIs or external LLMs from execute()." DETERMINISTIC: "Same valid deck_spec + template produces reproducible slide structure and text content." diff --git a/skills/creative/deck_builder/placeholders.py b/skills/creative/deck_builder/placeholders.py new file mode 100644 index 0000000..fdb1bcf --- /dev/null +++ b/skills/creative/deck_builder/placeholders.py @@ -0,0 +1,137 @@ +"""Deterministic offline placeholder generation for creative/deck_builder.""" + +from __future__ import annotations + +import io +from typing import Optional +from PIL import Image, ImageDraw, ImageFont + +_DEFAULT_BG = (240, 243, 246) +_DEFAULT_BORDER = (203, 213, 225) +_DEFAULT_TEXT = (100, 116, 139) +_ACCENT_COLOR = (110, 87, 224) + + +def generate_placeholder( + kind: str = "hero", + prompt: Optional[str] = None, + width: int = 800, + height: int = 450, + watermark: bool = True, +) -> io.BytesIO: + """ + Generates a deterministic, neutral placeholder image. + + Supported kinds: + - hero: Wide 16:9 scenic/graphic backdrop with subtle geometric styling + - logo: Square/badge frame for brand marks + - icon: Minimal badge placeholder + - chart_backdrop: Subtle coordinate axes / grid visualization + - headshot: Neutral silhouette profile for team slides + """ + kind = (kind or "hero").lower().strip() + if kind == "logo": + width, height = min(width, 400), min(height, 400) + elif kind == "headshot": + width, height = min(width, 400), min(height, 400) + elif kind == "icon": + width, height = min(width, 256), min(height, 256) + + img = Image.new("RGBA", (width, height), color=_DEFAULT_BG) + draw = ImageDraw.Draw(img) + + # Outer border + draw.rectangle([(0, 0), (width - 1, height - 1)], outline=_DEFAULT_BORDER, width=2) + + font = ImageFont.load_default() + + if kind == "hero": + # Draw soft abstract geometric layers + draw.polygon( + [ + (0, height), + (width // 3, height // 2), + (width * 2 // 3, height * 3 // 4), + (width, height // 3), + (width, height), + ], + fill=(226, 232, 240), + ) + draw.polygon( + [ + (0, height), + (width // 4, height * 2 // 3), + (width // 2, height), + (width, height), + ], + fill=(241, 245, 249), + ) + elif kind == "logo": + # Centered shield / badge + cx, cy = width // 2, height // 2 + pad = min(width, height) // 4 + draw.rounded_rectangle( + [(cx - pad, cy - pad), (cx + pad, cy + pad)], + radius=12, + outline=_ACCENT_COLOR, + width=3, + fill=(248, 250, 252), + ) + elif kind == "headshot": + # Silhouette head & shoulders + cx, cy = width // 2, height // 2 + r = min(width, height) // 5 + # Head + draw.ellipse( + [(cx - r, cy - r - r // 2), (cx + r, cy + r - r // 2)], fill=(203, 213, 225) + ) + # Shoulders + draw.ellipse( + [(cx - r * 2, cy + r // 2), (cx + r * 2, cy + r * 3)], + fill=(203, 213, 225), + ) + elif kind == "chart_backdrop": + # Axes and subtle grid + pad = 40 + draw.line([(pad, pad), (pad, height - pad)], fill=_DEFAULT_BORDER, width=2) + draw.line( + [(pad, height - pad), (width - pad, height - pad)], + fill=_DEFAULT_BORDER, + width=2, + ) + for i in range(1, 4): + y = pad + (height - 2 * pad) * i // 4 + draw.line([(pad, y), (width - pad, y)], fill=(226, 232, 240), width=1) + + # Caption / label + label = prompt if prompt else f"Placeholder: {kind.capitalize()}" + if len(label) > 60: + label = label[:57] + "..." + + # Center text + bbox = font.getbbox(label) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + tx = (width - tw) // 2 + ty = height - th - 24 + + # Label background pill + draw.rounded_rectangle( + [(tx - 12, ty - 6), (tx + tw + 12, ty + th + 6)], + radius=6, + fill=(255, 255, 255, 220), + outline=_DEFAULT_BORDER, + width=1, + ) + draw.text((tx, ty), label, fill=_DEFAULT_TEXT, font=font) + + if watermark: + wm_text = "REPLACE BEFORE DISTRIBUTION" + wm_bbox = font.getbbox(wm_text) + wm_w = wm_bbox[2] - wm_bbox[0] + draw.text(((width - wm_w) // 2, 12), wm_text, fill=(148, 163, 184), font=font) + + buf = io.BytesIO() + img.convert("RGB").save(buf, format="PNG") + buf.seek(0) + return buf diff --git a/skills/creative/deck_builder/schemas/deck_spec.schema.json b/skills/creative/deck_builder/schemas/deck_spec.schema.json index e200882..abc7dbf 100644 --- a/skills/creative/deck_builder/schemas/deck_spec.schema.json +++ b/skills/creative/deck_builder/schemas/deck_spec.schema.json @@ -1,9 +1,14 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/ARPAHLS/skillware/main/skills/creative/deck_builder/schemas/deck_spec.schema.json", "title": "DeckSpec", - "description": "Structured presentation specification for creative/deck_builder.", + "description": "Structured presentation specification for creative/deck_builder v0.2.", "type": "object", "properties": { + "version": { + "type": "string", + "description": "Deck specification version format (e.g. '1.0' or '1.1')." + }, "title": { "type": "string", "description": "Overall presentation title." @@ -15,17 +20,24 @@ }, "theme": { "type": "object", - "description": "Theme token overrides.", + "description": "Theme tokens and typography overrides.", "properties": { "accent_color": { "type": "string" }, "font_heading": { "type": "string" }, "font_body": { "type": "string" } }, - "additionalProperties": false + "additionalProperties": true }, "metadata": { "type": "object", - "description": "Document metadata." + "description": "Document metadata, governance classifications, and audit info.", + "properties": { + "classification": { "type": "string", "enum": ["PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED"] }, + "author": { "type": "string" }, + "company": { "type": "string" }, + "subject": { "type": "string" }, + "legal_footer": { "type": "string" } + } }, "slides": { "type": "array", @@ -47,6 +59,9 @@ "quote", "table", "chart", + "timeline", + "metrics", + "comparison", "blank" ] }, @@ -60,13 +75,27 @@ "left": { "oneOf": [ { "type": "string" }, - { "type": "array", "items": { "type": "string" } } + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "properties": { + "title": { "type": "string" }, + "items": { "type": "array", "items": { "type": "string" } } + } + } ] }, "right": { "oneOf": [ { "type": "string" }, - { "type": "array", "items": { "type": "string" } } + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "properties": { + "title": { "type": "string" }, + "items": { "type": "array", "items": { "type": "string" } } + } + } ] }, "image": { @@ -74,7 +103,11 @@ "properties": { "path": { "type": "string" }, "base64": { "type": "string" }, - "mime_type": { "type": "string" } + "mime_type": { "type": "string" }, + "placeholder_id": { "type": "string", "enum": ["hero", "logo", "icon", "chart_backdrop", "headshot"] }, + "placeholder_prompt": { "type": "string" }, + "fit": { "type": "string", "enum": ["contain", "cover", "crop_center", "stretch", "native"] }, + "alt": { "type": "string" } } }, "caption": { "type": "string" }, @@ -118,6 +151,35 @@ } } } + }, + "items": { + "type": "array", + "description": "Milestones for timeline slide.", + "items": { + "type": "object", + "required": ["date", "title"], + "properties": { + "date": { "type": "string" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "status": { "type": "string", "enum": ["completed", "in_progress", "planned"] } + } + } + }, + "metrics": { + "type": "array", + "description": "Metrics array for KPI big numbers slide.", + "items": { + "type": "object", + "required": ["value", "label"], + "properties": { + "value": { "type": "string" }, + "label": { "type": "string" }, + "delta": { "type": "string" }, + "trend": { "type": "string", "enum": ["up", "down", "neutral"] }, + "unit": { "type": "string" } + } + } } } } diff --git a/skills/creative/deck_builder/skill.py b/skills/creative/deck_builder/skill.py index 7359fdc..93d11ac 100644 --- a/skills/creative/deck_builder/skill.py +++ b/skills/creative/deck_builder/skill.py @@ -11,9 +11,13 @@ try: from .builder import inspect_deck, list_templates, render_deck, validate_spec + from .lint import lint_deck + from .archetypes import suggest_outline except ImportError: sys.path.insert(0, os.path.dirname(__file__)) from builder import inspect_deck, list_templates, render_deck, validate_spec + from lint import lint_deck + from archetypes import suggest_outline class DeckBuilderSkill(BaseSkill): @@ -114,6 +118,30 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: if action == "list_templates": return list_templates() + if action == "lint_deck": + deck_spec = params.get("deck_spec") + if not deck_spec: + return { + "success": False, + "action": "lint_deck", + "score": 0, + "passed": False, + "error_code": "DECK_SPEC_MISSING", + "message": "deck_spec is required for lint_deck action.", + "findings": [], + } + options = { + "min_score": params.get("min_score", 70), + "strict_a11y": params.get("strict_a11y", False), + } + return lint_deck(deck_spec, options) + + if action == "suggest_outline": + archetype = params.get("archetype", "investor_pitch") + topic = params.get("topic", "") + constraints = params.get("constraints", []) + return suggest_outline(archetype, topic, constraints) + return { "success": False, "action": str(action), @@ -125,7 +153,7 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: "slide_index": -1, "message": ( f"Action '{action}' is not supported. " - "Use validate_spec, render, inspect, or list_templates." + "Use validate_spec, render, inspect, list_templates, lint_deck, or suggest_outline." ), } ], diff --git a/skills/creative/deck_builder/test_skill.py b/skills/creative/deck_builder/test_skill.py index 5b55ed2..a51b898 100644 --- a/skills/creative/deck_builder/test_skill.py +++ b/skills/creative/deck_builder/test_skill.py @@ -25,7 +25,7 @@ def sample_base64_png(): def test_manifest_loads_and_declares_requirements(skill): manifest = skill.manifest assert manifest["name"] == "creative/deck_builder" - assert manifest["version"] == "0.1.0" + assert manifest["version"] == "0.2.0" assert manifest["category"] == "creative" assert "python-pptx>=1.0.0" in manifest["requirements"] assert "pillow" in manifest["requirements"] @@ -428,7 +428,330 @@ def test_constitution_offline_no_remote_apis(): "requests.post", "urllib.request", ) - for name in ("skill.py", "builder.py"): + for name in ( + "skill.py", + "builder.py", + "placeholders.py", + "archetypes.py", + "lint.py", + ): text = open(os.path.join(root, name), encoding="utf-8").read().lower() for token in banned: assert token not in text, f"Found banned remote token '{token}' in {name}" + + +def test_suggest_outline_all_archetypes(skill): + archetypes = [ + "investor_pitch", + "technical_brief", + "quarterly_review", + "product_launch", + "training_workshop", + ] + for arch in archetypes: + res = skill.execute({"action": "suggest_outline", "archetype": arch}) + assert res["success"] is True + assert res["archetype"] == arch + assert "deck_spec" in res + spec = res["deck_spec"] + assert len(spec["slides"]) >= 4 + # Validate that generated skeleton conforms to schema + val = skill.execute({"action": "validate_spec", "deck_spec": spec}) + assert val["valid"] is True + + +def test_suggest_outline_negative_constraints(skill): + # Test filtering out pricing/financials + res_no_pricing = skill.execute( + { + "action": "suggest_outline", + "archetype": "investor_pitch", + "constraints": ["no pricing", "no business model"], + } + ) + assert res_no_pricing["success"] is True + titles = [s.get("title", "").lower() for s in res_no_pricing["deck_spec"]["slides"]] + assert not any("business model" in t for t in titles) + + +def test_lint_deck_perfect_score(skill): + clean_spec = { + "title": "Clean Presentation", + "slides": [ + {"type": "title", "title": "Welcome", "subtitle": "Intro"}, + { + "type": "bullets", + "title": "Overview", + "bullets": ["Item one", "Item two"], + }, + { + "type": "metrics", + "title": "KPIs", + "metrics": [{"value": "99.9%", "label": "Uptime"}], + }, + { + "type": "timeline", + "title": "Roadmap", + "items": [{"date": "Q1", "title": "Alpha", "description": "Release"}], + }, + ], + } + res = skill.execute({"action": "lint_deck", "deck_spec": clean_spec}) + assert res["success"] is True + assert res["score"] == 100 + assert res["passed"] is True + assert len(res["errors"]) == 0 + assert len(res["warnings"]) == 0 + + +def test_lint_deck_detects_violations(skill): + dirty_spec = { + "title": "Flawed Presentation", + "slides": [ + {"type": "title", "title": ""}, # EMPTY_TITLE + { + "type": "bullets", + "title": "Text Heavy", + "bullets": [ + "A" * 220, # WALL_OF_TEXT + ], # ORPHAN_BULLET (only 1 item) + }, + { + "type": "metrics", + "title": "Missing Labels", + "metrics": [{"value": "$10M"}], # METRIC_WITHOUT_LABEL + }, + ], + } + res = skill.execute({"action": "lint_deck", "deck_spec": dirty_spec}) + assert res["success"] is True + assert res["score"] < 100 + issue_codes = [i["code"] for i in res["issues"]] + assert "EMPTY_TITLE" in issue_codes + assert "WALL_OF_TEXT" in issue_codes + assert "ORPHAN_BULLET" in issue_codes + assert "METRIC_WITHOUT_LABEL" in issue_codes + + +def test_lint_deck_min_score_and_a11y(skill): + spec = { + "title": "Short Deck", + "slides": [ + {"type": "title", "title": "Deck Title"}, + { + "type": "image", + "title": "Photo", + "image": {"placeholder_id": "hero"}, + }, # MISSING_ALT in strict_a11y + ], # LOW_SLIDE_COUNT (<3) + } + # strict_a11y check + res = skill.execute( + {"action": "lint_deck", "deck_spec": spec, "strict_a11y": True, "min_score": 95} + ) + assert res["success"] is True + assert res["passed"] is False # score should be < 95 + issue_codes = [i["code"] for i in res["issues"]] + assert "LOW_SLIDE_COUNT" in issue_codes + assert "MISSING_ALT" in issue_codes + + +def test_render_v020_new_layouts(skill, tmp_path): + out_file = tmp_path / "v020_layouts.pptx" + spec = { + "title": "v0.2.0 Layout Showcase", + "metadata": { + "classification": "CONFIDENTIAL", + "legal_footer": "Internal Confidential - Do Not Distribute", + }, + "slides": [ + { + "type": "title", + "title": "v0.2.0 Showcase", + "subtitle": "New Layouts and Fit Policies", + "image": {"placeholder_id": "logo", "fit": "contain"}, + }, + { + "type": "timeline", + "title": "Product Roadmap", + "items": [ + { + "date": "Q1 2026", + "title": "Phase 1: Alpha", + "description": "Core layout architecture", + "status": "completed", + }, + { + "date": "Q2 2026", + "title": "Phase 2: Beta", + "description": "Smart placeholders and linting", + "status": "in_progress", + }, + { + "date": "Q3 2026", + "title": "Phase 3: GA", + "description": "Enterprise rollout", + "status": "planned", + }, + ], + "speaker_notes": "Highlight phase 2 progress.", + }, + { + "type": "metrics", + "title": "Quarterly Performance", + "metrics": [ + { + "value": "99.98%", + "label": "System Availability", + "delta": "+0.4%", + "trend": "up", + }, + { + "value": "$4.8M", + "label": "Annualized Run Rate", + "delta": "+28% YoY", + "trend": "up", + }, + { + "value": "12 ms", + "label": "p99 Execution Latency", + "delta": "-5 ms", + "trend": "down", + }, + { + "value": "1,420", + "label": "Active Organizations", + "trend": "neutral", + }, + ], + "speaker_notes": "Notice all key metrics beating targets.", + }, + { + "type": "comparison", + "title": "Architectural Comparison", + "left": { + "title": "Legacy Approach", + "items": [ + "Manual slide deck authoring", + "Unstructured copy-paste errors", + "No quality linting", + ], + }, + "right": { + "title": "Skillware Platform", + "items": [ + "Deterministic code-driven generation", + "Automated pre-flight schema checks", + "Zero remote network calls", + ], + }, + "speaker_notes": "Clear differentiation on deterministic reliability.", + }, + { + "type": "image", + "title": "Hero Placeholder", + "image": { + "placeholder_id": "hero", + "fit": "cover", + "alt": "Hero architecture diagram", + }, + "caption": "Figure: Architectural schematic", + }, + ], + } + + # Validate + val = skill.execute({"action": "validate_spec", "deck_spec": spec}) + assert val["valid"] is True + + # Lint + lint = skill.execute({"action": "lint_deck", "deck_spec": spec}) + assert lint["passed"] is True + assert lint["score"] >= 95 + + # Render + render = skill.execute( + {"action": "render", "deck_spec": spec, "output_path": str(out_file)} + ) + assert render["success"] is True + assert render["slide_count"] == 5 + assert render["slides"][1]["type"] == "timeline" + assert render["slides"][2]["type"] == "metrics" + assert render["slides"][3]["type"] == "comparison" + assert out_file.is_file() + assert out_file.stat().st_size > 1000 + + # Inspect + inspect_res = skill.execute({"action": "inspect", "input_path": str(out_file)}) + assert inspect_res["success"] is True + assert inspect_res["slide_count"] == 5 + assert inspect_res["slides"][1]["layout_name"] is not None + assert inspect_res["slides"][1]["has_notes"] is True + + +def test_governance_ribbon_and_footer_rendered(skill, tmp_path): + import pptx + + out_file = tmp_path / "gov_rendered.pptx" + spec = { + "title": "Governance Presentation", + "metadata": { + "classification": "RESTRICTED", + "legal_footer": "Proprietary and Confidential - Skillware", + }, + "slides": [ + { + "type": "title", + "title": "Cover Slide", + "subtitle": "Confidential Overview", + }, + {"type": "bullets", "title": "Key Items", "bullets": ["Item A", "Item B"]}, + ], + } + + render = skill.execute( + {"action": "render", "deck_spec": spec, "output_path": str(out_file)} + ) + assert render["success"] is True + assert out_file.is_file() + + # Inspect rendered pptx shapes directly to guarantee ribbon and footer are not no-ops + prs = pptx.Presentation(str(out_file)) + assert len(prs.slides) == 2 + for s in prs.slides: + texts = [] + for shape in s.shapes: + if shape.has_text_frame: + for p in shape.text_frame.paragraphs: + texts.append(p.text) + assert any( + "[RESTRICTED]" in t for t in texts + ), f"Missing classification ribbon in {texts}" + assert any( + "Proprietary and Confidential - Skillware" in t for t in texts + ), f"Missing legal footer in {texts}" + + # Also test backward-compatible root fallback + out_file_root = tmp_path / "gov_root_fallback.pptx" + spec_root = { + "title": "Root Fallback Governance", + "classification": "INTERNAL", + "legal_footer": "Internal Only", + "slides": [ + {"type": "title", "title": "Root Cover"}, + ], + } + render_root = skill.execute( + {"action": "render", "deck_spec": spec_root, "output_path": str(out_file_root)} + ) + assert render_root["success"] is True + prs_root = pptx.Presentation(str(out_file_root)) + root_texts = [ + p.text + for s in prs_root.slides + for shape in s.shapes + if shape.has_text_frame + for p in shape.text_frame.paragraphs + ] + assert any("[INTERNAL]" in t for t in root_texts) + assert any("Internal Only" in t for t in root_texts) diff --git a/tests/skills/creative/test_deck_builder.py b/tests/skills/creative/test_deck_builder.py index a41f51a..5dc8f81 100644 --- a/tests/skills/creative/test_deck_builder.py +++ b/tests/skills/creative/test_deck_builder.py @@ -8,7 +8,7 @@ def test_deck_builder_manifest_and_bundle_load(): bundle = SkillLoader.load_skill("creative/deck_builder") assert bundle["manifest"]["name"] == "creative/deck_builder" assert bundle["manifest"]["category"] == "creative" - assert bundle["manifest"]["version"] == "0.1.0" + assert bundle["manifest"]["version"] == "0.2.0" assert "pitch_v1" in bundle["instructions"] assert bundle["card"]["name"] == "Deck Builder" @@ -65,3 +65,28 @@ def test_deck_builder_loader_execute_workflow(tmp_path: Path): tpl_res = skill.execute({"action": "list_templates"}) assert tpl_res["success"] is True assert len(tpl_res["templates"]) >= 3 + + +def test_deck_builder_loader_suggest_outline_and_lint(): + bundle = SkillLoader.load_skill("creative/deck_builder") + skill = bundle["class"]() + + # 1. Suggest outline + outline_res = skill.execute( + { + "action": "suggest_outline", + "archetype": "investor_pitch", + "topic": "Skillware AI Engine", + "constraints": ["no pricing"], + } + ) + assert outline_res["success"] is True + assert outline_res["archetype"] == "investor_pitch" + deck_spec = outline_res["deck_spec"] + assert len(deck_spec["slides"]) >= 5 + + # 2. Lint outline + lint_res = skill.execute({"action": "lint_deck", "deck_spec": deck_spec}) + assert lint_res["success"] is True + assert lint_res["score"] >= 80 + assert lint_res["passed"] is True diff --git a/tests/test_examples_smoke.py b/tests/test_examples_smoke.py index 414560a..97da80b 100644 --- a/tests/test_examples_smoke.py +++ b/tests/test_examples_smoke.py @@ -122,6 +122,17 @@ "Host-injected ETHERSCAN_API_KEY accepted via config (no os.environ).", ], ), + ( + "deck_builder_chain_demo.py", + [ + "Loading SkillContext with creative/bg_remover and creative/deck_builder...", + "=== Step 1: Suggest Outline via Archetype ===", + "=== Step 2: Background Removal on Brand Mark ===", + "=== Step 3: Lint Deck Specification ===", + "=== Step 4: Render Final Presentation ===", + "Chain demo complete.", + ], + ), ] # Provider-dependent scripts that are deliberately excluded from CI smoke tests because