/ scripts / generate-templates.js
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  }