From a66e76e17550cbb50959a026487352aaf99f664c Mon Sep 17 00:00:00 2001 From: tusharjamunkar Date: Thu, 3 Sep 2026 20:13:00 +0530 Subject: [PATCH 1/2] feat(creative): add deck_builder skill for deterministic PPTX assembly (#276) --- CHANGELOG.md | 1 + docs/skills/README.md | 1 + docs/skills/deck_builder.md | 229 ++++++ docs/usage/agent_loops.md | 1 + docs/usage/install_extras.md | 1 + examples/README.md | 1 + examples/deck_builder_demo.py | 147 ++++ pyproject.toml | 7 + skills/creative/deck_builder/__init__.py | 0 skills/creative/deck_builder/builder.py | 752 ++++++++++++++++++ skills/creative/deck_builder/card.json | 40 + skills/creative/deck_builder/instructions.md | 182 +++++ skills/creative/deck_builder/manifest.yaml | 143 ++++ .../schemas/deck_spec.schema.json | 127 +++ skills/creative/deck_builder/skill.py | 132 +++ .../deck_builder/templates/corporate_v1.pptx | Bin 0 -> 27391 bytes .../deck_builder/templates/minimal_v1.pptx | Bin 0 -> 27391 bytes .../deck_builder/templates/pitch_v1.pptx | Bin 0 -> 27391 bytes skills/creative/deck_builder/test_skill.py | 434 ++++++++++ skillware/core/extras.py | 1 + .../creative__deck_builder.json | 7 + tests/skills/creative/test_deck_builder.py | 67 ++ tests/test_examples_smoke.py | 11 + 23 files changed, 2284 insertions(+) create mode 100644 docs/skills/deck_builder.md create mode 100644 examples/deck_builder_demo.py create mode 100644 skills/creative/deck_builder/__init__.py create mode 100644 skills/creative/deck_builder/builder.py create mode 100644 skills/creative/deck_builder/card.json create mode 100644 skills/creative/deck_builder/instructions.md create mode 100644 skills/creative/deck_builder/manifest.yaml create mode 100644 skills/creative/deck_builder/schemas/deck_spec.schema.json create mode 100644 skills/creative/deck_builder/skill.py create mode 100644 skills/creative/deck_builder/templates/corporate_v1.pptx create mode 100644 skills/creative/deck_builder/templates/minimal_v1.pptx create mode 100644 skills/creative/deck_builder/templates/pitch_v1.pptx create mode 100644 skills/creative/deck_builder/test_skill.py create mode 100644 tests/fixtures/card_ui_schema/creative__deck_builder.json create mode 100644 tests/skills/creative/test_deck_builder.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 181b0b3..b6b6d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ### Added - **CLI:** `skillware theme [pastel|ocean|mono]` subcommand — set or interactively choose the global presentation theme; `--help` topic index now includes Context, Chains, and Theme alongside existing groups. +- **Skill (`creative/deck_builder` v0.1.0):** Deterministic Microsoft PowerPoint (`.pptx`) presentation assembly from structured JSON deck specifications — 10 slide layout types (title, section, bullets, two-column, image, image with caption, quote, table, chart, blank), 3 bundled 16:9 widescreen master templates (pitch, corporate, minimal), theme token customization, pre-flight validation with soft-limit truncation warnings, directory traversal defenses, and inspection actions (#276). ### Changed diff --git a/docs/skills/README.md b/docs/skills/README.md index 2618e5a..c708b9d 100644 --- a/docs/skills/README.md +++ b/docs/skills/README.md @@ -18,6 +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. | ## Finance Tools for financial analysis, blockchain interaction, and regulatory compliance. diff --git a/docs/skills/deck_builder.md b/docs/skills/deck_builder.md new file mode 100644 index 0000000..b68459b --- /dev/null +++ b/docs/skills/deck_builder.md @@ -0,0 +1,229 @@ +# Deck Builder + +**ID**: `creative/deck_builder` +**Issuer**: [@tusharjamunkar](https://github.com/tusharjamunkar) ([@ARPAHLS](https://github.com/ARPAHLS)) + +**Version**: `0.1.0` + + +**Recommended install:** `pip install "skillware[creative_deck_builder]"`. See [Install extras](../usage/install_extras.md). +**Category**: Creative + +[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. + +## 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. +- **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 + +| Action | Parameters | Description | +| :--- | :--- | :--- | +| `validate_spec` *(default)* | `deck_spec`, `strict` *(optional)* | Validates `deck_spec` against JSON schema and business rules without writing files. | +| `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. | + +## Slide Layouts + +| Type | Description | Key Fields | +| :--- | :--- | :--- | +| `title` | Cover slide | `title`, `subtitle`, optional `image`, optional `speaker_notes` | +| `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_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` | +| `blank` | Clean canvas | optional `speaker_notes` | + +## Usage Examples + +Guides: [Usage index](../usage/README.md) · [Agent loops](../usage/agent_loops.md) + +### Direct execute + +```python +from skillware.core.loader import SkillLoader + +bundle = SkillLoader.load_skill("creative/deck_builder") +skill = bundle["class"]() + +spec = { + "title": "Quarterly Briefing", + "template_id": "pitch_v1", + "slides": [ + {"type": "title", "title": "Quarterly Briefing", "subtitle": "Executive Overview"}, + {"type": "bullets", "title": "Highlights", "bullets": ["Revenue up 24%", "Shipped 12 skills"]}, + ], +} + +# Pre-flight validation +val = skill.execute({"action": "validate_spec", "deck_spec": spec}) +print("Valid:", val["valid"]) + +# Render presentation +result = skill.execute({"action": "render", "deck_spec": spec, "output_path": "briefing.pptx"}) +print("Rendered:", result["output_path"], result["slide_count"], "slides") +``` + +### Claude (Anthropic Tool Use) + +```python +import os +import anthropic +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() +bundle = SkillLoader.load_skill("creative/deck_builder") +skill = bundle["class"]() +tool = SkillLoader.to_claude_tool(bundle) +client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) + +response = client.messages.create( + model="claude-3-7-sonnet-20250219", + max_tokens=1024, + tools=[tool], + messages=[{"role": "user", "content": "Assemble a 5-slide investor pitch deck for our AI platform."}], +) +``` + +### OpenAI (Function Calling) + +```python +import os +from openai import OpenAI +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() +bundle = SkillLoader.load_skill("creative/deck_builder") +skill = bundle["class"]() +openai_tool = SkillLoader.to_openai_tool(bundle) +client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + +response = client.chat.completions.create( + model="gpt-4o", + tools=[openai_tool], + messages=[{"role": "user", "content": "Build a quarterly review presentation with a revenue chart."}], +) +``` + +### DeepSeek + +```python +import os +from openai import OpenAI +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() +bundle = SkillLoader.load_skill("creative/deck_builder") +skill = bundle["class"]() +deepseek_tool = SkillLoader.to_deepseek_tool(bundle) +client = OpenAI( + api_key=os.environ.get("DEEPSEEK_API_KEY"), + base_url="https://api.deepseek.com", +) + +response = client.chat.completions.create( + model="deepseek-chat", + tools=[deepseek_tool], + messages=[{"role": "user", "content": "Validate and render a technical architecture deck."}], +) +``` + +### Ollama (Local LLMs) + +Prompt-based tool calling or system prompt injection. Pull a model such as `gemma3` or `qwen3.5`, then follow [Ollama usage](../usage/ollama.md): + +```python +from skillware.core.loader import SkillLoader + +bundle = SkillLoader.load_skill("creative/deck_builder") +system_tool_prompt = SkillLoader.to_ollama_prompt(bundle) +``` + +### Gemini + +```python +import os +import google.genai as genai +from skillware.core.loader import SkillLoader +from skillware.core.env import load_env_file + +load_env_file() +bundle = SkillLoader.load_skill("creative/deck_builder") +tool = SkillLoader.to_gemini_tool(bundle) +skill = bundle["class"]() +client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"]) + +response = client.models.generate_content( + model="gemini-2.5-flash", + contents="Assemble a deck specification into a presentation.", + config=genai.types.GenerateContentConfig(tools=[tool]), +) +``` + +### 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`: + +```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"], + }, + ], +} +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") +``` + +--- + + +## Skill history + +Commits that touched this skill bundle or its catalog page ([`creative/deck_builder`](https://github.com/ARPAHLS/skillware/tree/main/skills/creative/deck_builder)). + +| Commit | Description | Date | Version | Contributors | +| :--- | :--- | :--- | :--- | :--- | +| [`0db53ba`](https://github.com/ARPAHLS/skillware/commit/0db53ba) | feat(creative): add deck_builder skill for deterministic PPTX assembly (#276) | 4 Sep 2026 | 0.1.0 | [@tusharjamunkar](https://github.com/tusharjamunkar) | + + +## Enterprise disclaimer + +This skill is provided for demonstration and integration purposes. It is intended as a starting point that you can adapt to your own data, schemas, and operational requirements. For an enterprise-grade version of this skill with dedicated support, SLAs, and customization, contact skills@arpacorp.net. \ No newline at end of file diff --git a/docs/usage/agent_loops.md b/docs/usage/agent_loops.md index 1880c7b..bc85416 100644 --- a/docs/usage/agent_loops.md +++ b/docs/usage/agent_loops.md @@ -146,6 +146,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) | | `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/docs/usage/install_extras.md b/docs/usage/install_extras.md index 53dbdf3..3790f77 100644 --- a/docs/usage/install_extras.md +++ b/docs/usage/install_extras.md @@ -96,6 +96,7 @@ One extra per bundled registry skill. Naming: `{category}_{skill_name}` (registr | `compliance_pii_masker` | `compliance/pii_masker` | *(none today)* | Use this extra in docs and installs | | `compliance_tos_evaluator` | `compliance/tos_evaluator` | *(none today)* | Use this extra in docs and installs | | `creative_bg_remover` | `creative/bg_remover` | `rembg`, `pillow`, `onnxruntime` | | +| `creative_deck_builder` | `creative/deck_builder` | `python-pptx>=1.0.0`, `pillow` | Editable PowerPoint presentation assembly | | `data_engineering_novelty_extractor` | `data_engineering/novelty_extractor` | `fastembed`, `numpy` | | | `data_engineering_synthetic_generator` | `data_engineering/synthetic_generator` | *(none today)* | Use this extra in docs and installs | | `defi_evm_tx_handler` | `defi/evm_tx_handler` | `web3>=6.0.0` | | diff --git a/examples/README.md b/examples/README.md index d706cdc..8c83ada 100644 --- a/examples/README.md +++ b/examples/README.md @@ -67,6 +67,7 @@ pip install -e ".[dev,all,agents]" | `gemini_uk_companies_house_handler.py` | `finance/uk_companies_house_handler` | Gemini | `[finance_uk_companies_house_handler]`, `[gemini]` | `GOOGLE_API_KEY`, `COMPANIES_HOUSE_API_KEY` | Interactive v2b loop: composites, pipelines, disambiguation, partial previews. | | `uk_companies_house_handler_demo.py` | `finance/uk_companies_house_handler` | Local execute | `[finance_uk_companies_house_handler]` | None | Mocked v2b flows: composite, run_pipeline, disambiguation resume, partial officers preview. | | `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. | | `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. | | `gemini_gmail_handler.py` | `office/gmail_handler` | Gemini | `[office_gmail_handler]`, `[gemini]` | `GOOGLE_API_KEY`, `GMAIL_ADDRESS`, `GMAIL_APP_PASSWORD` (dedicated agent mailbox; demo: `GMAIL_HANDLER_EXAMPLE_DEMO=1`) | Interactive Gemini loop for resolve, search, read, preview/send mail. | diff --git a/examples/deck_builder_demo.py b/examples/deck_builder_demo.py new file mode 100644 index 0000000..9a4d055 --- /dev/null +++ b/examples/deck_builder_demo.py @@ -0,0 +1,147 @@ +"""Local execute demo for creative/deck_builder. + +Demonstrates deterministic assembly of an editable Microsoft PowerPoint (.pptx) +presentation from a structured JSON deck specification. Runs entirely offline +without network or LLM APIs. +""" + +from pathlib import Path +import tempfile + +from skillware.core.loader import SkillLoader + + +def run_demo(): + print("Loading creative/deck_builder...") + bundle = SkillLoader.load_skill("creative/deck_builder") + skill = bundle["class"]() + + # Step 1: List bundled templates + print("\n=== Step 1: List Bundled Templates ===") + templates_res = skill.execute({"action": "list_templates"}) + for tpl in templates_res.get("templates", []): + print( + f" - [{tpl['template_id']}] {tpl['name']} ({tpl['aspect_ratio']}): {tpl['description']}" + ) + + # Step 2: Validate deck specification + print("\n=== Step 2: Validate Deck Specification ===") + deck_spec = { + "title": "Skillware Executive Briefing", + "template_id": "pitch_v1", + "theme": { + "accent_color": "#6E57E0", + "font_heading": "Calibri", + "font_body": "Calibri", + }, + "metadata": { + "author": "ARPA Hellenic Logical Systems", + "subject": "Platform Architecture", + }, + "slides": [ + { + "type": "title", + "title": "Skillware Platform", + "subtitle": "Deterministic AI Skills for Production Agent Systems", + }, + { + "type": "section", + "title": "Part 1: The Trust Boundary", + "subtitle": "Why agents require governed capabilities", + }, + { + "type": "bullets", + "title": "Core Tenets", + "bullets": [ + "Deterministic, local execution for mission-critical actions", + "Offline-first verification with zero network dependency in execute()", + "Strict input schema validation and fail-closed security contracts", + ], + "speaker_notes": "Emphasize reproducibility and local unit testing across providers.", + }, + { + "type": "two_column", + "title": "Architectural Comparison", + "left": [ + "Legacy Tool Calling", + "Monolithic prompts", + "Unchecked hallucinations", + ], + "right": [ + "Skillware Architecture", + "Contract + Effect + Assurance", + "Provider-agnostic loaders", + ], + }, + { + "type": "quote", + "quote": "Deterministic skills are the foundation of agent reliability.", + "attribution": "ARPA HLS Engineering", + }, + { + "type": "table", + "title": "Registry Growth (2026)", + "columns": ["Category", "Skills Shipped", "Status"], + "rows": [ + ["Security", "2 skills", "Production"], + ["Creative", "2 skills", "Production"], + ["Compliance", "3 skills", "Production"], + ], + }, + { + "type": "chart", + "title": "Quarterly Agent Executions", + "chart": { + "kind": "bar", + "categories": ["Q1", "Q2", "Q3", "Q4"], + "series": [ + {"name": "Executions (k)", "values": [120, 280, 540, 920]} + ], + }, + "speaker_notes": "Growth reflects developer adoption of standard contracts.", + }, + { + "type": "blank", + "speaker_notes": "Open the floor for technical Q&A.", + }, + ], + } + + val_res = skill.execute({"action": "validate_spec", "deck_spec": deck_spec}) + print(f" valid: {val_res.get('valid')}") + print(f" slide_count: {val_res.get('slide_count')}") + print(f" warnings: {len(val_res.get('warnings', []))}") + print(f" errors: {len(val_res.get('errors', []))}") + + # Step 3: Render presentation + print("\n=== Step 3: Render Presentation ===") + with tempfile.TemporaryDirectory() as tmp_dir: + output_pptx = Path(tmp_dir) / "skillware_executive_briefing.pptx" + render_res = skill.execute( + { + "action": "render", + "deck_spec": deck_spec, + "output_path": str(output_pptx), + } + ) + print(f" 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', []))}") + + # Step 4: Inspect generated presentation + print("\n=== Step 4: Inspect Generated PPTX ===") + inspect_res = skill.execute( + {"action": "inspect", "input_path": str(output_pptx)} + ) + print(f" inspected_slides: {inspect_res.get('slide_count')}") + for s in inspect_res.get("slides", [])[:4]: + print( + f" - Slide {s['index'] + 1} ({s['layout_name']}): title='{s['title']}', notes={s['has_notes']}" + ) + + print("\nDemo complete.") + + +if __name__ == "__main__": + run_demo() diff --git a/pyproject.toml b/pyproject.toml index ab88f8a..8743b2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ compliance = [ creative = [ "onnxruntime", "pillow", + "python-pptx>=1.0.0", "rembg>=2.0.0", ] @@ -126,6 +127,11 @@ creative_bg_remover = [ "onnxruntime", ] +creative_deck_builder = [ + "python-pptx>=1.0.0", + "pillow", +] + data_engineering_novelty_extractor = [ "fastembed", "numpy", @@ -172,6 +178,7 @@ all = [ "onnxruntime", "pillow", "pymupdf", + "python-pptx>=1.0.0", "rembg>=2.0.0", "web3>=6.0.0", ] diff --git a/skills/creative/deck_builder/__init__.py b/skills/creative/deck_builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/creative/deck_builder/builder.py b/skills/creative/deck_builder/builder.py new file mode 100644 index 0000000..8804571 --- /dev/null +++ b/skills/creative/deck_builder/builder.py @@ -0,0 +1,752 @@ +"""Core PowerPoint (.pptx) builder, validator, and inspector for creative/deck_builder.""" + +from __future__ import annotations + +import base64 +import io +import json +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +import jsonschema +from PIL import Image +import pptx +from pptx.chart.data import CategoryChartData +from pptx.dml.color import RGBColor +from pptx.enum.chart import XL_CHART_TYPE +from pptx.util import Inches, Pt + +_HERE = Path(__file__).resolve().parent +_TEMPLATES_DIR = _HERE / "templates" +_SCHEMA_PATH = _HERE / "schemas" / "deck_spec.schema.json" + +TEMPLATES: Dict[str, Dict[str, Any]] = { + "pitch_v1": { + "template_id": "pitch_v1", + "name": "Modern Pitch Deck", + "description": "Vibrant modern aesthetic with bold typography and purple/indigo accents.", + "aspect_ratio": "16:9", + "default_accent": "#6E57E0", + "default_heading_font": "Calibri", + "default_body_font": "Calibri", + "filename": "pitch_v1.pptx", + }, + "corporate_v1": { + "template_id": "corporate_v1", + "name": "Executive Corporate", + "description": "Structured executive presentation layout with navy and slate accents.", + "aspect_ratio": "16:9", + "default_accent": "#1E3A8A", + "default_heading_font": "Calibri", + "default_body_font": "Calibri", + "filename": "corporate_v1.pptx", + }, + "minimal_v1": { + "template_id": "minimal_v1", + "name": "Clean Minimalist", + "description": "Monochrome high-contrast typography-forward layout for technical decks.", + "aspect_ratio": "16:9", + "default_accent": "#262626", + "default_heading_font": "Arial", + "default_body_font": "Arial", + "filename": "minimal_v1.pptx", + }, +} + +SUPPORTED_LAYOUT_TYPES = [ + "title", + "section", + "bullets", + "two_column", + "image", + "image_caption", + "quote", + "table", + "chart", + "blank", +] + + +def _load_schema() -> Dict[str, Any]: + with open(_SCHEMA_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + +def _hex_to_rgb(hex_str: str) -> RGBColor: + hex_clean = hex_str.lstrip("#") + if len(hex_clean) == 3: + hex_clean = "".join(c * 2 for c in hex_clean) + if len(hex_clean) != 6: + return RGBColor(110, 87, 224) + r = int(hex_clean[0:2], 16) + g = int(hex_clean[2:4], 16) + b = int(hex_clean[4:6], 16) + return RGBColor(r, g, b) + + +def list_templates() -> Dict[str, Any]: + """Return bundled templates, descriptions, and aspect ratios.""" + template_list = [] + for tid, info in TEMPLATES.items(): + template_list.append( + { + "template_id": tid, + "name": info["name"], + "description": info["description"], + "aspect_ratio": info["aspect_ratio"], + "default_accent": info["default_accent"], + "supported_layouts": SUPPORTED_LAYOUT_TYPES, + } + ) + return { + "success": True, + "action": "list_templates", + "templates": template_list, + "error_code": None, + } + + +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]] = [] + + 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 + 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}", + } + ) + 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 []), + "warnings": [], + "errors": errors, + "error_code": "INVALID_SPEC", + } + + template_id = deck_spec.get("template_id", "pitch_v1") + if template_id not in TEMPLATES: + warnings.append( + { + "code": "TEMPLATE_NOT_FOUND", + "slide_index": -1, + "message": f"Unknown template_id '{template_id}'; will fall back to pitch_v1.", + } + ) + + slides = deck_spec.get("slides") or [] + for idx, slide in enumerate(slides): + stype = slide.get("type") + + # Soft character limit on bullets + if stype == "bullets": + bullets = slide.get("bullets") or [] + for b_idx, bullet in enumerate(bullets): + if len(bullet) > 120: + warnings.append( + { + "code": "BULLET_TRUNCATED", + "slide_index": idx, + "message": ( + f"Bullet {b_idx + 1} exceeded 120 chars ({len(bullet)} chars); " + "will wrap or truncate on render." + ), + } + ) + + 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: + img_path = img_obj.get("path") + b64_data = img_obj.get("base64") + if img_path: + if not os.path.exists(img_path): + warnings.append( + { + "code": "ASSET_NOT_FOUND", + "slide_index": idx, + "message": f"Image file not found at path: {img_path}", + } + ) + elif b64_data: + try: + raw_bytes = base64.b64decode(b64_data) + with Image.open(io.BytesIO(raw_bytes)) as pil_img: + pil_img.verify() + except Exception as exc: + warnings.append( + { + "code": "ASSET_INVALID", + "slide_index": idx, + "message": f"Invalid base64 image data: {exc}", + } + ) + + # Chart verification + if stype == "chart": + chart_obj = slide.get("chart") or {} + cats = chart_obj.get("categories") or [] + series_list = chart_obj.get("series") or [] + for s in series_list: + vals = s.get("values") or [] + if len(vals) != len(cats): + errors.append( + { + "code": "CHART_DIMENSION_MISMATCH", + "slide_index": idx, + "message": ( + f"Series '{s.get('name')}' length ({len(vals)}) " + f"does not match categories count ({len(cats)})." + ), + } + ) + + # Table verification + if stype == "table": + cols = slide.get("columns") or [] + rows = slide.get("rows") or [] + for r_idx, row in enumerate(rows): + if len(row) != len(cols): + warnings.append( + { + "code": "TABLE_DIMENSION_MISMATCH", + "slide_index": idx, + "message": ( + f"Row {r_idx + 1} length ({len(row)}) does not match " + f"column headers count ({len(cols)})." + ), + } + ) + + if strict and warnings: + for w in warnings: + errors.append( + { + "code": f"STRICT_{w['code']}", + "slide_index": w.get("slide_index", -1), + "message": f"Strict mode validation failure: {w['message']}", + } + ) + + is_valid = len(errors) == 0 + return { + "success": is_valid, + "action": "validate_spec", + "valid": is_valid, + "template_id": template_id, + "slide_count": len(slides), + "warnings": warnings, + "errors": errors, + "error_code": ( + None if is_valid else (errors[0]["code"] if errors else "VALIDATION_FAILED") + ), + } + + +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, + template_id: Optional[str] = None, + theme: Optional[Dict[str, Any]] = None, + strict: bool = False, +) -> Dict[str, Any]: + """Render presentation from deck specification to target .pptx file.""" + try: + safe_output_path = _validate_output_path(output_path) + except ValueError as exc: + return { + "success": False, + "action": "render", + "output_path": output_path, + "template_id": template_id or "unknown", + "slide_count": 0, + "file_size_bytes": 0, + "slides": [], + "warnings": [], + "errors": [ + {"code": "OUTPUT_PATH_UNSAFE", "slide_index": -1, "message": str(exc)} + ], + "error_code": "OUTPUT_PATH_UNSAFE", + } + + val_res = validate_spec(deck_spec, strict=strict) + if not val_res["valid"]: + return { + "success": False, + "action": "render", + "output_path": safe_output_path, + "template_id": val_res["template_id"], + "slide_count": val_res["slide_count"], + "file_size_bytes": 0, + "slides": [], + "warnings": val_res["warnings"], + "errors": val_res["errors"], + "error_code": val_res["error_code"] or "INVALID_SPEC", + } + + effective_template_id = template_id or deck_spec.get("template_id", "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"]) + 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 [] + rendered_slides_summary: List[Dict[str, Any]] = [] + + for s_idx, slide_data in enumerate(slides): + stype = slide_data.get("type", "blank") + title_text = slide_data.get("title", "") + + # 1. Title Slide + if stype == "title": + slide = prs.slides.add_slide(prs.slide_layouts[0]) + 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 + 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: + p.font.name = font_body + + img_stream = _load_image_stream(slide_data.get("image")) + if img_stream: + try: + slide.shapes.add_picture( + img_stream, Inches(9.5), Inches(2.0), width=Inches(3.0) + ) + except Exception: + pass + + # 2. Section Divider + elif stype == "section": + slide = prs.slides.add_slide(prs.slide_layouts[2]) + 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 + 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: + p.font.name = font_body + + # 3. Bullets + elif stype == "bullets": + slide = prs.slides.add_slide(prs.slide_layouts[1]) + 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 + bullets = slide_data.get("bullets") or [] + if len(slide.placeholders) > 1 and bullets: + tf = slide.placeholders[1].text_frame + tf.clear() + for b_idx, bullet in enumerate(bullets): + p = tf.paragraphs[0] if b_idx == 0 else tf.add_paragraph() + p.text = bullet + p.font.name = font_body + p.level = 0 + + # 4. Two Column + elif stype == "two_column": + 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 + 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 + elif stype == "image": + slide = prs.slides.add_slide(prs.slide_layouts[5]) + if slide.shapes.title and title_text: + 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 + img_stream = _load_image_stream(slide_data.get("image")) + if img_stream: + 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), + ) + except Exception: + pass + caption = slide_data.get("caption") + if caption: + tb = slide.shapes.add_textbox( + Inches(2.0), Inches(6.2), Inches(9.333), Inches(0.8) + ) + p = tb.text_frame.paragraphs[0] + p.text = caption + p.font.name = font_body + p.font.italic = True + + # 6. Image with Caption Body + elif stype == "image_caption": + 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 + img_stream = _load_image_stream(slide_data.get("image")) + if img_stream: + try: + slide.shapes.add_picture( + img_stream, + Inches(1.0), + Inches(1.8), + width=Inches(5.5), + ) + except Exception: + pass + body_text = slide_data.get("body", "") + if body_text: + tb = slide.shapes.add_textbox( + Inches(7.0), Inches(1.8), Inches(5.333), Inches(4.5) + ) + tf = tb.text_frame + tf.word_wrap = True + p = tf.paragraphs[0] + p.text = body_text + p.font.name = font_body + + # 7. Quote Slide + elif stype == "quote": + slide = prs.slides.add_slide(prs.slide_layouts[6]) + tb = slide.shapes.add_textbox( + Inches(1.8), Inches(2.0), Inches(9.7), Inches(3.5) + ) + tf = tb.text_frame + tf.word_wrap = True + quote_text = slide_data.get("quote", "") + p_q = tf.paragraphs[0] + p_q.text = f'"{quote_text}"' + p_q.font.name = font_heading + p_q.font.size = Pt(26) + p_q.font.bold = True + p_q.font.color.rgb = accent_rgb + attrib_text = slide_data.get("attribution", "") + if attrib_text: + p_a = tf.add_paragraph() + p_a.text = f"— {attrib_text}" + p_a.font.name = font_body + p_a.font.size = Pt(18) + p_a.font.italic = True + + # 8. Table + elif stype == "table": + 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 + cols = slide_data.get("columns") or [] + rows = slide_data.get("rows") or [] + num_rows = len(rows) + 1 + num_cols = len(cols) + if num_cols > 0 and num_rows > 1: + table_shape = slide.shapes.add_table( + num_rows, + num_cols, + Inches(1.0), + Inches(1.8), + Inches(11.333), + Inches(0.6 * num_rows), + ) + tbl = table_shape.table + for c_idx, c_name in enumerate(cols): + cell = tbl.cell(0, c_idx) + cell.text = str(c_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 + elif stype == "chart": + 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 + 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_type_map = { + "bar": XL_CHART_TYPE.COLUMN_CLUSTERED, + "line": XL_CHART_TYPE.LINE_MARKERS, + "pie": XL_CHART_TYPE.PIE, + } + xl_type = chart_type_map.get(kind, XL_CHART_TYPE.COLUMN_CLUSTERED) + slide.shapes.add_chart( + xl_type, + Inches(1.5), + Inches(1.8), + Inches(10.333), + Inches(5.0), + chart_data, + ) + + # 10. 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: + slide.notes_slide.notes_text_frame.text = speaker_notes + + rendered_slides_summary.append( + { + "index": s_idx, + "type": stype, + "title": title_text, + } + ) + + prs.save(safe_output_path) + file_size = os.path.getsize(safe_output_path) + + return { + "success": True, + "action": "render", + "output_path": safe_output_path, + "template_id": effective_template_id, + "slide_count": len(slides), + "file_size_bytes": file_size, + "slides": rendered_slides_summary, + "warnings": val_res["warnings"], + "error_code": None, + } + + +def inspect_deck(input_path: str) -> Dict[str, Any]: + """Inspect an existing .pptx presentation and return its slide manifest.""" + if not input_path or not os.path.exists(input_path): + return { + "success": False, + "action": "inspect", + "slide_count": 0, + "slides": [], + "error_code": "INSPECT_FAILED", + "errors": [ + { + "code": "FILE_NOT_FOUND", + "slide_index": -1, + "message": f"File not found at input_path: {input_path}", + } + ], + } + + try: + prs = pptx.Presentation(input_path) + slides_manifest: List[Dict[str, Any]] = [] + for idx, s in enumerate(prs.slides): + has_notes = False + try: + has_notes = bool( + s.has_notes_slide and s.notes_slide.notes_text_frame.text.strip() + ) + except Exception: + pass + title_text = ( + s.shapes.title.text + if (s.shapes.title and s.shapes.title.text) + else None + ) + layout_name = getattr(s.slide_layout, "name", "Custom") + slides_manifest.append( + { + "index": idx, + "layout_name": layout_name, + "title": title_text, + "has_notes": has_notes, + "shape_count": len(s.shapes), + } + ) + + return { + "success": True, + "action": "inspect", + "slide_count": len(slides_manifest), + "slides": slides_manifest, + "error_code": None, + } + except Exception as exc: + return { + "success": False, + "action": "inspect", + "slide_count": 0, + "slides": [], + "error_code": "INSPECT_FAILED", + "errors": [ + { + "code": "CORRUPT_OR_UNREADABLE", + "slide_index": -1, + "message": f"Failed to inspect .pptx file: {exc}", + } + ], + } diff --git a/skills/creative/deck_builder/card.json b/skills/creative/deck_builder/card.json new file mode 100644 index 0000000..b39761c --- /dev/null +++ b/skills/creative/deck_builder/card.json @@ -0,0 +1,40 @@ +{ + "name": "Deck Builder", + "description": "Deterministic PowerPoint (.pptx) assembly from structured deck specs.", + "issuer": { + "name": "Tushar Jamunkar", + "email": "tusharjamunkar@users.noreply.github.com", + "github": "tusharjamunkar", + "org": "ARPAHLS" + }, + "icon": "presentation", + "color": "indigo", + "ui_schema": { + "type": "card", + "fields": [ + { + "key": "success", + "label": "Status", + "type": "status_badge" + }, + { + "key": "action", + "label": "Action" + }, + { + "key": "template_id", + "label": "Template" + }, + { + "key": "slide_count", + "label": "Slide Count", + "type": "count" + }, + { + "key": "output_path", + "label": "Output", + "type": "file_path" + } + ] + } +} \ No newline at end of file diff --git a/skills/creative/deck_builder/instructions.md b/skills/creative/deck_builder/instructions.md new file mode 100644 index 0000000..2cc3c37 --- /dev/null +++ b/skills/creative/deck_builder/instructions.md @@ -0,0 +1,182 @@ +# Creative Deck Builder Instructions + +You are equipped with `creative/deck_builder`, a deterministic skill for validating, assembling, and inspecting Microsoft PowerPoint (`.pptx`) presentations from structured JSON specifications. + +## Purpose & Boundaries + +Use this tool when a user or upstream workflow requests an editable slide deck, investor pitch, technical architecture presentation, or business briefing. + +- **Local & Deterministic**: The skill executes entirely offline. It does not generate text copy or images autonomously; it strictly assembles the structure, layouts, tables, charts, and image assets supplied in `deck_spec`. +- **Workflow Separation**: Generate narrative outlines and copy in your agent loop, then compile them into a valid `deck_spec` and pass them to `deck_builder`. +- **Editable Output**: Renders standard OpenXML `.pptx` documents that can be opened and styled in Microsoft PowerPoint, LibreOffice Impress, Apple Keynote, or Google Slides. + +--- + +## Actions + +| Action | Parameters | Description | +| :--- | :--- | :--- | +| `validate_spec` *(default)* | `deck_spec`, `strict` *(optional)* | Validates `deck_spec` against JSON schema and business rules. Emits warnings for truncated text or asset issues without writing files. | +| `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. | + +--- + +## Slide Layout Types + +`deck_spec.slides` accepts an array of slide objects. Each must include `"type"`. Supported layout types: + +1. **`title`**: Cover slide. + - Keys: `title` (required), `subtitle` (optional), `image` (optional logo or hero graphic), `speaker_notes` (optional). +2. **`section`**: Section divider. + - Keys: `title` (required), `subtitle` (optional), `speaker_notes` (optional). +3. **`bullets`**: Standard list slide. + - Keys: `title` (required), `bullets` (required array of strings), `speaker_notes` (optional). + - *Soft limit*: Bullets over 120 characters emit a non-fatal `BULLET_TRUNCATED` warning. +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). +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. + - Keys: `quote` (required), `attribution` (optional name/title), `speaker_notes` (optional). +8. **`table`**: Tabular grid. + - 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. + - Keys: `speaker_notes` (optional). + +--- + +## Image Handling & Asset Strategy + +- **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). + +--- + +## Bundled Templates + +- **`pitch_v1`** *(default)*: 16:9 widescreen modern startup aesthetic with bold typography and purple/indigo accents (`#6E57E0`). +- **`corporate_v1`**: 16:9 widescreen structured executive presentation with navy and slate accents (`#1E3A8A`). +- **`minimal_v1`**: 16:9 widescreen clean monochrome layout with black/charcoal accents (`#262626`). + +--- + +## 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. + +--- + +## Worked Example: NL Prompt to 7-Slide Deck Spec + +### User Prompt +> "Build a 7-slide enterprise pitch deck for CortexEngine, an autonomous database optimization platform. Keep it high-level, include our architecture, 3 customer proof points, a benchmark comparison table, and next steps. Do NOT include pricing or licensing tiers." + +### Agent Mapping Rationale +- Slide 1 (`title`): Platform name, tagline, cover. +- Slide 2 (`section`): Problem overview ("The High Cost of Database Inefficiency"). +- Slide 3 (`bullets`): Core value proposition (3 concise points, each $< 120$ chars). +- Slide 4 (`two_column`): Legacy manual tuning vs CortexEngine autonomous tuning. +- Slide 5 (`table`): Benchmark comparison (Latency, Throughput, Cost Reduction across 3 engines). +- Slide 6 (`quote`): Enterprise customer endorsement quote. +- Slide 7 (`bullets`): Next steps & pilot deployment CTA (respecting constraint: no pricing). + +### Generated `deck_spec` +```json +{ + "title": "CortexEngine Enterprise Overview", + "template_id": "pitch_v1", + "theme": { + "accent_color": "#6E57E0", + "font_heading": "Calibri", + "font_body": "Calibri" + }, + "slides": [ + { + "type": "title", + "title": "CortexEngine", + "subtitle": "Autonomous Database Optimization for Modern Clouds", + "speaker_notes": "Introduce CortexEngine as a zero-touch optimization layer." + }, + { + "type": "section", + "title": "The Scaling Bottleneck", + "subtitle": "Why manual index tuning fails under petabyte workloads" + }, + { + "type": "bullets", + "title": "Autonomous Execution", + "bullets": [ + "Continuous telemetry inspection with zero query overhead", + "Deterministic index recommendation and instant rollout", + "Automated rollback on query regression or latency spikes" + ] + }, + { + "type": "two_column", + "title": "Operational Comparison", + "left": [ + "Manual DBA Tuning", + "Reactive incident response", + "Multi-week rollout cycles" + ], + "right": [ + "CortexEngine", + "Proactive autonomous adaptation", + "Real-time index updates without downtime" + ] + }, + { + "type": "table", + "title": "Benchmark Performance", + "columns": ["Workload", "Manual DBA", "CortexEngine", "Improvement"], + "rows": [ + ["TPC-C OLTP", "4.2 ms", "1.1 ms", "3.8x faster"], + ["Analytical Query", "18.5 s", "3.2 s", "5.7x faster"], + ["Peak Cloud Spend", "$45,000/mo", "$18,500/mo", "-58% cost"] + ] + }, + { + "type": "quote", + "quote": "CortexEngine slashed our p99 query latency by 70% within 48 hours of deployment.", + "attribution": "VP of Infrastructure, Global Fintech" + }, + { + "type": "bullets", + "title": "Next Steps: 30-Day Proof of Value", + "bullets": [ + "Non-intrusive read-only telemetry audit", + "Simulated index impact report against live workloads", + "Dedicated implementation engineering support" + ], + "speaker_notes": "Emphasize zero risk PoV; avoid pricing discussion until technical validation." + } + ] +} +``` + +--- + +## Error Codes + +- `INVALID_SPEC`: The provided `deck_spec` failed JSON Schema validation. +- `OUTPUT_PATH_UNSAFE`: The target path contains directory traversal sequences (`..`) or lacks `.pptx` extension. +- `OUTPUT_PATH_MISSING`: `output_path` was not specified for `action='render'`. +- `INPUT_PATH_MISSING`: `input_path` was not specified for `action='inspect'`. +- `INSPECT_FAILED`: File could not be found or read as a PowerPoint document. +- `CHART_DIMENSION_MISMATCH`: The number of series data points does not match categories count. +- `RENDER_FAILED`: python-pptx encountered an unrecoverable rendering exception. \ No newline at end of file diff --git a/skills/creative/deck_builder/manifest.yaml b/skills/creative/deck_builder/manifest.yaml new file mode 100644 index 0000000..236ed55 --- /dev/null +++ b/skills/creative/deck_builder/manifest.yaml @@ -0,0 +1,143 @@ +name: "creative/deck_builder" +version: "0.1.0" +description: "Deterministic PowerPoint (.pptx) assembly from structured deck specs." +short_description: "Assemble editable PPTX presentations from structured JSON deck specs." + +issuer: + name: "Tushar Jamunkar" + email: "tusharjamunkar@users.noreply.github.com" + github: "tusharjamunkar" + org: "ARPAHLS" + +category: "creative" + +requirements: + - "python-pptx>=1.0.0" + - "pillow" + +parameters: + type: object + properties: + action: + type: string + description: "Operation to perform: validate_spec (default), render, inspect, list_templates." + enum: + - validate_spec + - render + - inspect + - list_templates + default: validate_spec + + deck_spec: + type: object + description: "Structured deck specification containing title, template_id, theme, metadata, and slides." + + output_path: + type: string + description: "Destination file path for the rendered .pptx file (required for action='render')." + + input_path: + type: string + description: "Path to an existing .pptx file to inspect (required for action='inspect')." + + template_id: + type: string + description: "Optional template identifier overriding deck_spec.template_id (e.g. pitch_v1, corporate_v1, minimal_v1)." + + theme: + type: object + description: "Optional theme token overrides (accent_color, font_heading, font_body)." + properties: + accent_color: + type: string + description: "Hex color code for accents (e.g. '#6E57E0')." + font_heading: + type: string + description: "Font name for headings and titles (e.g. 'Calibri')." + font_body: + type: string + description: "Font name for body copy and bullets (e.g. 'Calibri')." + + strict: + type: boolean + description: "When true, warnings are treated as validation errors (default: false)." + default: false + required: [] + +outputs: + success: + type: boolean + description: "True when the requested action completed without errors." + + action: + type: string + description: "Echo of the executed action." + + valid: + type: boolean + description: "True when deck_spec passed schema and business rule validation." + + template_id: + type: string + description: "Identifier of the template used or validated." + + slide_count: + type: integer + description: "Total number of slides validated, rendered, or inspected." + + output_path: + type: string + description: "Target path where the presentation was saved (for render action)." + + file_size_bytes: + type: integer + description: "File size in bytes of the generated .pptx file." + + slides: + type: array + description: "Summary list of slides processed or inspected." + items: + type: object + + warnings: + type: array + description: "Non-fatal warnings (e.g. character truncation, fallback layouts)." + items: + type: object + properties: + code: + type: string + slide_index: + type: integer + message: + type: string + + errors: + type: array + description: "Fatal validation errors preventing rendering." + items: + type: object + properties: + code: + type: string + slide_index: + type: integer + message: + type: string + + error_code: + type: string + description: "Top-level error classification code when action fails." + + templates: + type: array + description: "List of bundled templates and metadata (for list_templates action)." + items: + type: object + +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." + EDITABLE_OUTPUT: "Produce standard .pptx editable in PowerPoint/LibreOffice; do not rasterize whole slides." + FAIL_CLOSED: "Validate before render; reject unsafe paths and invalid assets." + PRIVACY: "Do not persist deck content beyond the caller's output_path." \ No newline at end of file diff --git a/skills/creative/deck_builder/schemas/deck_spec.schema.json b/skills/creative/deck_builder/schemas/deck_spec.schema.json new file mode 100644 index 0000000..e200882 --- /dev/null +++ b/skills/creative/deck_builder/schemas/deck_spec.schema.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "DeckSpec", + "description": "Structured presentation specification for creative/deck_builder.", + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Overall presentation title." + }, + "template_id": { + "type": "string", + "description": "Master template identifier: pitch_v1, corporate_v1, or minimal_v1.", + "default": "pitch_v1" + }, + "theme": { + "type": "object", + "description": "Theme token overrides.", + "properties": { + "accent_color": { "type": "string" }, + "font_heading": { "type": "string" }, + "font_body": { "type": "string" } + }, + "additionalProperties": false + }, + "metadata": { + "type": "object", + "description": "Document metadata." + }, + "slides": { + "type": "array", + "description": "Ordered list of slides to assemble.", + "minItems": 1, + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "title", + "section", + "bullets", + "two_column", + "image", + "image_caption", + "quote", + "table", + "chart", + "blank" + ] + }, + "title": { "type": "string" }, + "subtitle": { "type": "string" }, + "speaker_notes": { "type": "string" }, + "bullets": { + "type": "array", + "items": { "type": "string" } + }, + "left": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "right": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "image": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "base64": { "type": "string" }, + "mime_type": { "type": "string" } + } + }, + "caption": { "type": "string" }, + "body": { "type": "string" }, + "quote": { "type": "string" }, + "attribution": { "type": "string" }, + "columns": { + "type": "array", + "items": { "type": "string" } + }, + "rows": { + "type": "array", + "items": { + "type": "array" + } + }, + "chart": { + "type": "object", + "required": ["kind", "categories", "series"], + "properties": { + "kind": { + "type": "string", + "enum": ["bar", "line", "pie"] + }, + "categories": { + "type": "array", + "items": { "type": "string" } + }, + "series": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "values"], + "properties": { + "name": { "type": "string" }, + "values": { + "type": "array", + "items": { "type": "number" } + } + } + } + } + } + } + } + } + } + }, + "required": ["title", "slides"] +} \ No newline at end of file diff --git a/skills/creative/deck_builder/skill.py b/skills/creative/deck_builder/skill.py new file mode 100644 index 0000000..7359fdc --- /dev/null +++ b/skills/creative/deck_builder/skill.py @@ -0,0 +1,132 @@ +"""Deck Builder Skill: Deterministic assembly of editable .pptx presentations.""" + +from __future__ import annotations + +import os +import sys +from typing import Any, Dict, Optional + +import yaml +from skillware.core.base_skill import BaseSkill + +try: + from .builder import inspect_deck, list_templates, render_deck, validate_spec +except ImportError: + sys.path.insert(0, os.path.dirname(__file__)) + from builder import inspect_deck, list_templates, render_deck, validate_spec + + +class DeckBuilderSkill(BaseSkill): + """Deterministic presentation builder from structured JSON deck specifications.""" + + def __init__(self, config: Optional[Dict[str, Any]] = None): + super().__init__(config) + + @property + def manifest(self) -> Dict[str, Any]: + manifest_path = os.path.join(os.path.dirname(__file__), "manifest.yaml") + if os.path.exists(manifest_path): + with open(manifest_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + return {} + + def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: + """ + Execute requested presentation operation. + + Supported actions: + - validate_spec (default): Validate deck_spec against schema and soft limits. + - render: Assemble .pptx to output_path. + - inspect: Inspect existing .pptx file at input_path. + - list_templates: List bundled templates and layout types. + """ + if not isinstance(params, dict): + return { + "success": False, + "action": "unknown", + "valid": False, + "error_code": "INVALID_PARAMS", + "errors": [ + { + "code": "INVALID_PARAMS", + "slide_index": -1, + "message": "Parameters must be provided as a JSON dictionary.", + } + ], + } + + action = params.get("action", "validate_spec") + + if action == "validate_spec": + return validate_spec( + deck_spec=params.get("deck_spec"), + strict=bool(params.get("strict", False)), + ) + + if action == "render": + output_path = params.get("output_path") + if not output_path: + return { + "success": False, + "action": "render", + "valid": False, + "slide_count": 0, + "file_size_bytes": 0, + "slides": [], + "warnings": [], + "errors": [ + { + "code": "OUTPUT_PATH_MISSING", + "slide_index": -1, + "message": "output_path is required for render action.", + } + ], + "error_code": "OUTPUT_PATH_MISSING", + } + + return render_deck( + deck_spec=params.get("deck_spec") or {}, + output_path=str(output_path), + template_id=params.get("template_id"), + theme=params.get("theme"), + strict=bool(params.get("strict", False)), + ) + + if action == "inspect": + input_path = params.get("input_path") + if not input_path: + return { + "success": False, + "action": "inspect", + "slide_count": 0, + "slides": [], + "errors": [ + { + "code": "INPUT_PATH_MISSING", + "slide_index": -1, + "message": "input_path is required for inspect action.", + } + ], + "error_code": "INPUT_PATH_MISSING", + } + return inspect_deck(str(input_path)) + + if action == "list_templates": + return list_templates() + + return { + "success": False, + "action": str(action), + "valid": False, + "error_code": "UNKNOWN_ACTION", + "errors": [ + { + "code": "UNKNOWN_ACTION", + "slide_index": -1, + "message": ( + f"Action '{action}' is not supported. " + "Use validate_spec, render, inspect, or list_templates." + ), + } + ], + } diff --git a/skills/creative/deck_builder/templates/corporate_v1.pptx b/skills/creative/deck_builder/templates/corporate_v1.pptx new file mode 100644 index 0000000000000000000000000000000000000000..d8261703210b8faafd7edc22169cfe979b109569 GIT binary patch literal 27391 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.0.0" in manifest["requirements"] + assert "pillow" in manifest["requirements"] + assert manifest["issuer"]["org"] == "ARPAHLS" + + +def test_list_templates_returns_bundled_templates(skill): + res = skill.execute({"action": "list_templates"}) + assert res["success"] is True + assert len(res["templates"]) >= 3 + tids = [t["template_id"] for t in res["templates"]] + assert "pitch_v1" in tids + assert "corporate_v1" in tids + assert "minimal_v1" in tids + + +def test_validate_spec_valid_payload(skill): + spec = { + "title": "Clean Pitch", + "template_id": "pitch_v1", + "slides": [ + {"type": "title", "title": "Overview", "subtitle": "A short summary"}, + { + "type": "bullets", + "title": "Key Points", + "bullets": ["First point", "Second point"], + }, + ], + } + res = skill.execute({"action": "validate_spec", "deck_spec": spec}) + assert res["success"] is True + assert res["valid"] is True + assert res["slide_count"] == 2 + assert len(res["errors"]) == 0 + + +def test_validate_spec_invalid_schema_rejects(skill): + bad_spec = {"title": 12345} # missing slides and invalid title type + res = skill.execute({"action": "validate_spec", "deck_spec": bad_spec}) + assert res["success"] is False + assert res["valid"] is False + assert res["error_code"] == "INVALID_SPEC" + assert len(res["errors"]) > 0 + + +def test_validate_spec_bullet_truncated_warning(skill): + spec = { + "title": "Lengthy Bullet Deck", + "slides": [ + { + "type": "bullets", + "title": "Long Bullet Slide", + "bullets": [ + "Short bullet", + "A" * 130, # exceeds 120 chars + ], + } + ], + } + res = skill.execute({"action": "validate_spec", "deck_spec": spec}) + assert res["valid"] is True # non-fatal by default + assert any(w["code"] == "BULLET_TRUNCATED" for w in res["warnings"]) + + +def test_validate_spec_strict_mode_fails_on_warning(skill): + spec = { + "title": "Strict Mode Test", + "slides": [ + { + "type": "bullets", + "title": "Long Bullet Slide", + "bullets": ["A" * 135], + } + ], + } + res = skill.execute({"action": "validate_spec", "deck_spec": spec, "strict": True}) + assert res["valid"] is False + assert res["success"] is False + assert any("STRICT_" in e["code"] for e in res["errors"]) + + +def test_validate_spec_chart_dimension_mismatch(skill): + spec = { + "title": "Mismatched Chart", + "slides": [ + { + "type": "chart", + "title": "Growth", + "chart": { + "kind": "bar", + "categories": ["Q1", "Q2", "Q3"], + "series": [ + {"name": "Users", "values": [10, 20]} + ], # only 2 values for 3 categories + }, + } + ], + } + res = skill.execute({"action": "validate_spec", "deck_spec": spec}) + assert res["valid"] is False + assert any(e["code"] == "CHART_DIMENSION_MISMATCH" for e in res["errors"]) + + +def test_validate_spec_asset_not_found(skill): + spec = { + "title": "Missing Image Deck", + "slides": [ + { + "type": "image", + "title": "Photo", + "image": {"path": "/nonexistent/path/to/missing_file.png"}, + } + ], + } + res = skill.execute({"action": "validate_spec", "deck_spec": spec}) + assert any(w["code"] == "ASSET_NOT_FOUND" for w in res["warnings"]) + + +def test_validate_spec_invalid_base64_asset(skill): + spec = { + "title": "Corrupt Base64 Deck", + "slides": [ + { + "type": "image", + "title": "Corrupt", + "image": {"base64": "!!!not_valid_base64$$$"}, + } + ], + } + res = skill.execute({"action": "validate_spec", "deck_spec": spec}) + assert any(w["code"] == "ASSET_INVALID" for w in res["warnings"]) + + +def test_render_all_ten_slide_types(skill, tmp_path, sample_base64_png): + img_file = tmp_path / "test_logo.png" + Image.new("RGB", (80, 80), color=(20, 150, 80)).save(img_file) + + out_file = tmp_path / "all_types.pptx" + spec = { + "title": "Full Feature Deck", + "template_id": "corporate_v1", + "theme": { + "accent_color": "#1E3A8A", + "font_heading": "Calibri", + "font_body": "Calibri", + }, + "slides": [ + { + "type": "title", + "title": "Corporate Briefing", + "subtitle": "Executive summary", + "image": {"path": str(img_file)}, + }, + { + "type": "section", + "title": "Part 1: Operational Review", + "subtitle": "Key metrics and progress", + }, + { + "type": "bullets", + "title": "Strategic Objectives", + "bullets": ["Expand market presence", "Optimize unit economics"], + }, + { + "type": "two_column", + "title": "Comparison", + "left": ["Legacy Approach", "Manual checks"], + "right": ["Skillware", "Autonomous validation"], + }, + { + "type": "image", + "title": "Visual Dashboard", + "image": {"base64": sample_base64_png}, + "caption": "Figure 1: Pipeline efficiency", + }, + { + "type": "image_caption", + "title": "Architecture", + "image": {"path": str(img_file)}, + "body": "Microservices communicate via gRPC with strict contracts.", + }, + { + "type": "quote", + "quote": "Reliability is the foundation of autonomy.", + "attribution": "System Architect", + }, + { + "type": "table", + "title": "Regional Performance", + "columns": ["Region", "Growth", "Status"], + "rows": [["Americas", "+24%", "On Track"], ["EMEA", "+18%", "Ahead"]], + }, + { + "type": "chart", + "title": "Quarterly Expansion", + "chart": { + "kind": "bar", + "categories": ["Q1", "Q2", "Q3", "Q4"], + "series": [{"name": "ARR ($M)", "values": [4.2, 5.8, 8.1, 11.4]}], + }, + }, + {"type": "blank", "speaker_notes": "Conclude with Q&A session."}, + ], + } + + res = skill.execute( + {"action": "render", "deck_spec": spec, "output_path": str(out_file)} + ) + assert res["success"] is True + assert res["action"] == "render" + assert res["slide_count"] == 10 + assert os.path.exists(out_file) + assert res["file_size_bytes"] > 1000 + + # Inspect rendered file to ensure validity + inspect_res = skill.execute({"action": "inspect", "input_path": str(out_file)}) + assert inspect_res["success"] is True + assert inspect_res["slide_count"] == 10 + assert inspect_res["slides"][0]["title"] == "Corporate Briefing" + assert inspect_res["slides"][9]["has_notes"] is True + + +def test_render_path_traversal_rejected(skill, tmp_path): + spec = {"title": "Safe Deck", "slides": [{"type": "blank"}]} + unsafe_path = str(tmp_path / ".." / ".." / "escape.pptx") + res = skill.execute( + {"action": "render", "deck_spec": spec, "output_path": unsafe_path} + ) + assert res["success"] is False + assert res["error_code"] == "OUTPUT_PATH_UNSAFE" + + bad_ext_path = str(tmp_path / "presentation.pdf") + res_bad_ext = skill.execute( + {"action": "render", "deck_spec": spec, "output_path": bad_ext_path} + ) + assert res_bad_ext["success"] is False + assert res_bad_ext["error_code"] == "OUTPUT_PATH_UNSAFE" + + +def test_inspect_nonexistent_file(skill, tmp_path): + missing = str(tmp_path / "does_not_exist.pptx") + res = skill.execute({"action": "inspect", "input_path": missing}) + assert res["success"] is False + assert res["error_code"] == "INSPECT_FAILED" + + +def test_12_slide_investor_deck_spec(skill, tmp_path): + """ + Acceptance criteria from Issue #276: + An agent can validate a 12-slide investor deck spec, render a .pptx using + the pitch template, receive warnings for one missing optional image, open + the file with editable text and notes, and pass tests offline in CI. + """ + out_file = tmp_path / "series_a_pitch.pptx" + investor_deck_spec = { + "title": "Skillware Series A Deck", + "template_id": "pitch_v1", + "theme": { + "accent_color": "#6E57E0", + "font_heading": "Calibri", + "font_body": "Calibri", + }, + "metadata": { + "author": "ARPA Hellenic Logical Systems", + "subject": "Investor Overview", + }, + "slides": [ + { + "type": "title", + "title": "Skillware", + "subtitle": "Deterministic AI Skills for Agent Runtimes", + }, + { + "type": "bullets", + "title": "The Problem", + "bullets": [ + "Agents hallucinate tool definitions", + "Fragile JSON parsing breaks agent loops", + "Zero audit trail on sensitive actions", + ], + }, + { + "type": "bullets", + "title": "The Solution", + "bullets": [ + "Deterministic, offline-first skill bundles", + "Strict schema validation and constitution enforcement", + "Universal provider adapters", + ], + }, + { + "type": "section", + "title": "Market Opportunity", + "subtitle": "Autonomous AI Agent Infrastructure", + }, + { + "type": "chart", + "title": "Agent Market Growth", + "chart": { + "kind": "line", + "categories": ["2024", "2025", "2026", "2027"], + "series": [ + {"name": "Market Size ($B)", "values": [5.1, 12.8, 28.5, 52.0]} + ], + }, + }, + { + "type": "two_column", + "title": "Competitive Advantage", + "left": ["Monolithic Frameworks", "High latency", "Vendor lock-in"], + "right": [ + "Skillware", + "Sub-millisecond local execute", + "Universal across Claude, OpenAI, Gemini", + ], + }, + { + "type": "table", + "title": "Traction & Milestones", + "columns": ["Quarter", "Skills Shipped", "Total Downloads"], + "rows": [ + ["Q1 2026", "12", "45,000"], + ["Q2 2026", "18", "120,000"], + ["Q3 2026", "25", "310,000"], + ], + }, + { + "type": "image", + "title": "Architecture Diagram", + "image": {"path": "/tmp/optional_arch_diagram_missing.png"}, + "caption": "Figure: Host-to-skill boundary", + }, + { + "type": "quote", + "quote": "Deterministic skills are the fundamental building blocks of production agentic software.", + "attribution": "Lead AI Researcher", + }, + { + "type": "bullets", + "title": "Business Model", + "bullets": [ + "Open-source core registry", + "Enterprise SLA & customized skills support", + "Private corporate registry hosting", + ], + }, + { + "type": "bullets", + "title": "The Ask", + "bullets": [ + "$12M Series A financing", + "18 months runway", + "Key engineering & developer advocacy hires", + ], + "speaker_notes": "Emphasize capital efficiency and current organic developer pull.", + }, + { + "type": "blank", + "speaker_notes": "Thank the investors and open for partner questions.", + }, + ], + } + + # 1. Validation check + val_res = skill.execute( + {"action": "validate_spec", "deck_spec": investor_deck_spec} + ) + assert val_res["valid"] is True + assert val_res["slide_count"] == 12 + # Receives warning for the one missing optional image + assert any(w["code"] == "ASSET_NOT_FOUND" for w in val_res["warnings"]) + + # 2. Render check + render_res = skill.execute( + { + "action": "render", + "deck_spec": investor_deck_spec, + "output_path": str(out_file), + } + ) + assert render_res["success"] is True + assert render_res["slide_count"] == 12 + assert os.path.exists(out_file) + + # 3. Inspect check + inspect_res = skill.execute({"action": "inspect", "input_path": str(out_file)}) + assert inspect_res["success"] is True + assert inspect_res["slide_count"] == 12 + assert inspect_res["slides"][10]["has_notes"] is True + assert inspect_res["slides"][11]["has_notes"] is True + + +def test_constitution_offline_no_remote_apis(): + root = os.path.dirname(__file__) + banned = ( + "openai", + "anthropic", + "gemini", + "requests.get", + "requests.post", + "urllib.request", + ) + for name in ("skill.py", "builder.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}" diff --git a/skillware/core/extras.py b/skillware/core/extras.py index 6e5d4a7..4747ae3 100644 --- a/skillware/core/extras.py +++ b/skillware/core/extras.py @@ -39,6 +39,7 @@ "beautifulsoup4": "bs4", "pyyaml": "yaml", "pillow": "PIL", + "python-pptx": "pptx", } GENERATED_BEGIN = "# --- extras: begin generated by scripts/sync_extras.py ---" diff --git a/tests/fixtures/card_ui_schema/creative__deck_builder.json b/tests/fixtures/card_ui_schema/creative__deck_builder.json new file mode 100644 index 0000000..64c246d --- /dev/null +++ b/tests/fixtures/card_ui_schema/creative__deck_builder.json @@ -0,0 +1,7 @@ +{ + "success": true, + "action": "render", + "template_id": "pitch_v1", + "slide_count": 4, + "output_path": "/tmp/skillware_pitch.pptx" +} \ No newline at end of file diff --git a/tests/skills/creative/test_deck_builder.py b/tests/skills/creative/test_deck_builder.py new file mode 100644 index 0000000..a41f51a --- /dev/null +++ b/tests/skills/creative/test_deck_builder.py @@ -0,0 +1,67 @@ +"""Integration tests for creative/deck_builder through SkillLoader.""" + +from pathlib import Path +from skillware.core.loader import SkillLoader + + +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 "pitch_v1" in bundle["instructions"] + assert bundle["card"]["name"] == "Deck Builder" + + +def test_deck_builder_loader_execute_workflow(tmp_path: Path): + bundle = SkillLoader.load_skill("creative/deck_builder") + skill = bundle["class"]() + + out_file = tmp_path / "integration_deck.pptx" + spec = { + "title": "Loader Integration Presentation", + "template_id": "pitch_v1", + "slides": [ + { + "type": "title", + "title": "Skillware Presentation", + "subtitle": "Built via SkillLoader", + }, + { + "type": "bullets", + "title": "Key Features", + "bullets": ["Offline-first", "100% Deterministic"], + }, + { + "type": "quote", + "quote": "Reliability at scale.", + "attribution": "ARPA HLS", + }, + ], + } + + # 1. Validate + val_res = skill.execute({"action": "validate_spec", "deck_spec": spec}) + assert val_res["success"] is True + assert val_res["valid"] is True + assert val_res["slide_count"] == 3 + + # 2. Render + render_res = skill.execute( + {"action": "render", "deck_spec": spec, "output_path": str(out_file)} + ) + assert render_res["success"] is True + assert render_res["slide_count"] == 3 + assert out_file.is_file() + assert out_file.stat().st_size > 1000 + + # 3. Inspect + inspect_res = skill.execute({"action": "inspect", "input_path": str(out_file)}) + assert inspect_res["success"] is True + assert inspect_res["slide_count"] == 3 + assert inspect_res["slides"][0]["title"] == "Skillware Presentation" + + # 4. List templates + tpl_res = skill.execute({"action": "list_templates"}) + assert tpl_res["success"] is True + assert len(tpl_res["templates"]) >= 3 diff --git a/tests/test_examples_smoke.py b/tests/test_examples_smoke.py index f5fcde5..b0b7423 100644 --- a/tests/test_examples_smoke.py +++ b/tests/test_examples_smoke.py @@ -96,6 +96,17 @@ "Input image not found: examples/sample_input.png", ], ), + ( + "deck_builder_demo.py", + [ + "Loading creative/deck_builder...", + "=== Step 1: List Bundled Templates ===", + "=== Step 2: Validate Deck Specification ===", + "=== Step 3: Render Presentation ===", + "=== Step 4: Inspect Generated PPTX ===", + "Demo complete.", + ], + ), ] # Provider-dependent scripts that are deliberately excluded from CI smoke tests because From b25c80d261c6a0937a746137602653745c6300ec Mon Sep 17 00:00:00 2001 From: rosspeili Date: Fri, 4 Sep 2026 11:34:48 +0300 Subject: [PATCH 2/2] chore(creative/deck_builder): maintainer wrap-up for PR #331 Rebase onto main (CHANGELOG merge with #335), fix skill history commit hash, and correct instructions typo. --- docs/skills/deck_builder.md | 2 +- skills/creative/deck_builder/instructions.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/skills/deck_builder.md b/docs/skills/deck_builder.md index b68459b..9a8a4ca 100644 --- a/docs/skills/deck_builder.md +++ b/docs/skills/deck_builder.md @@ -221,7 +221,7 @@ Commits that touched this skill bundle or its catalog page ([`creative/deck_buil | Commit | Description | Date | Version | Contributors | | :--- | :--- | :--- | :--- | :--- | -| [`0db53ba`](https://github.com/ARPAHLS/skillware/commit/0db53ba) | feat(creative): add deck_builder skill for deterministic PPTX assembly (#276) | 4 Sep 2026 | 0.1.0 | [@tusharjamunkar](https://github.com/tusharjamunkar) | +| [`a66e76e`](https://github.com/ARPAHLS/skillware/commit/a66e76e) | feat(creative): add deck_builder skill for deterministic PPTX assembly (#276) | 4 Sep 2026 | 0.1.0 | [@tusharjamunkar](https://github.com/tusharjamunkar) | ## Enterprise disclaimer diff --git a/skills/creative/deck_builder/instructions.md b/skills/creative/deck_builder/instructions.md index 2cc3c37..e9deeea 100644 --- a/skills/creative/deck_builder/instructions.md +++ b/skills/creative/deck_builder/instructions.md @@ -89,7 +89,7 @@ Use this tool when a user or upstream workflow requests an editable slide deck, ### Agent Mapping Rationale - Slide 1 (`title`): Platform name, tagline, cover. - Slide 2 (`section`): Problem overview ("The High Cost of Database Inefficiency"). -- Slide 3 (`bullets`): Core value proposition (3 concise points, each $< 120$ chars). +- Slide 3 (`bullets`): Core value proposition (3 concise points, each under 120 chars). - Slide 4 (`two_column`): Legacy manual tuning vs CortexEngine autonomous tuning. - Slide 5 (`table`): Benchmark comparison (Latency, Throughput, Cost Reduction across 3 engines). - Slide 6 (`quote`): Enterprise customer endorsement quote.