- Create Economist SubmissionTracking package correctly: * mainArticle = full blog post content * coverLetter = 216-word SIR— letter * Links to blog post via blogPostId - Archive 'Letter to The Economist' from blog posts (it's the cover letter) - Fix date display on article cards (use published_at) - Target publication already displaying via blue badge Database changes: - Make blogPostId optional in SubmissionTracking model - Economist package ID: 68fa85ae49d4900e7f2ecd83 - Le Monde package ID: 68fa2abd2e6acd5691932150 Next: Enhanced modal with tabs, validation, export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
334 lines
10 KiB
JavaScript
334 lines
10 KiB
JavaScript
/**
|
|
* Content Analyzer Service
|
|
* Analyzes existing articles for tone, audience fit, themes, and publication suitability
|
|
*/
|
|
|
|
const ClaudeAPIService = require('./ClaudeAPI.service');
|
|
const publicationConfig = require('../config/publication-targets.config');
|
|
const logger = require('../utils/logger.util');
|
|
|
|
class ContentAnalyzerService {
|
|
/**
|
|
* Analyze article content for quality and publication fit
|
|
* @param {Object} article - Article to analyze
|
|
* @param {string} article.title - Article title
|
|
* @param {string} article.content - Article content
|
|
* @param {number} article.wordCount - Word count
|
|
* @param {string} article.targetPublication - Publication ID (optional)
|
|
* @returns {Promise<Object>} Analysis results
|
|
*/
|
|
async analyzeArticle(article) {
|
|
logger.info(`Analyzing article: ${article.title}`);
|
|
|
|
try {
|
|
const claudeAPI = ClaudeAPIService;
|
|
|
|
// Build system prompt for analysis
|
|
const systemPrompt = `You are an expert content analyst evaluating articles about AI governance and the Tractatus framework.
|
|
|
|
Your task is to analyze the provided article and return a comprehensive assessment in JSON format.
|
|
|
|
Analyze for:
|
|
1. **Tone**: strategic, conversational, academic, investigative, persuasive
|
|
2. **Primary Audience**: leader, research, general, technical, policy
|
|
3. **Key Themes**: Extract 3-5 main themes discussed
|
|
4. **Strengths**: What works well (2-3 points)
|
|
5. **Weaknesses**: What could be improved (2-3 points)
|
|
6. **Publication Suitability**: If target publication specified, assess fit (1-10 score)
|
|
|
|
Return JSON:
|
|
{
|
|
"tone": {
|
|
"primary": "strategic|conversational|academic|investigative|persuasive",
|
|
"secondary": "strategic|conversational|academic|investigative|persuasive",
|
|
"confidence": 0.0-1.0
|
|
},
|
|
"audience": {
|
|
"primary": "leader|research|general|technical|policy",
|
|
"secondary": "leader|research|general|technical|policy",
|
|
"readingLevel": "undergraduate|postgraduate|professional|general",
|
|
"confidence": 0.0-1.0
|
|
},
|
|
"themes": [
|
|
{
|
|
"theme": "Theme name",
|
|
"prominence": 0.0-1.0,
|
|
"description": "Brief description"
|
|
}
|
|
],
|
|
"strengths": [
|
|
"Strength description"
|
|
],
|
|
"weaknesses": [
|
|
"Weakness description with specific suggestion"
|
|
],
|
|
"publicationFit": {
|
|
"score": 1-10,
|
|
"reasoning": "Why this score",
|
|
"recommendations": ["Specific changes to improve fit"]
|
|
},
|
|
"tractatus": {
|
|
"frameworkAlignment": 0.0-1.0,
|
|
"quadrant": "STRATEGIC|OPERATIONAL|TACTICAL|MAINTENANCE",
|
|
"valuesSensitive": boolean,
|
|
"concerns": ["Any concerns about framework representation"]
|
|
}
|
|
}`;
|
|
|
|
// Build user prompt with article details
|
|
let userPrompt = `Analyze this article:
|
|
|
|
**Title**: ${article.title}
|
|
**Word Count**: ${article.wordCount}
|
|
${article.targetPublication ? `**Target Publication**: ${this._getPublicationName(article.targetPublication)}\n` : ''}
|
|
|
|
**Content**:
|
|
${article.content}
|
|
|
|
Provide comprehensive analysis in JSON format.`;
|
|
|
|
// If target publication specified, include readership profile
|
|
if (article.targetPublication) {
|
|
const publication = publicationConfig.getPublicationById(article.targetPublication);
|
|
if (publication && publication.readership) {
|
|
userPrompt += `\n\n**Target Publication Profile**:
|
|
- Name: ${publication.name}
|
|
- Type: ${publication.type}
|
|
- Primary Audience: ${publication.readership.primary}
|
|
- Reader Interests: ${publication.readership.demographics.interests.join(', ')}
|
|
- Editorial Tone: ${publication.editorial.tone.join(', ')}
|
|
- Preferred Themes: ${publication.readership.contentPreferences.topicThemes.join(', ')}
|
|
|
|
Assess how well this article fits this publication's readership.`;
|
|
}
|
|
}
|
|
|
|
const messages = [
|
|
{
|
|
role: 'user',
|
|
content: userPrompt
|
|
}
|
|
];
|
|
|
|
const response = await claudeAPI.sendMessage(messages, {
|
|
system: systemPrompt,
|
|
max_tokens: 2048,
|
|
temperature: 0.3 // Lower temperature for consistent analysis
|
|
});
|
|
|
|
const analysis = claudeAPI.extractJSON(response);
|
|
|
|
logger.info(`Analysis complete for: ${article.title}`);
|
|
return analysis;
|
|
|
|
} catch (error) {
|
|
logger.error('Content analysis error:', error);
|
|
throw new Error(`Failed to analyze article: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Batch analyze multiple articles
|
|
* @param {Array<Object>} articles - Articles to analyze
|
|
* @returns {Promise<Array<Object>>} Analysis results
|
|
*/
|
|
async batchAnalyze(articles) {
|
|
logger.info(`Batch analyzing ${articles.length} articles`);
|
|
|
|
const results = [];
|
|
for (const article of articles) {
|
|
try {
|
|
const analysis = await this.analyzeArticle(article);
|
|
results.push({
|
|
title: article.title,
|
|
success: true,
|
|
analysis
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Failed to analyze ${article.title}:`, error);
|
|
results.push({
|
|
title: article.title,
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Compare article against publication requirements
|
|
* @param {Object} article - Article with content and metadata
|
|
* @param {string} publicationId - Publication to compare against
|
|
* @returns {Promise<Object>} Comparison results
|
|
*/
|
|
async compareToPublication(article, publicationId) {
|
|
const publication = publicationConfig.getPublicationById(publicationId);
|
|
|
|
if (!publication) {
|
|
throw new Error(`Publication not found: ${publicationId}`);
|
|
}
|
|
|
|
logger.info(`Comparing "${article.title}" to ${publication.name}`);
|
|
|
|
const analysis = await this.analyzeArticle({
|
|
...article,
|
|
targetPublication: publicationId
|
|
});
|
|
|
|
// Build comparison report
|
|
const comparison = {
|
|
publication: {
|
|
id: publication.id,
|
|
name: publication.name,
|
|
type: publication.type
|
|
},
|
|
article: {
|
|
title: article.title,
|
|
wordCount: article.wordCount
|
|
},
|
|
fit: {
|
|
overall: analysis.publicationFit?.score || 0,
|
|
reasoning: analysis.publicationFit?.reasoning || 'No fit assessment available'
|
|
},
|
|
requirements: {
|
|
wordCount: {
|
|
required: publication.requirements?.wordCount || 'Not specified',
|
|
actual: article.wordCount,
|
|
meets: this._checkWordCount(article.wordCount, publication.requirements?.wordCount)
|
|
},
|
|
tone: {
|
|
preferred: publication.editorial?.tone || [],
|
|
detected: analysis.tone.primary,
|
|
matches: publication.editorial?.tone?.includes(analysis.tone.primary)
|
|
},
|
|
audience: {
|
|
target: publication.readership?.primary || 'Not specified',
|
|
detected: analysis.audience.primary,
|
|
matches: publication.readership?.primary === analysis.audience.primary
|
|
}
|
|
},
|
|
recommendations: analysis.publicationFit?.recommendations || [],
|
|
analysis
|
|
};
|
|
|
|
return comparison;
|
|
}
|
|
|
|
/**
|
|
* Generate improvement suggestions based on analysis
|
|
* @param {Object} analysis - Analysis results from analyzeArticle
|
|
* @param {string} targetPublicationId - Optional target publication
|
|
* @returns {Array<Object>} Prioritized improvement suggestions
|
|
*/
|
|
generateImprovements(analysis, targetPublicationId = null) {
|
|
const improvements = [];
|
|
|
|
// Check tone alignment
|
|
if (targetPublicationId) {
|
|
const publication = publicationConfig.getPublicationById(targetPublicationId);
|
|
if (publication && publication.editorial) {
|
|
const toneMatches = publication.editorial.tone.includes(analysis.tone.primary);
|
|
if (!toneMatches) {
|
|
improvements.push({
|
|
priority: 'high',
|
|
category: 'tone',
|
|
issue: `Tone mismatch: Article is ${analysis.tone.primary}, but ${publication.name} prefers ${publication.editorial.tone.join(' or ')}`,
|
|
suggestion: `Revise to adopt a more ${publication.editorial.tone[0]} tone throughout`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check audience alignment
|
|
if (analysis.audience.confidence < 0.7) {
|
|
improvements.push({
|
|
priority: 'medium',
|
|
category: 'audience',
|
|
issue: 'Unclear target audience',
|
|
suggestion: 'Clarify who this article is for and adjust language/examples accordingly'
|
|
});
|
|
}
|
|
|
|
// Add weaknesses as improvements
|
|
if (analysis.weaknesses && analysis.weaknesses.length > 0) {
|
|
analysis.weaknesses.forEach((weakness, index) => {
|
|
improvements.push({
|
|
priority: index === 0 ? 'high' : 'medium',
|
|
category: 'content',
|
|
issue: weakness,
|
|
suggestion: 'Address this weakness in revision'
|
|
});
|
|
});
|
|
}
|
|
|
|
// Check Tractatus alignment
|
|
if (analysis.tractatus && analysis.tractatus.frameworkAlignment < 0.7) {
|
|
improvements.push({
|
|
priority: 'high',
|
|
category: 'framework',
|
|
issue: 'Weak Tractatus framework alignment',
|
|
suggestion: 'Strengthen connection to Tractatus principles and examples'
|
|
});
|
|
}
|
|
|
|
// Publication-specific recommendations
|
|
if (analysis.publicationFit && analysis.publicationFit.recommendations) {
|
|
analysis.publicationFit.recommendations.forEach(rec => {
|
|
improvements.push({
|
|
priority: 'high',
|
|
category: 'publication-fit',
|
|
issue: 'Publication fit improvement',
|
|
suggestion: rec
|
|
});
|
|
});
|
|
}
|
|
|
|
// Sort by priority
|
|
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
|
improvements.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
|
|
|
return improvements;
|
|
}
|
|
|
|
/**
|
|
* Helper: Get publication name by ID
|
|
*/
|
|
_getPublicationName(publicationId) {
|
|
const publication = publicationConfig.getPublicationById(publicationId);
|
|
return publication ? publication.name : publicationId;
|
|
}
|
|
|
|
/**
|
|
* Helper: Check if word count meets requirements
|
|
*/
|
|
_checkWordCount(actual, required) {
|
|
if (!required) return true;
|
|
|
|
if (typeof required === 'string') {
|
|
// Parse range like "750-1500"
|
|
const match = required.match(/(\d+)-(\d+)/);
|
|
if (match) {
|
|
const min = parseInt(match[1]);
|
|
const max = parseInt(match[2]);
|
|
return actual >= min && actual <= max;
|
|
}
|
|
}
|
|
|
|
return true; // Default to true if can't parse
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let instance = null;
|
|
|
|
module.exports = {
|
|
getInstance: () => {
|
|
if (!instance) {
|
|
instance = new ContentAnalyzerService();
|
|
}
|
|
return instance;
|
|
},
|
|
ContentAnalyzerService
|
|
};
|