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
JavaScript
/**
* 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