/ scripts / spintax-grammar-check.js
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  });