$ cat node-template.py

Slide Generator

// Generates a single Reveal.js slide from text content and an instruction. Output is an HTML <section> element.

Process
Document
template.py
1import os2import sys3import json4import re5import base646import mimetypes7import traceback8import httpx910LITELLM_BASE_URL = os.getenv("OPENAI_BASE_URL")11LITELLM_API_KEY = os.getenv("LITELLM_API_KEY")1213if not LITELLM_BASE_URL:14    raise ValueError("OPENAI_BASE_URL environment variable not set")15if not LITELLM_API_KEY:16    raise ValueError("LITELLM_API_KEY environment variable not set")1718SYSTEM_PROMPT = '''You are a presentation slide designer. Your task is to generate exactly ONE Reveal.js slide as an HTML <section> element.1920RULES:211. Output ONLY a single <section>...</section> element. No <!DOCTYPE>, no <html>, no <head>, no wrapper.222. Use <h2> for the slide title (not <h1>).233. Use semantic HTML: <ul>/<ol> for lists, <p> for text, <table> for data, <blockquote> for quotes.244. Use class="fragment" on elements that should appear one-by-one during the presentation.255. Use <aside class="notes">...</aside> for speaker notes (always include brief notes).266. Do NOT set data-background-color on <section>. The presentation theme controls all colors — never override it.277. Keep content concise — slides should be scannable, not walls of text.288. Do NOT include any CSS <style> blocks or <script> tags.299. Do NOT set color, background-color, or any color-related inline styles. Let the Reveal.js theme handle all colors.3010. Only use inline styles for LAYOUT purposes (display:flex, display:grid, gap, font-size). Never for colors or backgrounds.3111. Use plain HTML that Reveal.js themes will style automatically.3233IMAGE PLACEHOLDERS:34When a slide layout requires images, use picsum.photos for royalty-free placeholder images:35- Landscape: <img src="https://picsum.photos/seed/{topic}/600/400" alt="description">36- Portrait: <img src="https://picsum.photos/seed/{topic}/400/600" alt="description">37- Square: <img src="https://picsum.photos/seed/{topic}/400/400" alt="description">38Replace {topic} with a short keyword related to the slide content (e.g. "analytics", "team", "growth").39Always include a descriptive alt attribute. Set width via style if needed for layout.4041LAYOUT PATTERNS you can use:42- Title slide: <h2> + <p> subtitle43- Bullet points: <h2> + <ul> with <li class="fragment">44- Two columns: <div style="display:flex;gap:2em"> with two <div> children45- Data table: <h2> + <table>46- Quote: <blockquote> + attribution47- Image + text: <h2> + <div style="display:flex;gap:2em"> with <img> and text side by side4849Always output valid HTML. The content should be based ONLY on the provided source material — do not invent facts or data.'''5051# Layout instructions for each slide template52TEMPLATE_INSTRUCTIONS = {53    # Title54    "title-slide": "Create a TITLE SLIDE: centered <h2> with large bold title + <p> subtitle below. Keep it simple and impactful.",55    "title-subtitle": "Create a TITLE & SUBTITLE slide: <h2> title + multiple <p> elements for extended subtitle/description text.",56    "section-divider": "Create a SECTION DIVIDER: a simple <section> with centered <h2> in bold and a <p> sub-label. Do NOT set any background color — the theme handles it.",57    "closing-thankyou": "Create a CLOSING slide: centered <h2> 'Thank You' or similar + <p> with contact info or call-to-action below.",58    # Content59    "bullet-points": "Create a BULLET POINTS slide: <h2> title + <ul> with 4-6 <li class='fragment'> bullet items. Keep each bullet concise.",60    "numbered-list": "Create a NUMBERED LIST slide: <h2> title + <ol> with numbered <li class='fragment'> items for steps or sequences.",61    "key-takeaway": "Create a KEY TAKEAWAY slide: <h2> small label + <p style='font-size:1.4em'> with one prominent statement, then <p> for supporting context.",62    "text-block": "Create a TEXT BLOCK slide: <h2> title + several <p> paragraphs of flowing text content. Keep paragraphs concise.",63    # Layout64    "two-column": "Create a TWO-COLUMN slide: <h2> title + <div style='display:flex;gap:2em'> with two equal <div> children containing content.",65    "two-column-header": "Create a TWO-COLUMN WITH HEADER slide: <h2> title + <p> subtitle description + <div style='display:flex;gap:2em'> with two <div> columns.",66    "image-left": "Create an IMAGE LEFT slide. Use this EXACT structure:\n"67        "<section>\n"68        "  <div style='display:flex;gap:2em;align-items:center'>\n"69        "    <div style='flex:1;min-width:0'><img src='{{IMAGE}}' alt='description' style='width:100%;border-radius:8px'></div>\n"70        "    <div style='flex:1;min-width:0'>\n"71        "      <h2>Title</h2>\n"72        "      (text content, bullet points, etc.)\n"73        "    </div>\n"74        "  </div>\n"75        "  <aside class='notes'>Speaker notes.</aside>\n"76        "</section>\n"77        "CRITICAL: Both children MUST have style='flex:1;min-width:0'. The image MUST be wrapped in a <div> — never put <img> as a direct flex child. "78        "Use {{IMAGE}} as the exact src value — it will be replaced with the actual image.",79    "image-right": "Create an IMAGE RIGHT slide. Use this EXACT structure:\n"80        "<section>\n"81        "  <div style='display:flex;gap:2em;align-items:center'>\n"82        "    <div style='flex:1;min-width:0'>\n"83        "      <h2>Title</h2>\n"84        "      (text content, bullet points, etc.)\n"85        "    </div>\n"86        "    <div style='flex:1;min-width:0'><img src='{{IMAGE}}' alt='description' style='width:100%;border-radius:8px'></div>\n"87        "  </div>\n"88        "  <aside class='notes'>Speaker notes.</aside>\n"89        "</section>\n"90        "CRITICAL: Both children MUST have style='flex:1;min-width:0'. The image MUST be wrapped in a <div> — never put <img> as a direct flex child. "91        "Use {{IMAGE}} as the exact src value — it will be replaced with the actual image.",92    # Data93    "stats-metrics": "Create a STATS/METRICS slide: <h2> title + flex/grid container with 3-5 metric cards, each having a large number (<strong style='font-size:2em'>) and label below.",94    "data-table": "Create a DATA TABLE slide: <h2> title + <table> with <thead>/<tbody>, proper headers, and data rows. Keep the table clean and readable.",95    "timeline": "Create a TIMELINE slide: <h2> title + ordered list or custom layout showing chronological events with dates and descriptions.",96    "progress-steps": "Create a PROGRESS STEPS slide: <h2> title + horizontal flex container with numbered step circles/badges and labels below each step.",97    # Compare98    "side-by-side": "Create a SIDE-BY-SIDE comparison: <h2> title + <div style='display:flex;gap:2em'> with two <div> sections, each with its own <h3> heading and feature list.",99    "pros-cons": "Create a PROS & CONS slide: <h2> title + two columns — left column with green-themed 'Pros' heading and list, right with red-themed 'Cons' heading and list.",100    "before-after": "Create a BEFORE & AFTER slide: <h2> title + two columns with 'Before' label on left and 'After' label on right, showing the transformation.",101    "swot-matrix": "Create a SWOT MATRIX: <h2> title + 2x2 CSS grid (<div style='display:grid;grid-template-columns:1fr 1fr;gap:1em'>). Four cells: Strengths, Weaknesses, Opportunities, Threats, each with colored header and bullet items.",102    # Special103    "quote-blockquote": "Create a QUOTE slide: <blockquote><p> with the quoted text </p></blockquote> + <p> for attribution (e.g. '— Author Name'). Make the quote prominent.",104    "team-profile-grid": "Create a TEAM GRID slide: <h2> title + flex/grid container with team member cards, each having a name in <strong>, role in <em>, and brief description.",105    "code-showcase": "Create a CODE SHOWCASE slide: <h2> title + <pre><code> block with formatted code sample, followed by <p> explanation of what the code does.",106    "big-number": "Create a BIG NUMBER slide: <h2> small context label + <p style='font-size:3em;font-weight:bold'> with one large number/stat + <p> description below explaining the significance.",107    # Branded — Polizia Postale (layout from original PPTX)108    "polpostale-intro": "Create a POLIZIA POSTALE INTRO/COVER slide. Use this EXACT structure:\n"109        "<section class='pp-cover'>\n"110        "  <div class='pp-intro-layout'>\n"111        "    <img class='pp-emblem' src='__PP_STEMMA__' alt='Stemma'>\n"112        "    <div class='pp-intro-text'>\n"113        "      <h2>TITLE</h2>\n"114        "      <p class='pp-subtitle'>Subtitle line 1</p>\n"115        "      <p class='pp-subtitle'>Subtitle line 2</p>\n"116        "    </div>\n"117        "  </div>\n"118        "  <aside class='notes'>Cover slide.</aside>\n"119        "</section>\n"120        "Replace the title and subtitle with content from the source material. "121        "The emblem goes on the LEFT, title text on the RIGHT — matching the original PPTX layout. "122        "The h2 title should be in UPPERCASE. The pp-subtitle paragraphs are for secondary info. "123        "The h2 title should be in UPPERCASE. The pp-subtitle paragraphs are for secondary info. Keep all class names exactly.",124    "polpostale-content": "Create a POLIZIA POSTALE CONTENT slide. Use this EXACT structure:\n"125        "<section>\n"126        "  <div class='pp-header-title'>SLIDE TITLE HERE</div>\n"127        "  <h2>Section heading</h2>\n"128        "  (content: bullet points, text, tables, etc.)\n"129        "  <aside class='notes'>Speaker notes.</aside>\n"130        "</section>\n"131        "The pp-header-title is white bold text positioned INSIDE the blue header bar at the top — this is the slide title. "132        "The <h2> below is for the main content heading in navy color. "133        "The theme background image already includes the blue header bar, Polizia Postale badge, and footer — do NOT add any badge images. "134        "Use standard HTML for content (lists, tables, text).",135    "polpostale-image-left": "Create a POLIZIA POSTALE IMAGE LEFT slide. Use this EXACT structure:\n"136        "<section>\n"137        "  <div class='pp-header-title'>SLIDE TITLE HERE</div>\n"138        "  <div style='display:flex;gap:2em;align-items:center;padding:0.5em 1em'>\n"139        "    <div style='flex:1'><img src='{{IMAGE}}' alt='description' style='max-width:100%;border-radius:8px'></div>\n"140        "    <div style='flex:1'>\n"141        "      <h2>Section heading</h2>\n"142        "      (text content, bullet points, etc.)\n"143        "    </div>\n"144        "  </div>\n"145        "  <aside class='notes'>Speaker notes.</aside>\n"146        "</section>\n"147        "The pp-header-title is white bold text positioned INSIDE the blue header bar at the top. "148        "Use {{IMAGE}} as the exact src value — it will be replaced with the actual image. "149        "The theme background image already includes the blue header bar, badge, and footer — do NOT add any badge images.",150    "polpostale-image-right": "Create a POLIZIA POSTALE IMAGE RIGHT slide. Use this EXACT structure:\n"151        "<section>\n"152        "  <div class='pp-header-title'>SLIDE TITLE HERE</div>\n"153        "  <div style='display:flex;gap:2em;align-items:center;padding:0.5em 1em'>\n"154        "    <div style='flex:1'>\n"155        "      <h2>Section heading</h2>\n"156        "      (text content, bullet points, etc.)\n"157        "    </div>\n"158        "    <div style='flex:1'><img src='{{IMAGE}}' alt='description' style='max-width:100%;border-radius:8px'></div>\n"159        "  </div>\n"160        "  <aside class='notes'>Speaker notes.</aside>\n"161        "</section>\n"162        "The pp-header-title is white bold text positioned INSIDE the blue header bar at the top. "163        "Use {{IMAGE}} as the exact src value — it will be replaced with the actual image. "164        "The theme background image already includes the blue header bar, badge, and footer — do NOT add any badge images.",165    "polpostale-outro": "Create a POLIZIA POSTALE CLOSING slide. Use this EXACT structure:\n"166        "<section class='pp-closing'>\n"167        "  <img class='pp-emblem' src='__PP_STEMMA__' alt='Stemma'>\n"168        "  <p class='pp-closing-text'>GRAZIE</p>\n"169        "  <aside class='notes'>Closing slide.</aside>\n"170        "</section>\n"171        "The closing slide shows the coat of arms next to 'GRAZIE' in large navy bold text (40pt equivalent). "172        "Replace 'GRAZIE' with appropriate closing text from the source material if relevant. "173        "The pp-closing class centers the layout horizontally. Keep all class names exactly.",174}175176177def build_layout_instruction(template_name):178    """Build additional system prompt instruction for the selected layout template."""179    if not template_name or template_name == "auto":180        return ""181    instruction = TEMPLATE_INSTRUCTIONS.get(template_name, "")182    if instruction:183        return f"\n\nIMPORTANT LAYOUT INSTRUCTION: The user selected the '{template_name}' layout. {instruction}\nFollow this layout pattern precisely while adapting the content."184    return f"\n\nThe user selected the '{template_name}' layout. Use this layout pattern."185186187def call_llm(messages: list, model: str) -> str:188    # Call LiteLLM proxy using OpenAI-compatible API.189    headers = {190        "Authorization": f"Bearer {LITELLM_API_KEY}",191        "Content-Type": "application/json",192    }193    payload = {194        "model": model,195        "messages": messages,196        "temperature": 0.3,197    }198    url = f"{LITELLM_BASE_URL}/v1/chat/completions"199200    with httpx.Client(timeout=300) as client:201        response = client.post(url, headers=headers, json=payload)202        response.raise_for_status()203        result = response.json()204        return result["choices"][0]["message"]["content"].strip()205206207def extract_section(html: str) -> str:208    # Extract the <section>...</section> from LLM output, handling markdown fences.209210    # Strip markdown code fences if present211    html = re.sub(r"^```(?:html)?\s*", "", html, flags=re.MULTILINE)212    html = re.sub(r"```\s*$", "", html, flags=re.MULTILINE)213    html = html.strip()214215    # Extract <section>...</section> if wrapped in other content216    match = re.search(r"(<section[\s\S]*</section>)", html, re.IGNORECASE)217    if match:218        return match.group(1)219220    # If no <section> found, wrap the content in one221    return f"<section>\n{html}\n</section>"222223224def main():225    try:226        execution_input = json.loads(sys.stdin.read())227        inputs = execution_input.get("inputs", {})228229        content = inputs.get("content")230        instruction = inputs.get("instruction")231        image_filename = inputs.get("image", "")232        template_name = inputs.get("template_name", "auto")233        llm_model = inputs.get("llmModel")234235        if not content:236            raise ValueError("Required input 'content' not provided")237        if not instruction:238            raise ValueError("Required input 'instruction' not provided")239        if not llm_model:240            raise ValueError("Required input 'llmModel' not provided")241242        # Read image and convert to base64 data URI if provided243        image_data_uri = ""244        if image_filename:245            image_path = f"/data/input/{image_filename}"246            if os.path.isfile(image_path):247                mime = mimetypes.guess_type(image_filename)[0] or "image/png"248                with open(image_path, "rb") as f:249                    b64 = base64.b64encode(f.read()).decode("ascii")250                image_data_uri = f"data:{mime};base64,{b64}"251                print(f"Image loaded: {image_filename} ({len(b64)} base64 chars)", file=sys.stderr)252253        print(f"Generating slide with model: {llm_model}", file=sys.stderr)254        print(f"Content length: {len(content)} chars", file=sys.stderr)255        print(f"Instruction: {instruction[:100]}...", file=sys.stderr)256        print(f"Layout template: {template_name}", file=sys.stderr)257258        # Build system prompt with optional layout instruction259        system_prompt = SYSTEM_PROMPT + build_layout_instruction(template_name)260261        # If image provided, add instruction to use {{IMAGE}} placeholder262        if image_data_uri:263            system_prompt += (264                "\n\nIMAGE INPUT: The user has provided an image. "265                "Use {{IMAGE}} as the exact src attribute value for the <img> tag. "266                "CRITICAL: Always wrap the <img> inside a <div style='flex:1;min-width:0'> when using flex layout. "267                "Never place <img> as a direct flex child — it will overflow. "268                "Example: <div style='flex:1;min-width:0'><img src='{{IMAGE}}' alt='description' style='width:100%;border-radius:8px'></div>. "269                "Do NOT use picsum.photos — use {{IMAGE}} instead."270            )271272        user_message = (273            f"<instruction>{instruction}</instruction>\n\n"274            f"<source_content>\n{content}\n</source_content>"275        )276277        messages = [278            {"role": "system", "content": system_prompt},279            {"role": "user", "content": user_message},280        ]281282        raw_output = call_llm(messages, llm_model)283        slide_html = extract_section(raw_output)284285        # Post-process: ensure flex children have min-width:0 to prevent overflow.286        # LLMs output flex:1 without min-width:0, causing text to expand and hide images.287        def _fix_flex_styles(html):288            result = []289            i = 0290            tag = 'style="'291            while i < len(html):292                pos = html.find(tag, i)293                if pos == -1:294                    # Also try single quotes295                    pos2 = html.find("style='", i)296                    if pos2 == -1:297                        result.append(html[i:])298                        break299                    pos = pos2300                    tag = "style='"301                    q = "'"302                else:303                    q = '"'304                result.append(html[i:pos])305                # Find end of style attribute306                start = pos + len('style=') + 1307                end = html.find(q, start)308                if end == -1:309                    result.append(html[pos:])310                    break311                style_val = html[start:end]312                # Fix: add min-width:0 if has flex:1 but not min-width313                if 'flex:' in style_val and '1' in style_val.split('flex:')[-1][:3] and 'min-width' not in style_val:314                    style_val = style_val.rstrip(';') + ';min-width:0;overflow:hidden'315                result.append(f'style={q}{style_val}{q}')316                i = end + 1317                tag = 'style="'318            return ''.join(result)319320        slide_html = _fix_flex_styles(slide_html)321322        # Replace {{IMAGE}} placeholder with actual base64 data URI323        if image_data_uri and "{{IMAGE}}" in slide_html:324            slide_html = slide_html.replace("{{IMAGE}}", image_data_uri)325            print("Replaced {{IMAGE}} placeholder with base64 data URI", file=sys.stderr)326327        print(f"Generated slide: {len(slide_html)} chars", file=sys.stderr)328329        output = {"slide_html": slide_html}330        print(json.dumps(output))331332    except Exception as e:333        error_output = {334            "error": str(e),335            "errorType": type(e).__name__,336            "traceback": traceback.format_exc(),337        }338        print(json.dumps(error_output), file=sys.stderr)339        sys.exit(1)340341342if __name__ == "__main__":343    main()