generate-templates.js
1 #!/usr/bin/env node 2 3 /** 4 * Template Generation Script 5 * Uses Claude Sonnet to generate conversion-optimized templates for all countries and channels 6 * 7 * Generates: 8 * - 20 SMS templates per country (under 160 chars, TCPA compliant) 9 * - 20 email templates per country (CAN-SPAM compliant, varied approaches) 10 * - Total: 1,000 templates (20 × 2 channels × 25 countries) 11 * 12 * Usage: 13 * node scripts/generate-templates.js --country AU --channel sms 14 * node scripts/generate-templates.js --all # Generate all 1,000 templates 15 */ 16 17 import { readFileSync, writeFileSync, mkdirSync } from 'fs'; 18 import { join, dirname } from 'path'; 19 import { fileURLToPath } from 'url'; 20 import Database from 'better-sqlite3'; 21 import { callLLM, getProviderDisplayName } from '../src/utils/llm-provider.js'; 22 import { openRouterLimiter } from '../src/utils/rate-limiter.js'; 23 import { COUNTRIES } from '../src/config/countries.js'; 24 import Logger from '../src/utils/logger.js'; 25 import dotenv from 'dotenv'; 26 27 dotenv.config(); 28 29 const __filename = fileURLToPath(import.meta.url); 30 const __dirname = dirname(__filename); 31 const projectRoot = join(__dirname, '..'); 32 33 const logger = new Logger('TemplateGenerator'); 34 35 // Use Claude Sonnet for high-quality template generation 36 const TEMPLATE_MODEL = process.env.CLAUDE_SONNET_MODEL || 'anthropic/claude-sonnet-4-6'; 37 38 /** 39 * Load best practices documentation 40 */ 41 function loadBestPractices() { 42 const smsBestPractices = readFileSync(join(projectRoot, 'docs/BEST-PRACTICES-SMS.md'), 'utf-8'); 43 const emailBestPractices = readFileSync( 44 join(projectRoot, 'docs/BEST-PRACTICES-EMAIL.md'), 45 'utf-8' 46 ); 47 48 return { smsBestPractices, emailBestPractices }; 49 } 50 51 /** 52 * Generate prompt for template generation 53 */ 54 function buildTemplatePrompt(countryCode, countryName, channel, bestPractices) { 55 const { smsBestPractices, emailBestPractices } = bestPractices; 56 57 const channelGuidance = 58 channel === 'sms' 59 ? ` 60 ## SMS REQUIREMENTS (CRITICAL) 61 62 ${smsBestPractices} 63 64 **Character Limit**: MUST be under 160 characters total including opt-out text 65 **Format**: Each template MUST end with "Reply STOP to opt out" 66 **Personalization**: Use {firstname} placeholder for names (defaults to "there" if unknown) 67 **Compliance**: Follow ALL TCPA requirements listed above 68 ` 69 : ` 70 ## EMAIL REQUIREMENTS (CRITICAL) 71 72 ${emailBestPractices} 73 74 **Subject Line**: Use tested templates from PROPOSAL.md prompt (lowercase, avoid company name, use {kwd} placeholder) 75 **Personalization**: Use {firstname} placeholder (defaults to "there") 76 **Compliance**: Follow ALL CAN-SPAM requirements 77 **No Signoff**: Do NOT include closing signature (auto-added from env vars) 78 `; 79 80 return `You are an expert copywriter creating conversion-optimized ${channel.toUpperCase()} templates for ${countryName}. 81 82 ## Context 83 84 These templates will be used for cold outreach to local service businesses about website conversion audits. The system automatically: 85 - Extracts the PRIMARY weakness from AI scoring (lowest-scoring factor like "trust signals", "CTA clarity", etc.) 86 - Calculates IMPACT percentage (how much this hurts conversions) 87 - Provides EVIDENCE (what specifically is wrong) 88 - Provides REASONING (why it matters) 89 90 ## Your Task 91 92 Generate 20 UNIQUE ${channel.toUpperCase()} templates optimized for ${countryName} businesses. 93 94 **CRITICAL RULES:** 95 96 1. **Don't Give Away the Solution**: Templates should identify the problem and explain WHY it hurts conversions, but NOT explain HOW to fix it (that's the paid audit) 97 2. **Use Placeholders**: Available fields are: 98 - {firstname} - Contact's name (defaults to "there" if unknown) 99 - {business_name} - Business name from domain 100 - {domain} - Website domain 101 - {keyword} - Business type/keyword 102 - {grade} - Letter grade (A+ to F) 103 - {score} - Numeric score (0-100) 104 - {primary_weakness} - Name of lowest-scoring factor 105 - {evidence} - What specifically is wrong 106 - {reasoning} - Why it's a problem 107 - {impact} - Estimated conversion loss % (20-50%) 108 - {industry} - Industry context 109 110 3. **Vary Approaches**: Use different messaging angles across the 20 templates: 111 - Problem-Solution (direct, ROI-focused) 112 - Social Proof (competitor comparison) 113 - Quick Win (low friction offer) 114 - Educational (value-add insight) 115 - Urgency (genuine scarcity) 116 - Authority (expertise/case study) 117 118 4. **Cultural Localization for ${countryName}**: 119 - Use appropriate spelling conventions (e.g., "optimise" for UK/AU, "optimize" for US) 120 - Reference local business norms and expectations 121 - Use culturally appropriate tone (formal vs casual) 122 - Consider local business communication styles 123 124 ${channelGuidance} 125 126 ## Output Format 127 128 Return ONLY valid JSON (no markdown code fences): 129 130 \`\`\`json 131 { 132 "templates": [ 133 { 134 "id": "${channel}_001", 135 "template": "Hi {firstname}, your {domain} site scores {grade}...", 136 ${channel === 'email' ? '"subject_line": "is your {kwd} site doing its job?",' : ''} 137 "approach": "Problem-Solution", 138 "tested": false, 139 "conversions": 0, 140 "sends": 0 141 } 142 ] 143 } 144 \`\`\` 145 146 **CRITICAL**: ${channel === 'sms' ? 'Each SMS template MUST be under 160 characters INCLUDING "Reply STOP to opt out"' : 'Email templates must end with a CTA question, NO signoff/signature'} 147 148 Generate 20 diverse, high-converting templates now.`; 149 } 150 151 /** 152 * Generate templates for a country/channel using Claude Sonnet 153 */ 154 async function generateTemplates(countryCode, countryName, channel) { 155 logger.info(`Generating ${channel.toUpperCase()} templates for ${countryName}...`); 156 157 const bestPractices = loadBestPractices(); 158 const prompt = buildTemplatePrompt(countryCode, countryName, channel, bestPractices); 159 160 try { 161 const response = await openRouterLimiter.schedule(() => 162 callLLM({ 163 model: TEMPLATE_MODEL, 164 messages: [{ role: 'user', content: prompt }], 165 temperature: 0.8, // Higher temp for creative variety 166 max_tokens: 8192, 167 json_mode: true, 168 }) 169 ); 170 171 const { content, usage } = response; 172 173 logger.info( 174 `Generated templates using ${getProviderDisplayName()} - ${usage.promptTokens + usage.completionTokens} tokens` 175 ); 176 177 if (!content) { 178 throw new Error('No content in API response'); 179 } 180 181 // Parse JSON response 182 let result; 183 try { 184 result = JSON.parse(content); 185 } catch (parseError) { 186 // Try to extract JSON if wrapped in markdown 187 const jsonMatch = content.match(/\{[\s\S]*\}/); 188 if (jsonMatch) { 189 result = JSON.parse(jsonMatch[0]); 190 } else { 191 throw new Error(`Failed to parse JSON response: ${parseError.message}`); 192 } 193 } 194 195 if (!result.templates || !Array.isArray(result.templates)) { 196 throw new Error('Invalid response format: missing templates array'); 197 } 198 199 if (result.templates.length !== 20) { 200 logger.warn( 201 `Expected 20 templates but got ${result.templates.length} for ${countryCode}/${channel}` 202 ); 203 } 204 205 // Validate SMS templates are under 160 chars 206 if (channel === 'sms') { 207 const tooLong = result.templates.filter(t => t.template.length > 160); 208 if (tooLong.length > 0) { 209 logger.error(`${tooLong.length} SMS templates exceed 160 characters:`); 210 tooLong.forEach(t => { 211 logger.error(` ${t.id}: ${t.template.length} chars - "${t.template}"`); 212 }); 213 throw new Error(`${tooLong.length} SMS templates exceed 160 character limit`); 214 } 215 } 216 217 logger.success(`Generated ${result.templates.length} ${channel} templates for ${countryName}`); 218 219 return result; 220 } catch (error) { 221 logger.error(`Failed to generate ${channel} templates for ${countryName}`, error); 222 throw error; 223 } 224 } 225 226 /** 227 * Save templates to file 228 */ 229 function saveTemplates(countryCode, channel, templates) { 230 const dirPath = join(projectRoot, `data/templates/${countryCode}`); 231 mkdirSync(dirPath, { recursive: true }); 232 233 const filePath = join(dirPath, `${channel}.json`); 234 writeFileSync(filePath, JSON.stringify(templates, null, 2), 'utf-8'); 235 236 logger.success(`Saved templates to ${filePath}`); 237 } 238 239 /** 240 * Generate templates for all countries and channels 241 */ 242 async function generateAllTemplates() { 243 const countryCodes = Object.keys(COUNTRIES); 244 const channels = ['sms', 'email']; 245 246 logger.info( 247 `Generating templates for ${countryCodes.length} countries × ${channels.length} channels = ${countryCodes.length * channels.length * 20} total templates` 248 ); 249 250 let successCount = 0; 251 let failCount = 0; 252 253 for (const countryCode of countryCodes) { 254 const country = COUNTRIES[countryCode]; 255 256 for (const channel of channels) { 257 try { 258 const templates = await generateTemplates(countryCode, country.name, channel); 259 saveTemplates(countryCode, channel, templates); 260 successCount++; 261 262 // Rate limiting to avoid API throttling 263 await new Promise(resolve => setTimeout(resolve, 2000)); 264 } catch (error) { 265 logger.error(`Failed for ${countryCode}/${channel}:`, error); 266 failCount++; 267 } 268 } 269 } 270 271 logger.success(`\nTemplate generation complete: ${successCount} succeeded, ${failCount} failed`); 272 logger.info(`Total templates: ${successCount * 20}`); 273 } 274 275 /** 276 * Generate templates for a single country/channel 277 */ 278 async function generateSingle(countryCode, channel) { 279 const country = COUNTRIES[countryCode]; 280 281 if (!country) { 282 logger.error(`Invalid country code: ${countryCode}`); 283 logger.info(`Valid country codes: ${Object.keys(COUNTRIES).join(', ')}`); 284 process.exit(1); 285 } 286 287 if (!['sms', 'email'].includes(channel)) { 288 logger.error(`Invalid channel: ${channel}. Must be 'sms' or 'email'`); 289 process.exit(1); 290 } 291 292 const templates = await generateTemplates(countryCode, country.name, channel); 293 saveTemplates(countryCode, channel, templates); 294 295 logger.success( 296 `\nGenerated ${templates.templates.length} templates for ${countryCode}/${channel}` 297 ); 298 } 299 300 // CLI 301 const args = process.argv.slice(2); 302 303 if (args.includes('--all')) { 304 generateAllTemplates() 305 .then(() => process.exit(0)) 306 .catch(error => { 307 logger.error('Generation failed:', error); 308 process.exit(1); 309 }); 310 } else if (args.includes('--country') && args.includes('--channel')) { 311 const countryIndex = args.indexOf('--country') + 1; 312 const channelIndex = args.indexOf('--channel') + 1; 313 314 const countryCode = args[countryIndex]; 315 const channel = args[channelIndex]; 316 317 generateSingle(countryCode, channel) 318 .then(() => process.exit(0)) 319 .catch(error => { 320 logger.error('Generation failed:', error); 321 process.exit(1); 322 }); 323 } else { 324 console.log('Usage:'); 325 console.log(' Generate all templates (1,000 total):'); 326 console.log(' node scripts/generate-templates.js --all'); 327 console.log(''); 328 console.log(' Generate for specific country/channel:'); 329 console.log(' node scripts/generate-templates.js --country AU --channel sms'); 330 console.log(' node scripts/generate-templates.js --country US --channel email'); 331 console.log(''); 332 console.log('Valid countries:', Object.keys(COUNTRIES).join(', ')); 333 console.log('Valid channels: sms, email'); 334 console.log(''); 335 console.log('Notes:'); 336 console.log(' - Uses Claude Sonnet for high-quality template generation'); 337 console.log(' - SMS templates are under 160 chars with TCPA compliance'); 338 console.log(' - Email templates follow CAN-SPAM best practices'); 339 console.log(' - Templates are culturally optimized per country'); 340 console.log(' - Generates 20 unique templates per country/channel'); 341 process.exit(1); 342 }