UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

595 lines (592 loc) 25.2 kB
/** * Writing Validation Engine * * Core validation engine that scans text for AI detection patterns, banned phrases, * and authenticity markers. Provides comprehensive scoring and issue reporting. * * @implements @.aiwg/requirements/use-cases/UC-001-validate-ai-generated-content.md * @architecture @.aiwg/architecture/software-architecture-doc.md - Section 5.1 WritingValidator * @nfr @.aiwg/requirements/nfr-modules/performance.md - NFR-PERF-001 (<60s validation) * @nfr @.aiwg/requirements/nfr-modules/accuracy.md - NFR-ACC-001 (<5% false positives) * @tests @test/unit/writing/validation-engine.test.ts * @depends @src/writing/validation-rules.ts * @depends @src/writing/pattern-library.ts */ import { readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { ValidationRuleLoader } from './validation-rules.js'; import { loadScoringConfig, getScoringConfig } from './scoring-config-loader.js'; /** * Core Writing Validation Engine */ export class WritingValidationEngine { ruleLoader; ruleSet = null; initialized = false; constructor(guideBasePath) { this.ruleLoader = new ValidationRuleLoader(guideBasePath); } /** * Initialize the engine by loading rules */ async initialize() { if (this.initialized) { return; } // Load scoring configuration await loadScoringConfig(); try { this.ruleSet = await this.ruleLoader.loadRuleSet(); // If loaded rules are empty (files not found), use defaults if (this.ruleSet.bannedPhrases.length === 0 && this.ruleSet.aiPatterns.length === 0 && this.ruleSet.structuralPatterns.length === 0) { this.ruleSet = this.ruleLoader.getDefaultRules(); } } catch (error) { // Fall back to default rules if guide is not available console.warn('Failed to load AIWG rules, using defaults:', error); this.ruleSet = this.ruleLoader.getDefaultRules(); } this.initialized = true; } /** * Validate content and return comprehensive results */ async validate(content, context) { await this.initialize(); const issues = []; // Run all detections issues.push(...this.detectBannedPhrases(content, context)); issues.push(...this.detectAIPatterns(content, context)); issues.push(...this.detectFormulicStructures(content)); // Analyze authenticity const authenticityAnalysis = this.analyzeAuthenticity(content); // Check for missing authenticity markers if (authenticityAnalysis.missingMarkers.length > 0) { issues.push({ type: 'missing_authenticity', severity: 'info', message: 'Content lacks authenticity markers', location: { start: 0, end: 0, line: 1, column: 1 }, suggestion: `Consider adding: ${authenticityAnalysis.missingMarkers.join(', ')}`, context: authenticityAnalysis.missingMarkers.slice(0, 3).join('; ') }); } // Calculate summary const summary = this.calculateSummary(content, issues, authenticityAnalysis); // Generate overall score const score = this.calculateOverallScore(summary, authenticityAnalysis); // Generate actionable suggestions const suggestions = this.generateSuggestions(issues, authenticityAnalysis, summary); return { score, issues, summary, suggestions, humanMarkers: authenticityAnalysis.humanMarkers, aiTells: authenticityAnalysis.aiTells }; } /** * Validate a file */ async validateFile(filePath, context) { if (!existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } const content = await readFile(filePath, 'utf-8'); return this.validate(content, context); } /** * Validate multiple files in batch */ async validateBatch(files, context) { const results = new Map(); // Process in parallel await Promise.all(files.map(async (file) => { try { const result = await this.validateFile(file, context); results.set(file, result); } catch (error) { console.error(`Failed to validate ${file}:`, error); } })); return results; } /** * Detect banned phrases in content */ detectBannedPhrases(content, context) { if (!this.ruleSet) { return []; } const issues = []; let rules = this.ruleSet.bannedPhrases; if (context) { rules = this.ruleLoader.filterByContext(rules, context); } for (const rule of rules) { const matches = this.findPatternMatches(content, rule.pattern); for (const match of matches) { issues.push({ type: 'banned_phrase', severity: rule.severity, message: rule.message, location: match.location, suggestion: rule.suggestion, context: match.context, ruleId: rule.id }); } } return issues; } /** * Detect AI writing patterns */ detectAIPatterns(content, context) { if (!this.ruleSet) { return []; } const issues = []; let rules = this.ruleSet.aiPatterns; if (context) { rules = this.ruleLoader.filterByContext(rules, context); } for (const rule of rules) { const matches = this.findPatternMatches(content, rule.pattern); for (const match of matches) { issues.push({ type: 'ai_pattern', severity: rule.severity, message: rule.message, location: match.location, suggestion: rule.suggestion, context: match.context, ruleId: rule.id }); } } return issues; } /** * Detect formulaic structures */ detectFormulicStructures(content) { if (!this.ruleSet) { return []; } const issues = []; for (const rule of this.ruleSet.structuralPatterns) { const matches = this.findPatternMatches(content, rule.pattern); for (const match of matches) { issues.push({ type: 'formulaic_structure', severity: rule.severity, message: rule.message, location: match.location, suggestion: rule.suggestion, context: match.context, ruleId: rule.id }); } } return issues; } /** * Analyze content for authenticity markers */ analyzeAuthenticity(content) { const humanMarkers = []; const aiTells = []; const missingMarkers = []; // Detect human markers const humanPatterns = [ { pattern: /\b(I think|in my experience|we found|we chose|we decided)\b/gi, name: 'personal statements' }, { pattern: /\bwhile .+, .+ (must|should|need|have to)/gi, name: 'trade-off acknowledgments' }, { pattern: /\b(reduced|increased|improved) .+ by \d+(%|ms|s|KB|MB)/gi, name: 'specific metrics' }, { pattern: /\b(p\d{2}|99\.9%|version \d+\.\d+)/gi, name: 'technical specifics' }, { pattern: /\b(bug|issue|problem|challenge|failed|broke|difficult)\b/gi, name: 'problem mentions' }, { pattern: /\b(\w+DB|Redis|Kubernetes|Docker|React|Vue|Angular)\b/g, name: 'specific technologies' } ]; for (const { pattern, name } of humanPatterns) { const matches = content.match(pattern); if (matches && matches.length > 0) { humanMarkers.push(`${name} (${matches.length})`); } } // Detect AI tells const aiTellPatterns = [ { pattern: /\b(seamlessly|cutting-edge|revolutionary|transformative|groundbreaking)\b/gi, name: 'marketing buzzwords' }, { pattern: /^(Moreover|Furthermore|Additionally|Consequently),/gm, name: 'formulaic transitions' }, { pattern: /\b(comprehensive|robust|innovative|optimal|best-in-class)\b/gi, name: 'vague intensifiers' }, { pattern: /\b(it is important to note|it is worth noting|it should be mentioned)\b/gi, name: 'throat-clearing phrases' }, { pattern: /\b(may help to|can serve to|has the potential to)\b/gi, name: 'excessive hedging' }, { pattern: /\b(\w+, \w+, and \w+)\b/g, name: 'three-item lists' } ]; for (const { pattern, name } of aiTellPatterns) { const matches = content.match(pattern); if (matches && matches.length > 0) { aiTells.push(`${name} (${matches.length})`); } } // Check for missing markers const contentLower = content.toLowerCase(); if (!contentLower.includes('i think') && !contentLower.includes('we found') && !contentLower.includes('we chose')) { missingMarkers.push('personal statements or opinions'); } if (!/\d+(%|ms|s|KB|MB|GB)/.test(content)) { missingMarkers.push('specific metrics or measurements'); } if (!/(bug|issue|problem|challenge|failed|broke|difficult)/i.test(content)) { missingMarkers.push('acknowledgment of challenges or problems'); } // Calculate authenticity score using configured weights const config = getScoringConfig(); const humanScore = humanMarkers.length * config.authenticity.humanMarkerWeight; const aiPenalty = aiTells.length * config.authenticity.aiTellPenalty; const score = Math.max(0, Math.min(100, humanScore - aiPenalty + config.authenticity.baseScore)); return { score, humanMarkers, missingMarkers, aiTells }; } /** * Validate content for specific context */ async validateForContext(content, context) { const result = await this.validate(content, context); // Apply context-specific adjustments const contextAdjustments = this.getContextAdjustments(content, context); // Modify score based on context result.score = Math.max(0, Math.min(100, result.score + contextAdjustments.scoreModifier)); // Add context-specific suggestions result.suggestions.push(...contextAdjustments.suggestions); return result; } /** * Load rules from AIWG */ loadRulesFromGuide(guidePath) { this.ruleLoader = new ValidationRuleLoader(guidePath); this.initialized = false; } /** * Update rules with custom rule set */ updateRules(rules) { if (!this.ruleSet) { this.ruleSet = this.ruleLoader.getDefaultRules(); } if (rules.bannedPhrases) { this.ruleSet.bannedPhrases = this.ruleLoader.mergeRules(this.ruleSet.bannedPhrases, rules.bannedPhrases); } if (rules.aiPatterns) { this.ruleSet.aiPatterns = this.ruleLoader.mergeRules(this.ruleSet.aiPatterns, rules.aiPatterns); } if (rules.authenticityMarkers) { this.ruleSet.authenticityMarkers = this.ruleLoader.mergeRules(this.ruleSet.authenticityMarkers, rules.authenticityMarkers); } if (rules.structuralPatterns) { this.ruleSet.structuralPatterns = this.ruleLoader.mergeRules(this.ruleSet.structuralPatterns, rules.structuralPatterns); } } /** * Generate report from validation results */ generateReport(results, format) { if (results instanceof Map) { return this.generateBatchReport(results, format); } switch (format) { case 'json': return JSON.stringify(results, null, 2); case 'html': return this.generateHtmlReport(results); case 'text': default: return this.generateTextReport(results); } } // Private helper methods findPatternMatches(content, pattern) { const matches = []; const regex = typeof pattern === 'string' ? new RegExp(pattern, 'gi') : pattern; const lines = content.split('\n'); let absolutePos = 0; for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { const line = lines[lineIdx]; const lineMatches = Array.from(line.matchAll(new RegExp(regex.source, regex.flags))); for (const match of lineMatches) { const start = absolutePos + (match.index || 0); const end = start + match[0].length; // Extract context (surrounding text) const contextStart = Math.max(0, (match.index || 0) - 30); const contextEnd = Math.min(line.length, (match.index || 0) + match[0].length + 30); const context = line.substring(contextStart, contextEnd); matches.push({ match: match[0], location: { start, end, line: lineIdx + 1, column: (match.index || 0) + 1 }, context }); } absolutePos += line.length + 1; // +1 for newline } return matches; } calculateSummary(content, issues, authenticityAnalysis) { const criticalCount = issues.filter(i => i.severity === 'critical').length; const warningCount = issues.filter(i => i.severity === 'warning').length; const infoCount = issues.filter(i => i.severity === 'info').length; // Count words and sentences const words = content.split(/\s+/).filter(w => w.length > 0); const sentences = content.split(/[.!?]+/).filter(s => s.trim().length > 0); // Calculate AI pattern score (0-100, higher = more AI-like) const config = getScoringConfig(); const aiPatternCount = issues.filter(i => i.type === 'ai_pattern' || i.type === 'banned_phrase').length; const aiPatternScore = Math.min(100, (aiPatternCount / Math.max(1, words.length / 100)) * config.issueScoring.aiPatternNormalizer); return { totalIssues: issues.length, criticalCount, warningCount, infoCount, authenticityScore: authenticityAnalysis.score, aiPatternScore, wordCount: words.length, sentenceCount: sentences.length }; } calculateOverallScore(summary, authenticityAnalysis) { const config = getScoringConfig(); // Start with authenticity score let score = authenticityAnalysis.score; // Deduct for critical issues (heavy penalty) score -= summary.criticalCount * config.issueScoring.criticalPenalty; // Deduct for warnings (moderate penalty) score -= summary.warningCount * config.issueScoring.warningPenalty; // Deduct for high AI pattern score score -= summary.aiPatternScore * config.issueScoring.aiPatternMultiplier; // Clamp to 0-100 return Math.max(0, Math.min(100, score)); } generateSuggestions(issues, authenticityAnalysis, summary) { const suggestions = []; // Critical issues first if (summary.criticalCount > 0) { suggestions.push(`Remove ${summary.criticalCount} banned phrase(s) immediately`); } // AI patterns const config = getScoringConfig(); if (summary.aiPatternScore > config.thresholds.highAIPatternWarning) { suggestions.push('High AI pattern score - review formulaic transitions and vague intensifiers'); } // Missing authenticity if (authenticityAnalysis.missingMarkers.length > 0) { suggestions.push(`Add authenticity markers: ${authenticityAnalysis.missingMarkers.slice(0, 2).join(', ')}`); } // Specific improvements const hasTransitionIssues = issues.some(i => i.message.includes('transition')); if (hasTransitionIssues) { suggestions.push('Remove formulaic transitions (Moreover, Furthermore) - just start the next sentence'); } const hasVagueIntensifiers = issues.some(i => i.message.includes('comprehensive') || i.message.includes('robust')); if (hasVagueIntensifiers) { suggestions.push('Replace vague descriptors with specific details'); } // If score is low, provide general guidance const score = this.calculateOverallScore(summary, authenticityAnalysis); if (score < config.thresholds.lowScoreWarning) { suggestions.push('Consider adding: specific metrics, problem acknowledgments, and technical details'); } return suggestions; } getContextAdjustments(content, context) { const adjustments = { scoreModifier: 0, suggestions: [] }; switch (context) { case 'academic': // Allow more formal language if (/\bcitation|reference|study|research\b/i.test(content)) { adjustments.scoreModifier += 5; } if (!/\b(cited|et al|figure \d+)\b/i.test(content)) { adjustments.suggestions.push('Academic context: Consider adding citations or references'); } break; case 'technical': // Require technical specificity if (/\b(algorithm|O\(|API|protocol|architecture)\b/i.test(content)) { adjustments.scoreModifier += 10; } if (!/\b\d+(%|ms|MB|GB)\b/.test(content)) { adjustments.suggestions.push('Technical context: Include specific metrics and performance numbers'); } break; case 'executive': // Penalize hedging heavily const hedgeCount = (content.match(/\b(may|might|could|perhaps)\b/gi) || []).length; if (hedgeCount > 3) { adjustments.scoreModifier -= 10; adjustments.suggestions.push('Executive context: Reduce hedging - make direct assertions'); } break; case 'casual': // Allow contractions and informal language if (/\b(don't|won't|can't|it's)\b/.test(content)) { adjustments.scoreModifier += 5; } break; } return adjustments; } generateTextReport(result) { const lines = []; lines.push('=== Writing Validation Report ===\n'); lines.push(`Overall Score: ${result.score}/100`); lines.push(`Authenticity Score: ${result.summary.authenticityScore}/100`); lines.push(`AI Pattern Score: ${result.summary.aiPatternScore}/100 (lower is better)\n`); lines.push(`Total Issues: ${result.summary.totalIssues}`); lines.push(` Critical: ${result.summary.criticalCount}`); lines.push(` Warnings: ${result.summary.warningCount}`); lines.push(` Info: ${result.summary.infoCount}\n`); lines.push(`Word Count: ${result.summary.wordCount}`); lines.push(`Sentence Count: ${result.summary.sentenceCount}\n`); if (result.issues.length > 0) { lines.push('=== Issues ===\n'); for (const issue of result.issues.slice(0, 20)) { lines.push(`[${issue.severity.toUpperCase()}] Line ${issue.location.line}: ${issue.message}`); if (issue.context) { lines.push(` Context: ...${issue.context}...`); } if (issue.suggestion) { lines.push(` Suggestion: ${issue.suggestion}`); } lines.push(''); } if (result.issues.length > 20) { lines.push(`... and ${result.issues.length - 20} more issues\n`); } } if (result.suggestions.length > 0) { lines.push('=== Suggestions ===\n'); result.suggestions.forEach((s, i) => { lines.push(`${i + 1}. ${s}`); }); lines.push(''); } if (result.humanMarkers.length > 0) { lines.push('=== Human Markers Found ==='); result.humanMarkers.forEach(m => lines.push(` ✓ ${m}`)); lines.push(''); } if (result.aiTells.length > 0) { lines.push('=== AI Tells Found ==='); result.aiTells.forEach(t => lines.push(` ✗ ${t}`)); lines.push(''); } return lines.join('\n'); } generateBatchReport(results, format) { if (format === 'json') { const obj = {}; results.forEach((result, filePath) => { obj[filePath] = result; }); return JSON.stringify(obj, null, 2); } const lines = []; lines.push('=== Batch Validation Report ===\n'); lines.push(`Total Files: ${results.size}\n`); let totalScore = 0; let passed = 0; let failed = 0; const config = getScoringConfig(); const passThreshold = config.thresholds.passScore; results.forEach((result) => { totalScore += result.score; if (result.score >= passThreshold) { passed++; } else { failed++; } }); const avgScore = results.size > 0 ? totalScore / results.size : 0; lines.push(`Average Score: ${avgScore.toFixed(1)}/100`); lines.push(`Passed (>=${passThreshold}): ${passed}`); lines.push(`Failed (<${passThreshold}): ${failed}\n`); lines.push('=== File Results ===\n'); results.forEach((result, filePath) => { const status = result.score >= passThreshold ? 'PASS' : 'FAIL'; lines.push(`[${status}] ${filePath}: ${result.score}/100`); if (result.summary.criticalCount > 0) { lines.push(` ${result.summary.criticalCount} critical issue(s)`); } }); return lines.join('\n'); } generateHtmlReport(result) { const config = getScoringConfig(); const statusColor = result.score >= config.thresholds.passScore ? '#22c55e' : result.score >= config.thresholds.lowScoreWarning ? '#eab308' : '#ef4444'; return `<!DOCTYPE html> <html> <head> <title>Writing Validation Report</title> <style> body { font-family: system-ui, sans-serif; max-width: 1200px; margin: 0 auto; padding: 2rem; } .score { font-size: 3rem; font-weight: bold; color: ${statusColor}; } .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 2rem 0; } .metric { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; } .metric-value { font-size: 1.5rem; font-weight: bold; } .issue { border-left: 3px solid #ef4444; padding: 0.5rem 1rem; margin: 0.5rem 0; background: #fef2f2; } .issue.warning { border-color: #eab308; background: #fefce8; } .issue.info { border-color: #3b82f6; background: #eff6ff; } .suggestion { padding: 0.5rem 1rem; margin: 0.5rem 0; background: #f0fdf4; border-left: 3px solid #22c55e; } </style> </head> <body> <h1>Writing Validation Report</h1> <div class="score">${result.score}/100</div> <div class="summary"> <div class="metric"> <div>Authenticity Score</div> <div class="metric-value">${result.summary.authenticityScore}/100</div> </div> <div class="metric"> <div>AI Pattern Score</div> <div class="metric-value">${result.summary.aiPatternScore}/100</div> </div> <div class="metric"> <div>Total Issues</div> <div class="metric-value">${result.summary.totalIssues}</div> </div> <div class="metric"> <div>Word Count</div> <div class="metric-value">${result.summary.wordCount}</div> </div> </div> <h2>Issues (${result.issues.length})</h2> ${result.issues.slice(0, 50).map(issue => ` <div class="issue ${issue.severity}"> <strong>Line ${issue.location.line}:</strong> ${issue.message} ${issue.suggestion ? `<br><em>Suggestion: ${issue.suggestion}</em>` : ''} </div> `).join('')} ${result.suggestions.length > 0 ? ` <h2>Suggestions</h2> ${result.suggestions.map(s => `<div class="suggestion">${s}</div>`).join('')} ` : ''} </body> </html>`; } } //# sourceMappingURL=validation-engine.js.map