Added plan.md with complete architecture for format-agnostic content generation: - Support for Markdown, HTML, Plain Text, JSON formats - New FormatExporter module with neutral data structure - Integration strategy with existing ContentAssembly and ArticleStorage - Bonus features: SEO metadata generation, readability scoring, WordPress Gutenberg format - Implementation roadmap with 4 phases (6h total estimated) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
532 lines
18 KiB
JavaScript
532 lines
18 KiB
JavaScript
// ========================================
|
|
// STYLE LAYER - COUCHE STYLE MODULAIRE
|
|
// Responsabilité: Adaptation personnalité modulaire réutilisable
|
|
// LLM: Mistral (excellence style et personnalité)
|
|
// ========================================
|
|
|
|
const { callLLM } = require('../LLMManager');
|
|
const { logSh } = require('../ErrorReporting');
|
|
const { tracer } = require('../trace');
|
|
const { chunkArray, sleep } = require('./SelectiveUtils');
|
|
|
|
/**
|
|
* COUCHE STYLE MODULAIRE
|
|
*/
|
|
class StyleLayer {
|
|
constructor() {
|
|
this.name = 'StyleEnhancement';
|
|
this.defaultLLM = 'mistral';
|
|
this.priority = 3; // Priorité basse - appliqué en dernier
|
|
}
|
|
|
|
/**
|
|
* MAIN METHOD - Appliquer amélioration style
|
|
*/
|
|
async apply(content, config = {}) {
|
|
return await tracer.run('StyleLayer.apply()', async () => {
|
|
const {
|
|
llmProvider = this.defaultLLM,
|
|
intensity = 1.0, // 0.0-2.0 intensité d'amélioration
|
|
analysisMode = true, // Analyser avant d'appliquer
|
|
csvData = null,
|
|
preserveStructure = true,
|
|
targetStyle = null // Style spécifique à appliquer
|
|
} = config;
|
|
|
|
await tracer.annotate({
|
|
styleLayer: true,
|
|
llmProvider,
|
|
intensity,
|
|
elementsCount: Object.keys(content).length,
|
|
personality: csvData?.personality?.nom
|
|
});
|
|
|
|
const startTime = Date.now();
|
|
logSh(`🎨 STYLE LAYER: Amélioration personnalité (${llmProvider})`, 'INFO');
|
|
logSh(` 📊 ${Object.keys(content).length} éléments | Style: ${csvData?.personality?.nom || 'standard'}`, 'INFO');
|
|
|
|
try {
|
|
let enhancedContent = {};
|
|
let elementsProcessed = 0;
|
|
let elementsEnhanced = 0;
|
|
|
|
// Vérifier présence personnalité
|
|
if (!csvData?.personality && !targetStyle) {
|
|
logSh(`⚠️ STYLE LAYER: Pas de personnalité définie, style générique appliqué`, 'WARNING');
|
|
}
|
|
|
|
if (analysisMode) {
|
|
// 1. Analyser éléments nécessitant amélioration style
|
|
const analysis = await this.analyzeStyleNeeds(content, csvData, targetStyle);
|
|
|
|
logSh(` 📋 Analyse: ${analysis.candidates.length}/${Object.keys(content).length} éléments candidats`, 'DEBUG');
|
|
|
|
if (analysis.candidates.length === 0) {
|
|
logSh(`✅ STYLE LAYER: Style déjà cohérent`, 'INFO');
|
|
return {
|
|
content,
|
|
stats: {
|
|
processed: Object.keys(content).length,
|
|
enhanced: 0,
|
|
analysisSkipped: true,
|
|
duration: Date.now() - startTime
|
|
}
|
|
};
|
|
}
|
|
|
|
// 2. Améliorer les éléments sélectionnés
|
|
const improvedResults = await this.enhanceStyleElements(
|
|
analysis.candidates,
|
|
csvData,
|
|
{ llmProvider, intensity, preserveStructure, targetStyle }
|
|
);
|
|
|
|
// 3. Merger avec contenu original
|
|
enhancedContent = { ...content };
|
|
Object.keys(improvedResults).forEach(tag => {
|
|
if (improvedResults[tag] !== content[tag]) {
|
|
enhancedContent[tag] = improvedResults[tag];
|
|
elementsEnhanced++;
|
|
}
|
|
});
|
|
|
|
elementsProcessed = analysis.candidates.length;
|
|
|
|
} else {
|
|
// Mode direct : améliorer tous les éléments textuels
|
|
const textualElements = Object.entries(content)
|
|
.filter(([tag, text]) => text.length > 50 && !tag.includes('FAQ_Question'))
|
|
.map(([tag, text]) => ({ tag, content: text, styleIssues: ['adaptation_générale'] }));
|
|
|
|
if (textualElements.length === 0) {
|
|
return { content, stats: { processed: 0, enhanced: 0, duration: Date.now() - startTime } };
|
|
}
|
|
|
|
const improvedResults = await this.enhanceStyleElements(
|
|
textualElements,
|
|
csvData,
|
|
{ llmProvider, intensity, preserveStructure, targetStyle }
|
|
);
|
|
|
|
enhancedContent = { ...content };
|
|
Object.keys(improvedResults).forEach(tag => {
|
|
if (improvedResults[tag] !== content[tag]) {
|
|
enhancedContent[tag] = improvedResults[tag];
|
|
elementsEnhanced++;
|
|
}
|
|
});
|
|
|
|
elementsProcessed = textualElements.length;
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
const stats = {
|
|
processed: elementsProcessed,
|
|
enhanced: elementsEnhanced,
|
|
total: Object.keys(content).length,
|
|
enhancementRate: (elementsEnhanced / Math.max(elementsProcessed, 1)) * 100,
|
|
duration,
|
|
llmProvider,
|
|
intensity,
|
|
personalityApplied: csvData?.personality?.nom || targetStyle || 'générique'
|
|
};
|
|
|
|
logSh(`✅ STYLE LAYER TERMINÉE: ${elementsEnhanced}/${elementsProcessed} stylisés (${duration}ms)`, 'INFO');
|
|
|
|
await tracer.event('Style layer appliquée', stats);
|
|
|
|
return { content: enhancedContent, stats };
|
|
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
logSh(`❌ STYLE LAYER ÉCHOUÉE après ${duration}ms: ${error.message}`, 'ERROR');
|
|
|
|
// Fallback gracieux : retourner contenu original
|
|
logSh(`🔄 Fallback: style original préservé`, 'WARNING');
|
|
return {
|
|
content,
|
|
stats: { fallback: true, duration },
|
|
error: error.message
|
|
};
|
|
}
|
|
}, { content: Object.keys(content), config });
|
|
}
|
|
|
|
/**
|
|
* ANALYSER BESOINS STYLE
|
|
*/
|
|
async analyzeStyleNeeds(content, csvData, targetStyle = null) {
|
|
logSh(`🎨 Analyse besoins style`, 'DEBUG');
|
|
|
|
const analysis = {
|
|
candidates: [],
|
|
globalScore: 0,
|
|
styleIssues: {
|
|
genericLanguage: 0,
|
|
personalityMismatch: 0,
|
|
inconsistentTone: 0,
|
|
missingVocabulary: 0
|
|
}
|
|
};
|
|
|
|
const personality = csvData?.personality;
|
|
const expectedStyle = targetStyle || personality;
|
|
|
|
// Analyser chaque élément
|
|
Object.entries(content).forEach(([tag, text]) => {
|
|
const elementAnalysis = this.analyzeStyleElement(text, expectedStyle, csvData);
|
|
|
|
if (elementAnalysis.needsImprovement) {
|
|
analysis.candidates.push({
|
|
tag,
|
|
content: text,
|
|
styleIssues: elementAnalysis.issues,
|
|
score: elementAnalysis.score,
|
|
improvements: elementAnalysis.improvements
|
|
});
|
|
|
|
analysis.globalScore += elementAnalysis.score;
|
|
|
|
// Compter types d'issues
|
|
elementAnalysis.issues.forEach(issue => {
|
|
if (analysis.styleIssues.hasOwnProperty(issue)) {
|
|
analysis.styleIssues[issue]++;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
analysis.globalScore = analysis.globalScore / Math.max(Object.keys(content).length, 1);
|
|
|
|
logSh(` 📊 Score global style: ${analysis.globalScore.toFixed(2)}`, 'DEBUG');
|
|
logSh(` 🎭 Issues style: ${JSON.stringify(analysis.styleIssues)}`, 'DEBUG');
|
|
|
|
return analysis;
|
|
}
|
|
|
|
/**
|
|
* AMÉLIORER ÉLÉMENTS STYLE SÉLECTIONNÉS
|
|
*/
|
|
async enhanceStyleElements(candidates, csvData, config) {
|
|
logSh(`🎨 Amélioration ${candidates.length} éléments style`, 'DEBUG');
|
|
|
|
const results = {};
|
|
const chunks = chunkArray(candidates, 5); // Chunks optimisés pour Mistral
|
|
|
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
const chunk = chunks[chunkIndex];
|
|
|
|
try {
|
|
logSh(` 📦 Chunk style ${chunkIndex + 1}/${chunks.length}: ${chunk.length} éléments`, 'DEBUG');
|
|
|
|
const enhancementPrompt = this.createStyleEnhancementPrompt(chunk, csvData, config);
|
|
|
|
const response = await callLLM(config.llmProvider, enhancementPrompt, {
|
|
temperature: 0.8, // Créativité élevée pour style
|
|
maxTokens: 3000
|
|
}, csvData?.personality);
|
|
|
|
const chunkResults = this.parseStyleResponse(response, chunk);
|
|
Object.assign(results, chunkResults);
|
|
|
|
logSh(` ✅ Chunk style ${chunkIndex + 1}: ${Object.keys(chunkResults).length} stylisés`, 'DEBUG');
|
|
|
|
// Délai entre chunks
|
|
if (chunkIndex < chunks.length - 1) {
|
|
await sleep(1800);
|
|
}
|
|
|
|
} catch (error) {
|
|
logSh(` ❌ Chunk style ${chunkIndex + 1} échoué: ${error.message}`, 'ERROR');
|
|
|
|
// Fallback: conserver contenu original
|
|
chunk.forEach(element => {
|
|
results[element.tag] = element.content;
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ============= HELPER METHODS =============
|
|
|
|
/**
|
|
* Analyser élément style individuel
|
|
*/
|
|
analyzeStyleElement(text, expectedStyle, csvData) {
|
|
let score = 0;
|
|
const issues = [];
|
|
const improvements = [];
|
|
|
|
// Si pas de style attendu, score faible
|
|
if (!expectedStyle) {
|
|
return { needsImprovement: false, score: 0.1, issues: ['pas_style_défini'], improvements: [] };
|
|
}
|
|
|
|
// 1. Analyser langage générique
|
|
const genericScore = this.analyzeGenericLanguage(text);
|
|
if (genericScore > 0.4) {
|
|
score += 0.3;
|
|
issues.push('genericLanguage');
|
|
improvements.push('personnaliser_vocabulaire');
|
|
}
|
|
|
|
// 2. Analyser adéquation personnalité
|
|
if (expectedStyle.vocabulairePref) {
|
|
const personalityScore = this.analyzePersonalityAlignment(text, expectedStyle);
|
|
if (personalityScore < 0.3) {
|
|
score += 0.4;
|
|
issues.push('personalityMismatch');
|
|
improvements.push('appliquer_style_personnalité');
|
|
}
|
|
}
|
|
|
|
// 3. Analyser cohérence de ton
|
|
const toneScore = this.analyzeToneConsistency(text, expectedStyle);
|
|
if (toneScore > 0.5) {
|
|
score += 0.2;
|
|
issues.push('inconsistentTone');
|
|
improvements.push('unifier_ton');
|
|
}
|
|
|
|
// 4. Analyser vocabulaire spécialisé
|
|
if (expectedStyle.niveauTechnique) {
|
|
const vocabScore = this.analyzeVocabularyLevel(text, expectedStyle);
|
|
if (vocabScore > 0.4) {
|
|
score += 0.1;
|
|
issues.push('missingVocabulary');
|
|
improvements.push('ajuster_niveau_vocabulaire');
|
|
}
|
|
}
|
|
|
|
return {
|
|
needsImprovement: score > 0.3,
|
|
score,
|
|
issues,
|
|
improvements
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyser langage générique
|
|
*/
|
|
analyzeGenericLanguage(text) {
|
|
const genericPhrases = [
|
|
'nos solutions', 'notre expertise', 'notre savoir-faire',
|
|
'nous vous proposons', 'nous mettons à votre disposition',
|
|
'qualité optimale', 'service de qualité', 'expertise reconnue'
|
|
];
|
|
|
|
let genericCount = 0;
|
|
genericPhrases.forEach(phrase => {
|
|
if (text.toLowerCase().includes(phrase)) genericCount++;
|
|
});
|
|
|
|
const wordCount = text.split(/\s+/).length;
|
|
return Math.min(1, (genericCount / Math.max(wordCount / 50, 1)));
|
|
}
|
|
|
|
/**
|
|
* Analyser alignement personnalité
|
|
*/
|
|
analyzePersonalityAlignment(text, personality) {
|
|
if (!personality.vocabulairePref) return 1;
|
|
|
|
const preferredWords = personality.vocabulairePref.toLowerCase().split(',');
|
|
const contentLower = text.toLowerCase();
|
|
|
|
let alignmentScore = 0;
|
|
preferredWords.forEach(word => {
|
|
if (word.trim() && contentLower.includes(word.trim())) {
|
|
alignmentScore++;
|
|
}
|
|
});
|
|
|
|
return Math.min(1, alignmentScore / Math.max(preferredWords.length, 1));
|
|
}
|
|
|
|
/**
|
|
* Analyser cohérence de ton
|
|
*/
|
|
analyzeToneConsistency(text, expectedStyle) {
|
|
const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 10);
|
|
if (sentences.length < 2) return 0;
|
|
|
|
const tones = sentences.map(sentence => this.detectSentenceTone(sentence));
|
|
const expectedTone = this.getExpectedTone(expectedStyle);
|
|
|
|
let inconsistencies = 0;
|
|
tones.forEach(tone => {
|
|
if (tone !== expectedTone && tone !== 'neutral') {
|
|
inconsistencies++;
|
|
}
|
|
});
|
|
|
|
return inconsistencies / tones.length;
|
|
}
|
|
|
|
/**
|
|
* Analyser niveau vocabulaire
|
|
*/
|
|
analyzeVocabularyLevel(text, expectedStyle) {
|
|
const technicalWords = text.match(/\b\w{8,}\b/g) || [];
|
|
const expectedLevel = expectedStyle.niveauTechnique || 'standard';
|
|
|
|
const techRatio = technicalWords.length / text.split(/\s+/).length;
|
|
|
|
switch (expectedLevel) {
|
|
case 'accessible':
|
|
return techRatio > 0.1 ? techRatio : 0; // Trop technique
|
|
case 'expert':
|
|
return techRatio < 0.05 ? 1 - techRatio : 0; // Pas assez technique
|
|
default:
|
|
return techRatio > 0.15 || techRatio < 0.02 ? Math.abs(0.08 - techRatio) : 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Détecter ton de phrase
|
|
*/
|
|
detectSentenceTone(sentence) {
|
|
const lowerSentence = sentence.toLowerCase();
|
|
|
|
if (/\b(excellent|remarquable|exceptionnel|parfait)\b/.test(lowerSentence)) return 'enthusiastic';
|
|
if (/\b(il convient|nous recommandons|il est conseillé)\b/.test(lowerSentence)) return 'formal';
|
|
if (/\b(sympa|cool|nickel|top)\b/.test(lowerSentence)) return 'casual';
|
|
if (/\b(technique|précision|spécification)\b/.test(lowerSentence)) return 'technical';
|
|
|
|
return 'neutral';
|
|
}
|
|
|
|
/**
|
|
* Obtenir ton attendu selon personnalité
|
|
*/
|
|
getExpectedTone(personality) {
|
|
if (!personality || !personality.style) return 'neutral';
|
|
|
|
const style = personality.style.toLowerCase();
|
|
|
|
if (style.includes('technique') || style.includes('expert')) return 'technical';
|
|
if (style.includes('commercial') || style.includes('vente')) return 'enthusiastic';
|
|
if (style.includes('décontracté') || style.includes('moderne')) return 'casual';
|
|
if (style.includes('professionnel') || style.includes('formel')) return 'formal';
|
|
|
|
return 'neutral';
|
|
}
|
|
|
|
/**
|
|
* Créer prompt amélioration style
|
|
*/
|
|
createStyleEnhancementPrompt(chunk, csvData, config) {
|
|
const personality = csvData?.personality || config.targetStyle;
|
|
|
|
let prompt = `MISSION: Adapte UNIQUEMENT le style et la personnalité de ces contenus.
|
|
|
|
CONTEXTE: Article SEO ${csvData?.mc0 || 'signalétique personnalisée'}
|
|
${personality ? `PERSONNALITÉ CIBLE: ${personality.nom} (${personality.style})` : 'STYLE: Professionnel standard'}
|
|
${personality?.description ? `DESCRIPTION: ${personality.description}` : ''}
|
|
INTENSITÉ: ${config.intensity} (0.5=léger, 1.0=standard, 1.5=intensif)
|
|
|
|
CONTENUS À STYLISER:
|
|
|
|
${chunk.map((item, i) => `[${i + 1}] TAG: ${item.tag}
|
|
PROBLÈMES: ${item.styleIssues.join(', ')}
|
|
CONTENU: "${item.content}"`).join('\n\n')}
|
|
|
|
PROFIL PERSONNALITÉ ${personality?.nom || 'Standard'}:
|
|
${personality ? `- Style: ${personality.style}
|
|
- Niveau: ${personality.niveauTechnique || 'standard'}
|
|
- Vocabulaire préféré: ${personality.vocabulairePref || 'professionnel'}
|
|
- Connecteurs: ${personality.connecteursPref || 'variés'}
|
|
${personality.specificites ? `- Spécificités: ${personality.specificites}` : ''}` : '- Style professionnel web standard'}
|
|
|
|
OBJECTIFS STYLE:
|
|
- Appliquer personnalité ${personality?.nom || 'standard'} de façon naturelle
|
|
- Utiliser vocabulaire et expressions caractéristiques
|
|
- Maintenir cohérence de ton sur tout le contenu
|
|
- Adapter niveau technique selon profil (${personality?.niveauTechnique || 'standard'})
|
|
- Style web ${personality?.style || 'professionnel'} mais authentique
|
|
|
|
CONSIGNES STRICTES:
|
|
- NE CHANGE PAS le fond du message ni les informations factuelles
|
|
- GARDE même structure et longueur approximative (±15%)
|
|
- Applique SEULEMENT style et personnalité sur la forme
|
|
- RESPECTE impérativement le niveau ${personality?.niveauTechnique || 'standard'}
|
|
- ÉVITE exagération qui rendrait artificiel
|
|
|
|
TECHNIQUES STYLE:
|
|
${personality?.vocabulairePref ? `- Intégrer naturellement: ${personality.vocabulairePref}` : '- Vocabulaire professionnel équilibré'}
|
|
- Adapter registre de langue selon ${personality?.style || 'professionnel'}
|
|
- Expressions et tournures caractéristiques personnalité
|
|
- Ton cohérent: ${this.getExpectedTone(personality)} mais naturel
|
|
- Connecteurs préférés: ${personality?.connecteursPref || 'variés et naturels'}
|
|
|
|
FORMAT RÉPONSE:
|
|
[1] Contenu avec style personnalisé
|
|
[2] Contenu avec style personnalisé
|
|
etc...
|
|
|
|
IMPORTANT: Réponse DIRECTE par les contenus stylisés, pas d'explication.`;
|
|
|
|
return prompt;
|
|
}
|
|
|
|
/**
|
|
* Parser réponse style
|
|
*/
|
|
parseStyleResponse(response, chunk) {
|
|
const results = {};
|
|
const regex = /\[(\d+)\]\s*([^[]*?)(?=\n\[\d+\]|$)/gs;
|
|
let match;
|
|
let index = 0;
|
|
|
|
while ((match = regex.exec(response)) && index < chunk.length) {
|
|
let styledContent = match[2].trim();
|
|
const element = chunk[index];
|
|
|
|
// Nettoyer contenu stylisé
|
|
styledContent = this.cleanStyleContent(styledContent);
|
|
|
|
if (styledContent && styledContent.length > 10) {
|
|
results[element.tag] = styledContent;
|
|
logSh(`✅ Stylisé [${element.tag}]: "${styledContent.substring(0, 60)}..."`, 'DEBUG');
|
|
} else {
|
|
results[element.tag] = element.content; // Fallback
|
|
logSh(`⚠️ Fallback style [${element.tag}]: amélioration invalide`, 'WARNING');
|
|
}
|
|
|
|
index++;
|
|
}
|
|
|
|
// Compléter les manquants
|
|
while (index < chunk.length) {
|
|
const element = chunk[index];
|
|
results[element.tag] = element.content;
|
|
index++;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Nettoyer contenu style généré
|
|
*/
|
|
cleanStyleContent(content) {
|
|
if (!content) return content;
|
|
|
|
// Supprimer préfixes indésirables
|
|
content = content.replace(/^(voici\s+)?le\s+contenu\s+(stylisé|adapté|personnalisé)\s*[:.]?\s*/gi, '');
|
|
content = content.replace(/^(avec\s+)?style\s+[^:]*\s*[:.]?\s*/gi, '');
|
|
content = content.replace(/^(dans\s+le\s+style\s+de\s+)[^:]*[:.]?\s*/gi, '');
|
|
|
|
// Nettoyer formatage
|
|
content = content.replace(/\*\*[^*]+\*\*/g, ''); // Gras markdown
|
|
content = content.replace(/\s{2,}/g, ' '); // Espaces multiples
|
|
content = content.trim();
|
|
|
|
return content;
|
|
}
|
|
}
|
|
|
|
module.exports = { StyleLayer }; |