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..9a8a4ca --- /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 | +| :--- | :--- | :--- | :--- | :--- | +| [`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 + +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..e9deeea --- /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 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. +- 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 0000000..d826170 Binary files /dev/null and b/skills/creative/deck_builder/templates/corporate_v1.pptx differ diff --git a/skills/creative/deck_builder/templates/minimal_v1.pptx b/skills/creative/deck_builder/templates/minimal_v1.pptx new file mode 100644 index 0000000..d826170 Binary files /dev/null and b/skills/creative/deck_builder/templates/minimal_v1.pptx differ diff --git a/skills/creative/deck_builder/templates/pitch_v1.pptx b/skills/creative/deck_builder/templates/pitch_v1.pptx new file mode 100644 index 0000000..d826170 Binary files /dev/null and b/skills/creative/deck_builder/templates/pitch_v1.pptx differ diff --git a/skills/creative/deck_builder/test_skill.py b/skills/creative/deck_builder/test_skill.py new file mode 100644 index 0000000..5b55ed2 --- /dev/null +++ b/skills/creative/deck_builder/test_skill.py @@ -0,0 +1,434 @@ +"""Unit and bundle tests for creative/deck_builder.""" + +import base64 +import io +import os +from PIL import Image +import pytest + +from skills.creative.deck_builder.skill import DeckBuilderSkill + + +@pytest.fixture +def skill(): + return DeckBuilderSkill() + + +@pytest.fixture +def sample_base64_png(): + img = Image.new("RGB", (100, 100), color=(110, 87, 224)) + buf = io.BytesIO() + img.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("utf-8") + + +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["category"] == "creative" + assert "python-pptx>=1.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