gigafibre-fsm/services/targo-hub/scripts/ai-convert-to-native.js
louispaulb 0fd1e9f6b5 feat(campaigns/templates): Gemini-powered HTML→native converter
Scales the native-block migration from "one template per manual spec"
to "any compiled .html template, one CLI command, ~5 seconds, ~$0.001
per template" via Gemini Flash semantic interpretation.

Pipeline (ai-convert-to-native.js):
  1. Read existing compiled .html
  2. Send inner body to Gemini Flash with a tight JSON schema
     (block.type ∈ text/image/button/divider/html, plus type-specific
     fields like fontSize/color/padding/href).
  3. AI returns { preheader, ariaLabel, blocks: [...] }
  4. Deterministic emit of a templates-spec/<name>-native.js file —
     no AI-touched markup goes into the final compiled output.
  5. Validation: every {{var}} in source MUST survive into the spec;
     warn loudly if any are dropped (the AI occasionally omits minor
     placeholders like {{year}} in the copyright line).

Why deterministic emit matters:
  Gemini understands SEMANTICS reliably ("this paragraph is the
  greeting, this div is the CTA, this span is a chip") but
  hallucinates DETAILS when generating final HTML. Splitting the
  responsibilities means the AI only outputs structured JSON
  describing the layout, and build-native-template.js produces the
  bytes shipped to recipients.

First conversion: gift-email-fr → gift-email-fr-native
  - 15 blocks identified by Gemini in 3006 tokens (Flash, ~5s).
  - 4 row groups: view-in-browser, white card (intro/chips/CTA/
    footer copy), contact info, dark footer band.
  - 7 text + 1 image + 1 button + 6 html blocks (chips, multi-logo
    strip, brand-logo card, expiry section stay as raw HTML —
    correct, those have no native equivalent).
  - HTML payload: 19,664 bytes vs original 39,913 bytes — **-51%**.
  - One AI omission caught by the new sanity check: {{year}} was
    stripped from the © line in the dark footer. Hand-patched in the
    generated spec. Re-running with stricter prompt should reduce
    that occurrence rate.

Hub preview endpoint now defaults vars.year to current year (matches
the test-send endpoint that already did this), so the sample render
shows "© 2026 TARGO Communications" instead of "©  TARGO ...".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:29:15 -04:00

214 lines
10 KiB
JavaScript

#!/usr/bin/env node
'use strict'
/**
* ai-convert-to-native.js — interpret an existing compiled .html template
* via Gemini Flash and produce a native-block templates-spec/<name>.js file
* that can be built by build-native-template.js.
*
* The AI handles SEMANTIC interpretation only (which paragraph is a
* greeting? which div is the CTA? which span is a chip?) — the conversion
* to specific Unlayer JSON / HTML markup is then deterministic in
* build-native-template.js. This split prevents AI hallucinations in the
* final markup that gets sent to recipients.
*
* Usage:
* AI_API_KEY=... node ai-convert-to-native.js gift-email-fr
*
* Output:
* scripts/templates-spec/gift-email-fr-native.js (generated)
* templates/gift-email-fr-native.html + .json (built from the spec)
*/
const fs = require('fs')
const path = require('path')
const { aiCall } = require('../lib/ai')
// The AI returns an array of "block descriptors". Our deterministic builder
// then maps each one to the matching factory in build-native-template.js.
// Keeping the schema TIGHT means less surface for the model to invent things.
const SYSTEM_PROMPT = `You are an email-template interpreter. Given the inner-body HTML of a transactional email, identify the SEMANTIC BLOCKS and return ONLY a JSON object: { "preheader": "...", "ariaLabel": "...", "blocks": [ ... ] }.
Each block is an object with:
- "type" — one of: "text", "image", "button", "html", "divider"
- "purpose" — short label like "greeting", "intro", "cta", "expiry-badge", "prorata", "signature", "contact-info", "header-logo", "footer-logo", "option-1-chip", "option-2-chip", "brand-logos-card", "view-in-browser-link", "footer-text", "footer-band"
- type-specific fields (see below)
For type "text":
- "html" : the rich HTML content (preserve <strong>, <em>, <br/>, <a>, Mustache {{vars}} like {{firstname}}, etc.)
- "fontSize" : e.g. "16px", "14px", "13px"
- "fontWeight": 400 / 500 / 600 / 700
- "color" : hex like "#374151" or "#1B2E24"
- "textAlign" : "left" / "center" / "right" / "justify"
- "padding" : CSS shorthand for container padding, e.g. "10px 36px 14px"
- "background": (optional) row background color hex; default "#ffffff" if part of the white card
- "lineHeight": (optional) e.g. "150%" or "140%"
For type "image":
- "src" : full URL
- "alt" : alt text
- "width" : pixel width as number (e.g. 140)
- "padding" : container padding
- "textAlign": "left" / "center" / "right"
- "background": (optional) row background
- "href" : (optional) link URL
For type "button":
- "text" : button label HTML (often with Mustache like "🎁 {{amount}}")
- "href" : link URL (often "{{gift_url}}")
- "bgColor" : background hex
- "color" : text color hex
- "padding" : container padding
- "buttonPadding": inner button padding (e.g. "30px 24px")
- "borderRadius": e.g. "12px"
- "fontSize": e.g. "32px"
- "background": (optional) row background
For type "html":
- "html" : the raw HTML markup (keep this for chips, multi-image rows, anything too custom for native blocks)
- "padding" : container padding
- "background": (optional) row background
For type "divider":
- "padding" : container padding
- "background": (optional) row background
ROW STYLING — if multiple consecutive blocks share the same card (white background, side borders, etc.), they should each carry the SAME "background" value so the deterministic builder groups them into one row with that background.
CRITICAL CONSTRAINTS:
- Preserve ALL Mustache placeholders verbatim: {{firstname}}, {{amount}}, {{gift_url}}, {{expires_at_date}}, {{commitment_months}}, {{description}}, {{year}}, {{view_url}}.
- Preserve all Mustache section blocks: {{#view_url}}...{{/view_url}} — these go inside an "html" block as-is.
- Preserve external image URLs verbatim (xqy3m.mjt.lu/...).
- The "preheader" is the hidden div at the top of the body — extract its inner text.
- The "ariaLabel" is the aria-label of the role="article" outer div.
- Output VALID JSON, no markdown fences, no commentary.`
function buildUserPrompt (innerHtml) {
return `Inner body HTML to interpret:
\`\`\`html
${innerHtml.slice(0, 60000)}
\`\`\`
Return the JSON now.`
}
function extractInnerBody (fullHtml) {
const m = fullHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
return m ? m[1] : fullHtml
}
// Map AI block descriptors → templates-spec JS source. The spec is a JS
// module so the operator can hand-tweak before re-running the builder.
function generateSpec (aiResult) {
const { preheader, ariaLabel, blocks } = aiResult
if (!Array.isArray(blocks)) throw new Error('AI did not return blocks[]')
// Group consecutive blocks sharing the same background into one row.
const rows = []
let current = null
for (const b of blocks) {
const bg = b.background || ''
if (!current || current.bg !== bg) {
if (current) rows.push(current)
current = { bg, blocks: [] }
}
current.blocks.push(b)
}
if (current) rows.push(current)
const factoryFor = (b) => {
switch (b.type) {
case 'text':
return `textBlock({ html: ${JSON.stringify(b.html || '')}, padding: ${JSON.stringify(b.padding || '10px')}, fontSize: ${JSON.stringify(b.fontSize || '16px')}, fontWeight: ${JSON.stringify(b.fontWeight || 400)}, color: ${JSON.stringify(b.color || '#374151')}, textAlign: ${JSON.stringify(b.textAlign || 'left')}${b.lineHeight ? `, lineHeight: ${JSON.stringify(b.lineHeight)}` : ''} })`
case 'image':
return `imageBlock({ src: ${JSON.stringify(b.src || '')}, alt: ${JSON.stringify(b.alt || '')}, width: ${b.width || 140}, padding: ${JSON.stringify(b.padding || '10px')}, textAlign: ${JSON.stringify(b.textAlign || 'center')}${b.href ? `, href: ${JSON.stringify(b.href)}` : ''} })`
case 'button':
return `buttonBlock({ text: ${JSON.stringify(b.text || '')}, href: ${JSON.stringify(b.href || '')}, padding: ${JSON.stringify(b.padding || '10px 25px')}, buttonPadding: ${JSON.stringify(b.buttonPadding || '30px 24px')}, bgColor: ${JSON.stringify(b.bgColor || '#00C853')}, color: ${JSON.stringify(b.color || '#FFFFFF')}, borderRadius: ${JSON.stringify(b.borderRadius || '12px')}, fontSize: ${JSON.stringify(b.fontSize || '32px')} })`
case 'divider':
return `dividerBlock({ padding: ${JSON.stringify(b.padding || '10px')} })`
case 'html':
default:
return `htmlBlock({ html: ${JSON.stringify(b.html || '')}, padding: ${JSON.stringify(b.padding || '0')} })`
}
}
const rowSrcs = rows.map(r => {
const blockSrcs = r.blocks.map(b => ` ${factoryFor(b)}, // ${b.purpose || b.type}`).join('\n')
const opts = r.bg ? `, { backgroundColor: ${JSON.stringify(r.bg)}, border: '1px solid #e5e7eb' }` : ''
return ` row([\n${blockSrcs}\n ]${opts}),`
})
return `'use strict'
/**
* AUTO-GENERATED by ai-convert-to-native.js — review before deploying.
*
* The AI identified ${blocks.length} semantic block(s) in ${rows.length} row(s).
* Each text/image/button block is individually editable in Unlayer.
* html blocks (chips, multi-image strips) require raw HTML edits.
*/
module.exports = {
preheader: ${JSON.stringify(preheader || '')},
ariaLabel: ${JSON.stringify(ariaLabel || '')},
rows: ({ textBlock, imageBlock, buttonBlock, htmlBlock, dividerBlock, row }) => [
${rowSrcs.join('\n\n')}
],
}
`
}
// ── CLI ───────────────────────────────────────────────────────────────────
async function main () {
const srcName = process.argv[2]
if (!srcName) { console.error('Usage: ai-convert-to-native.js <template-name>'); process.exit(1) }
const tplDir = path.resolve(__dirname, '..', 'templates')
const srcPath = path.join(tplDir, srcName + '.html')
if (!fs.existsSync(srcPath)) {
console.error(`✗ Source not found: ${srcPath}`); process.exit(1)
}
const innerHtml = extractInnerBody(fs.readFileSync(srcPath, 'utf8'))
console.log(`→ Source: ${srcName}.html (${innerHtml.length}b inner body)`)
console.log('→ Asking Gemini Flash to identify blocks...')
const aiResult = await aiCall(SYSTEM_PROMPT, buildUserPrompt(innerHtml), {
jsonMode: true, maxTokens: 32768, temperature: 0.2,
})
if (aiResult.error || aiResult.raw) {
console.error('✗ AI returned unparseable output:', aiResult); process.exit(1)
}
console.log(`→ AI identified ${aiResult.blocks?.length || 0} blocks`)
const outName = srcName + '-native'
const specPath = path.resolve(__dirname, 'templates-spec', outName + '.js')
const specSrc = generateSpec(aiResult)
fs.writeFileSync(specPath, specSrc, 'utf8')
console.log(`✓ Spec written: scripts/templates-spec/${outName}.js (${specSrc.length}b)`)
// Sanity check: every {{var}} present in the source must survive into the
// generated spec. The AI occasionally drops minor placeholders ({{year}}
// in a copyright line is the canonical miss) — warn loudly so the
// operator can patch the spec before building.
const srcVars = new Set([...innerHtml.matchAll(/\{\{\s*[#/]?\s*([a-z0-9_]+)\s*\}\}/gi)].map(m => m[1].toLowerCase()))
const specVars = new Set([...specSrc.matchAll(/\{\{\s*[#/]?\s*([a-z0-9_]+)\s*\}\}/gi)].map(m => m[1].toLowerCase()))
const missing = [...srcVars].filter(v => !specVars.has(v))
if (missing.length) {
console.warn(`${missing.length} Mustache variable(s) NOT preserved in spec — review before building:`)
for (const v of missing) console.warn(` - {{${v}}}`)
console.warn(' The AI sometimes drops minor placeholders. Edit the spec file to restore them.')
} else {
console.log(`✓ All ${srcVars.size} Mustache variable(s) preserved in spec`)
}
console.log()
console.log('Next step: review the spec, then build the files:')
console.log(` node scripts/build-native-template.js ${outName}`)
console.log()
console.log('If the layout is good, deploy by copying the generated')
console.log(`templates/${outName}.html and .json to prod.`)
}
main().catch(e => { console.error('Fatal:', e); process.exit(1) })