Problem: - Blog publishing has governance checks (inst_016/017/018/079) - Media responses and templates had NO checks - Inconsistent: same risks, different enforcement Solution - Unified Framework Enforcement: 1. Created ContentGovernanceChecker.service.js (shared service) 2. Enforced in media responses (blocks at API level) 3. Enforced in response templates (scans on create) 4. Scanner for existing templates Impact: ✅ Blog posts: Framework checks (existing) ✅ Media inquiry responses: Framework checks (NEW) ✅ Response templates: Framework checks (NEW) ✅ Future: Newsletter content ready for checks Files Changed: 1. src/services/ContentGovernanceChecker.service.js (NEW) - Unified content scanner for all external communications - Checks: inst_016 (stats), inst_017 (guarantees), inst_018 (claims), inst_079 (dark patterns) - Returns detailed violation reports with context 2. src/controllers/media.controller.js - Added governance check in respondToInquiry() - Blocks responses with violations (400 error) - Logs violations with media outlet context 3. src/models/ResponseTemplate.model.js - Added governance check in create() - Stores check results in template record - Prevents violating templates from being created 4. scripts/scan-response-templates.js (NEW) - Scans all existing templates for violations - Displays detailed violation reports - --fix flag to mark violating templates as inactive Testing: ✅ ContentGovernanceChecker: All pattern tests pass ✅ Clean content: Passes validation ✅ Fabricated stats: Detected (inst_016) ✅ Absolute guarantees: Detected (inst_017) ✅ Dark patterns: Detected (inst_079) ✅ Template scanner: Works (0 templates in DB) Enforcement Points: - Blog posts: publishPost() → blocked at API - Media responses: respondToInquiry() → blocked at API - Templates: create() → checked before insertion - Newsletter: ready for future implementation Architectural Consistency: If blog needs governance, ALL external communications need governance. References: - inst_016: No fabricated statistics - inst_017: No absolute guarantees - inst_018: No unverified production claims - inst_079: No dark patterns/manipulative urgency - inst_063: External communications consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
182 lines
5.1 KiB
JavaScript
182 lines
5.1 KiB
JavaScript
/**
|
|
* Content Governance Checker Service
|
|
* Unified framework checker for all external communications
|
|
*
|
|
* Used by:
|
|
* - Blog posts (before publication)
|
|
* - Media inquiry responses (before sending)
|
|
* - Response templates (on create/update)
|
|
* - Newsletter content (future)
|
|
*
|
|
* Enforces:
|
|
* - inst_016: No fabricated statistics without citation
|
|
* - inst_017: No absolute guarantees
|
|
* - inst_018: No unverified production claims
|
|
* - inst_079: No dark patterns or manipulative urgency
|
|
*/
|
|
|
|
// Pattern definitions from governance rules
|
|
const contentPatterns = {
|
|
inst_016_fabricated_stats: {
|
|
patterns: [
|
|
/\b\d+%\s+(?:faster|better|improvement|increase|decrease|reduction|more|less)\b(?!\s*\[NEEDS VERIFICATION\]|\s*\(source:|\s*\[source:)/gi,
|
|
/\b(?:faster|better|improvement)\s+of\s+\d+%\b(?!\s*\[NEEDS VERIFICATION\]|\s*\(source:|\s*\[source:)/gi,
|
|
/\b\d+x\s+(?:faster|better|more|increase)\b(?!\s*\[NEEDS VERIFICATION\]|\s*\(source:|\s*\[source:)/gi
|
|
],
|
|
severity: 'HIGH',
|
|
message: 'Statistics require citation or [NEEDS VERIFICATION] marker (inst_016)',
|
|
instruction: 'inst_016'
|
|
},
|
|
inst_017_absolute_guarantees: {
|
|
patterns: [
|
|
/\bguarantee(?:s|d|ing)?\b/gi,
|
|
/\b100%\s+(?:secure|safe|reliable|effective)\b/gi,
|
|
/\bcompletely\s+prevents?\b/gi,
|
|
/\bnever\s+fails?\b/gi,
|
|
/\balways\s+works?\b/gi,
|
|
/\beliminates?\s+all\b/gi,
|
|
/\bperfect(?:ly)?\s+(?:secure|safe|reliable)\b/gi
|
|
],
|
|
severity: 'HIGH',
|
|
message: 'Absolute guarantees prohibited - use evidence-based language (inst_017)',
|
|
instruction: 'inst_017'
|
|
},
|
|
inst_018_unverified_claims: {
|
|
patterns: [
|
|
/\bproduction-ready\b(?!\s+development\s+tool|\s+proof-of-concept)/gi,
|
|
/\bbattle-tested\b/gi,
|
|
/\benterprise-proven\b/gi,
|
|
/\bwidespread\s+adoption\b/gi,
|
|
/\bindustry-standard\b/gi
|
|
],
|
|
severity: 'MEDIUM',
|
|
message: 'Production claims require evidence (this is proof-of-concept) (inst_018)',
|
|
instruction: 'inst_018'
|
|
},
|
|
inst_079_dark_patterns: {
|
|
patterns: [
|
|
/\bclick\s+here\s+now\b/gi,
|
|
/\blimited\s+time\s+offer\b/gi,
|
|
/\bonly\s+\d+\s+spots?\s+left\b/gi,
|
|
/\bact\s+fast\b/gi,
|
|
/\bhurry\b.*\b(?:before|while)\b/gi,
|
|
/\bdon't\s+miss\s+out\b/gi
|
|
],
|
|
severity: 'MEDIUM',
|
|
message: 'Possible manipulative urgency/dark pattern detected (inst_079)',
|
|
instruction: 'inst_079'
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Scan content for governance violations
|
|
* @param {string} text - Content to scan
|
|
* @param {object} options - Scanning options
|
|
* @returns {Promise<object>} - Scan results with violations array
|
|
*/
|
|
async function scanContent(text, options = {}) {
|
|
const {
|
|
type = 'general', // blog, media_response, template, newsletter
|
|
context = {}
|
|
} = options;
|
|
|
|
if (!text || typeof text !== 'string') {
|
|
return {
|
|
success: true,
|
|
violations: [],
|
|
scannedAt: new Date(),
|
|
type,
|
|
context
|
|
};
|
|
}
|
|
|
|
const violations = [];
|
|
|
|
// Scan for each pattern
|
|
for (const [ruleId, config] of Object.entries(contentPatterns)) {
|
|
for (const pattern of config.patterns) {
|
|
const matches = text.match(pattern);
|
|
if (matches) {
|
|
// Get unique matches only
|
|
const uniqueMatches = [...new Set(matches)];
|
|
|
|
uniqueMatches.forEach(match => {
|
|
violations.push({
|
|
rule: ruleId,
|
|
instruction: config.instruction,
|
|
severity: config.severity,
|
|
match,
|
|
message: config.message,
|
|
position: text.indexOf(match),
|
|
context: getMatchContext(text, text.indexOf(match))
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: violations.length === 0,
|
|
violations,
|
|
scannedAt: new Date(),
|
|
type,
|
|
context,
|
|
summary: {
|
|
total: violations.length,
|
|
high: violations.filter(v => v.severity === 'HIGH').length,
|
|
medium: violations.filter(v => v.severity === 'MEDIUM').length,
|
|
byRule: violations.reduce((acc, v) => {
|
|
acc[v.instruction] = (acc[v.instruction] || 0) + 1;
|
|
return acc;
|
|
}, {})
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get context around a match (50 chars before and after)
|
|
*/
|
|
function getMatchContext(text, position, contextLength = 50) {
|
|
const start = Math.max(0, position - contextLength);
|
|
const end = Math.min(text.length, position + contextLength);
|
|
|
|
let context = text.substring(start, end);
|
|
|
|
// Add ellipsis if truncated
|
|
if (start > 0) context = '...' + context;
|
|
if (end < text.length) context = context + '...';
|
|
|
|
return context;
|
|
}
|
|
|
|
/**
|
|
* Format violations for display
|
|
*/
|
|
function formatViolations(violations) {
|
|
if (!violations || violations.length === 0) {
|
|
return 'No violations found';
|
|
}
|
|
|
|
return violations.map((v, idx) =>
|
|
`${idx + 1}. [${v.severity}] ${v.rule}\n` +
|
|
` Match: "${v.match}"\n` +
|
|
` ${v.message}\n` +
|
|
` Context: ${v.context}`
|
|
).join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Check if content is safe for publication
|
|
* Convenience method that returns boolean
|
|
*/
|
|
async function isSafeForPublication(text, options = {}) {
|
|
const result = await scanContent(text, options);
|
|
return result.success;
|
|
}
|
|
|
|
module.exports = {
|
|
scanContent,
|
|
formatViolations,
|
|
isSafeForPublication,
|
|
contentPatterns
|
|
};
|