spintax-grammar-check.js
1 #!/usr/bin/env node 2 /** 3 * Spintax Grammar Checker 4 * Sends raw spintax to Claude for branch-path grammar analysis. 5 * 6 * Strategy: Rather than exhaustively expanding all combinations (AU email has up to 15,552), 7 * Claude reasons through every possible branch path and identifies grammatical problems. 8 * 9 * Usage: 10 * node scripts/spintax-grammar-check.js --file <path> [--lang en] [--auto-fix] 11 * 12 * Examples: 13 * node scripts/spintax-grammar-check.js --file data/templates/AU/email.json 14 * node scripts/spintax-grammar-check.js --file data/templates/AU/sms.json --auto-fix 15 * node scripts/spintax-grammar-check.js --file data/templates/IN/hi/email.json --lang hi 16 */ 17 18 import { readFileSync, writeFileSync } from 'fs'; 19 import { join, dirname, basename } from 'path'; 20 import { fileURLToPath } from 'url'; 21 import { createInterface } from 'readline'; 22 import dotenv from 'dotenv'; 23 24 dotenv.config(); 25 26 const __filename = fileURLToPath(import.meta.url); 27 const __dirname = dirname(__filename); 28 const projectRoot = join(__dirname, '..'); 29 30 // Parse CLI args 31 const args = process.argv.slice(2); 32 const fileArg = args[indexOf('--file') + 1]; 33 const lang = args[indexOf('--lang') + 1] || 'en'; 34 const autoFix = args.includes('--auto-fix'); 35 36 function indexOf(flag) { 37 const i = args.indexOf(flag); 38 return i === -1 ? -9999 : i; 39 } 40 41 if (!fileArg) { 42 console.error( 43 'Usage: node scripts/spintax-grammar-check.js --file <path> [--lang en] [--auto-fix]' 44 ); 45 process.exit(1); 46 } 47 48 const filePath = fileArg.startsWith('/') ? fileArg : join(projectRoot, fileArg); 49 const reportPath = join(dirname(filePath), `grammar-report-${basename(filePath, '.json')}.txt`); 50 51 // OpenRouter API (same credentials as rest of project) 52 const { OPENROUTER_API_KEY } = process.env; 53 if (!OPENROUTER_API_KEY) { 54 console.error('OPENROUTER_API_KEY not set in .env'); 55 process.exit(1); 56 } 57 58 const MODEL = 'anthropic/claude-sonnet-4-6'; 59 60 async function checkGrammar(templateId, spintaxText, fieldName, language) { 61 const langHint = language === 'en' ? 'English' : `language code: ${language}`; 62 const prompt = `You are a professional copywriter and grammar expert reviewing marketing message templates. 63 64 The following is a spintax template in ${langHint}. Spintax uses {option1|option2} to indicate random word choices. [placeholder] tokens will be replaced with real values at runtime. 65 66 Your task: Analyze EVERY possible combination of spintax branches and identify any combinations that produce grammatical errors, awkward phrasing, or broken sentences. Consider: 67 - Subject-verb agreement issues when different branch options are selected 68 - Punctuation problems (double punctuation, missing punctuation) 69 - Word repetition across adjacent branches 70 - Sentences that become grammatically incorrect with certain branch selections 71 - Tone inconsistencies (mixing formal/informal in same sentence) 72 73 TEMPLATE (${fieldName}): 74 --- 75 ${spintaxText} 76 --- 77 78 Respond in this exact format: 79 80 ISSUES FOUND: <number> 81 82 For each issue (if any): 83 ISSUE <n>: 84 PROBLEMATIC BRANCH: <quote the specific {option1|option2} or surrounding text that causes the problem> 85 CONTEXT: <which combinations trigger this issue> 86 PROBLEM: <brief description> 87 SUGGESTED FIX: <corrected spintax that preserves the structure> 88 89 If no issues found, respond with: 90 ISSUES FOUND: 0 91 (Template looks grammatically correct across all branch combinations)`; 92 93 const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { 94 method: 'POST', 95 headers: { 96 Authorization: `Bearer ${OPENROUTER_API_KEY}`, 97 'Content-Type': 'application/json', 98 'HTTP-Referer': 'https://github.com/333method', 99 }, 100 body: JSON.stringify({ 101 model: MODEL, 102 messages: [{ role: 'user', content: prompt }], 103 temperature: 0.1, 104 max_tokens: 2000, 105 }), 106 }); 107 108 if (!response.ok) { 109 throw new Error(`OpenRouter API error: ${response.status} ${await response.text()}`); 110 } 111 112 const data = await response.json(); 113 return data.choices[0].message.content; 114 } 115 116 function parseIssueCount(analysis) { 117 const match = analysis.match(/ISSUES FOUND:\s*(\d+)/); 118 return match ? parseInt(match[1], 10) : 0; 119 } 120 121 function stripBackticks(s) { 122 // Claude often wraps code in backticks — strip them for matching 123 return s.replace(/^`+|`+$/g, '').trim(); 124 } 125 126 function applyFix(spintax, problemBranch, suggestedFix) { 127 if (!problemBranch || !suggestedFix) return spintax; 128 const clean = stripBackticks(problemBranch); 129 if (!clean) return spintax; 130 // Escape special regex chars in the problematic branch 131 const escaped = clean.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 132 const result = spintax.replace(new RegExp(escaped, 'g'), suggestedFix); 133 if (result === spintax) { 134 console.log(` ⚠ Pattern not found in spintax: ${clean.slice(0, 60)}`); 135 } 136 return result; 137 } 138 139 function extractFixes(analysis) { 140 const fixes = []; 141 // Match ISSUE blocks — SUGGESTED FIX may be followed by a code block or inline 142 // Extract the first line of SUGGESTED FIX (strip leading backtick/code block) 143 const issueRegex = 144 /ISSUE \d+:\s*\n\s*PROBLEMATIC BRANCH:\s*(.+?)\n[\s\S]*?SUGGESTED FIX:\s*([^\n]+)/g; 145 let match; 146 while ((match = issueRegex.exec(analysis)) !== null) { 147 const problematic = stripBackticks(match[1].trim()); 148 const fix = stripBackticks(match[2].trim()); 149 if (problematic && fix) { 150 fixes.push({ problematic, fix }); 151 } 152 } 153 return fixes; 154 } 155 156 async function askUser(question) { 157 const rl = createInterface({ input: process.stdin, output: process.stdout }); 158 return new Promise(resolve => { 159 rl.question(question, answer => { 160 rl.close(); 161 resolve(answer.trim().toLowerCase()); 162 }); 163 }); 164 } 165 166 async function main() { 167 console.log(`\n=== Spintax Grammar Check ===`); 168 console.log(`File: ${filePath}`); 169 console.log(`Language: ${lang}`); 170 console.log(`Mode: ${autoFix ? 'auto-fix' : 'interactive'}\n`); 171 172 const fileData = JSON.parse(readFileSync(filePath, 'utf-8')); 173 const templates = fileData.templates || []; 174 175 if (templates.length === 0) { 176 console.log('No templates found in file.'); 177 process.exit(0); 178 } 179 180 const reportLines = [ 181 `Grammar Check Report: ${filePath}`, 182 `Generated: ${new Date().toISOString()}`, 183 `Language: ${lang}`, 184 `Mode: ${autoFix ? 'auto-fix' : 'interactive'}`, 185 '', 186 ]; 187 let totalIssues = 0; 188 let totalFixed = 0; 189 let anyChanges = false; 190 191 for (let i = 0; i < templates.length; i++) { 192 const template = templates[i]; 193 console.log(`\n[${i + 1}/${templates.length}] Checking template: ${template.id}`); 194 195 const fields = [ 196 { name: 'subject_spintax', value: template.subject_spintax }, 197 { name: 'body_spintax', value: template.body_spintax }, 198 ].filter(f => f.value); 199 200 for (const field of fields) { 201 process.stdout.write(` Analyzing ${field.name}...`); 202 const analysis = await checkGrammar(template.id, field.value, field.name, lang); 203 const issueCount = parseIssueCount(analysis); 204 console.log(` ${issueCount} issue(s)`); 205 206 reportLines.push(`Template: ${template.id} | Field: ${field.name}`); 207 reportLines.push(analysis); 208 reportLines.push(''); 209 210 if (issueCount > 0) { 211 totalIssues += issueCount; 212 console.log(`\n${analysis}`); 213 214 const fixes = extractFixes(analysis); 215 216 if (autoFix) { 217 let appliedCount = 0; 218 for (const fix of fixes) { 219 const before = templates[i][field.name]; 220 templates[i][field.name] = applyFix(templates[i][field.name], fix.problematic, fix.fix); 221 if (templates[i][field.name] !== before) { 222 totalFixed++; 223 anyChanges = true; 224 appliedCount++; 225 } 226 } 227 if (appliedCount > 0) { 228 console.log(` ✓ Auto-applied ${appliedCount} fix(es)`); 229 } else if (fixes.length > 0) { 230 console.log( 231 ` ⚠ ${fixes.length} fix(es) identified but patterns not matched in spintax — manual review needed` 232 ); 233 } 234 } else { 235 // Interactive: ask for each fix 236 for (const fix of fixes) { 237 console.log(`\n Problematic: ${fix.problematic}`); 238 console.log(` Suggested: ${fix.fix}`); 239 const answer = await askUser(' Apply this fix? [y/n]: '); 240 if (answer === 'y' || answer === 'yes') { 241 templates[i][field.name] = applyFix( 242 templates[i][field.name], 243 fix.problematic, 244 fix.fix 245 ); 246 totalFixed++; 247 anyChanges = true; 248 console.log(' ✓ Fix applied'); 249 } else { 250 console.log(' ✗ Fix skipped'); 251 } 252 } 253 } 254 } 255 } 256 } 257 258 // Write report 259 writeFileSync(reportPath, reportLines.join('\n')); 260 console.log(`\n=== Summary ===`); 261 console.log(`Total issues found: ${totalIssues}`); 262 console.log(`Total fixes applied: ${totalFixed}`); 263 console.log(`Report saved: ${reportPath}`); 264 265 // Save updated templates if changes were made 266 if (anyChanges) { 267 writeFileSync(filePath, `${JSON.stringify(fileData, null, 2)}\n`); 268 console.log(`Templates saved: ${filePath}`); 269 } else { 270 console.log('No changes to templates.'); 271 } 272 } 273 274 main().catch(err => { 275 console.error('Error:', err.message); 276 process.exit(1); 277 });