`;
return html;
}
// 🔄 NODE.JS EXPORTS
module.exports = {
logSh,
cleanLogSheet,
validateWorkflowIntegrity,
detectDuplicateTags,
detectMissingCSVVariables,
assessGenerationQuality,
sendErrorReport,
createHTMLReport
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/BrainConfig.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// FICHIER: BrainConfig.js - Version Node.js
// Description: Configuration cerveau + sélection personnalité IA
// ========================================
require('dotenv').config();
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
// Import de la fonction logSh (assumant qu'elle existe dans votre projet Node.js)
const { logSh } = require('./ErrorReporting');
// Configuration
const CONFIG = {
openai: {
apiKey: process.env.OPENAI_API_KEY || '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',
endpoint: 'https://api.openai.com/v1/chat/completions'
},
dataSource: {
type: process.env.DATA_SOURCE_TYPE || 'json', // 'json', 'csv', 'database'
instructionsPath: './data/instructions.json',
personalitiesPath: './data/personalities.json'
}
};
/**
* FONCTION PRINCIPALE - Équivalent getBrainConfig()
* @param {number|object} data - Numéro de ligne ou données directes
* @returns {object} Configuration avec données CSV + personnalité
*/
async function getBrainConfig(data) {
try {
logSh("🧠 Début getBrainConfig Node.js", "INFO");
// 1. RÉCUPÉRER LES DONNÉES CSV
let csvData;
if (typeof data === 'number') {
// Numéro de ligne fourni - lire depuis fichier
csvData = await readInstructionsData(data);
} else if (typeof data === 'object' && data.rowNumber) {
csvData = await readInstructionsData(data.rowNumber);
} else {
// Données déjà fournies
csvData = data;
}
logSh(`✅ CSV récupéré: ${csvData.mc0}`, "INFO");
// 2. RÉCUPÉRER LES PERSONNALITÉS
const personalities = await getPersonalities();
logSh(`✅ ${personalities.length} personnalités chargées`, "INFO");
// 3. SÉLECTIONNER LA MEILLEURE PERSONNALITÉ VIA IA
const selectedPersonality = await selectPersonalityWithAI(
csvData.mc0,
csvData.t0,
personalities
);
logSh(`✅ Personnalité sélectionnée: ${selectedPersonality.nom}`, "INFO");
return {
success: true,
data: {
...csvData,
personality: selectedPersonality,
timestamp: new Date().toISOString()
}
};
} catch (error) {
logSh(`❌ Erreur getBrainConfig: ${error.message}`, "ERROR");
return {
success: false,
error: error.message
};
}
}
/**
* LIRE DONNÉES INSTRUCTIONS depuis Google Sheets DIRECTEMENT
* @param {number} rowNumber - Numéro de ligne (2 = première ligne de données)
* @returns {object} Données CSV parsées
*/
async function readInstructionsData(rowNumber = 2) {
try {
logSh(`📊 Lecture Google Sheet ligne ${rowNumber}...`, 'INFO');
// NOUVEAU : Lecture directe depuis Google Sheets
const { google } = require('googleapis');
// Configuration auth Google Sheets - FORCE utilisation fichier JSON pour éviter problème TLS
const keyFilePath = path.join(__dirname, '..', 'seo-generator-470715-85d4a971c1af.json');
const auth = new google.auth.GoogleAuth({
keyFile: keyFilePath,
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly']
});
logSh('🔑 Utilisation fichier JSON pour contourner problème TLS OAuth', 'INFO');
const sheets = google.sheets({ version: 'v4', auth });
const SHEET_ID = process.env.GOOGLE_SHEETS_ID || '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c';
// Récupérer la ligne spécifique (A à I au minimum)
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SHEET_ID,
range: `Instructions!A${rowNumber}:I${rowNumber}` // Ligne spécifique A-I
});
if (!response.data.values || response.data.values.length === 0) {
throw new Error(`Ligne ${rowNumber} non trouvée dans Google Sheet`);
}
const row = response.data.values[0];
logSh(`✅ Ligne ${rowNumber} récupérée: ${row.length} colonnes`, 'INFO');
const xmlTemplateValue = row[8] || '';
let xmlTemplate = xmlTemplateValue;
let xmlFileName = null;
// Si c'est un nom de fichier, garder le nom ET utiliser un template par défaut
if (xmlTemplateValue && xmlTemplateValue.endsWith('.xml') && xmlTemplateValue.length < 100) {
logSh(`🔧 XML filename detected (${xmlTemplateValue}), keeping filename for Digital Ocean`, 'INFO');
xmlFileName = xmlTemplateValue; // Garder le nom du fichier pour Digital Ocean
xmlTemplate = createDefaultXMLTemplate(); // Template par défaut pour le processing
}
return {
rowNumber: rowNumber,
slug: row[0] || '', // Colonne A
t0: row[1] || '', // Colonne B
mc0: row[2] || '', // Colonne C
tMinus1: row[3] || '', // Colonne D
lMinus1: row[4] || '', // Colonne E
mcPlus1: row[5] || '', // Colonne F
tPlus1: row[6] || '', // Colonne G
lPlus1: row[7] || '', // Colonne H
xmlTemplate: xmlTemplate, // XML template pour processing
xmlFileName: xmlFileName // Nom fichier pour Digital Ocean (si applicable)
};
} catch (error) {
logSh(`❌ Erreur lecture Google Sheet: ${error.message}`, "ERROR");
throw error;
}
}
/**
* RÉCUPÉRER PERSONNALITÉS depuis l'onglet "Personnalites" du Google Sheet
* @returns {Array} Liste des personnalités disponibles
*/
async function getPersonalities() {
try {
logSh('📊 Lecture personnalités depuis Google Sheet (onglet Personnalites)...', 'INFO');
// Configuration auth Google Sheets - FORCE utilisation fichier JSON pour éviter problème TLS
const { google } = require('googleapis');
const keyFilePath = path.join(__dirname, '..', 'seo-generator-470715-85d4a971c1af.json');
const auth = new google.auth.GoogleAuth({
keyFile: keyFilePath,
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly']
});
logSh('🔑 Utilisation fichier JSON pour contourner problème TLS OAuth (personnalités)', 'INFO');
const sheets = google.sheets({ version: 'v4', auth });
const SHEET_ID = process.env.GOOGLE_SHEETS_ID || '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c';
// Récupérer toutes les personnalités (après la ligne d'en-tête)
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SHEET_ID,
range: 'Personnalites!A2:O' // Colonnes A à O pour inclure les nouvelles colonnes IA
});
if (!response.data.values || response.data.values.length === 0) {
throw new Error('Aucune personnalité trouvée dans l\'onglet Personnalites');
}
const personalities = [];
// Traiter chaque ligne de personnalité
response.data.values.forEach((row, index) => {
if (row[0] && row[0].toString().trim() !== '') { // Si nom existe (colonne A)
const personality = {
nom: row[0]?.toString().trim() || '',
description: row[1]?.toString().trim() || 'Expert généraliste',
style: row[2]?.toString().trim() || 'professionnel',
// Configuration avancée depuis colonnes Google Sheet
motsClesSecteurs: parseCSVField(row[3]),
vocabulairePref: parseCSVField(row[4]),
connecteursPref: parseCSVField(row[5]),
erreursTypiques: parseCSVField(row[6]),
longueurPhrases: row[7]?.toString().trim() || 'moyennes',
niveauTechnique: row[8]?.toString().trim() || 'moyen',
ctaStyle: parseCSVField(row[9]),
defautsSimules: parseCSVField(row[10]),
// NOUVEAU: Configuration IA par étape depuis Google Sheets (colonnes L-O)
aiEtape1Base: row[11]?.toString().trim().toLowerCase() || '',
aiEtape2Technique: row[12]?.toString().trim().toLowerCase() || '',
aiEtape3Transitions: row[13]?.toString().trim().toLowerCase() || '',
aiEtape4Style: row[14]?.toString().trim().toLowerCase() || '',
// Backward compatibility
motsCles: parseCSVField(row[3] || '') // Utilise motsClesSecteurs
};
personalities.push(personality);
logSh(`✓ Personnalité chargée: ${personality.nom} (${personality.style})`, 'DEBUG');
}
});
logSh(`📊 ${personalities.length} personnalités chargées depuis Google Sheet`, "INFO");
return personalities;
} catch (error) {
logSh(`❌ ÉCHEC: Impossible de récupérer les personnalités Google Sheets - ${error.message}`, "ERROR");
throw new Error(`FATAL: Personnalités Google Sheets inaccessibles - arrêt du workflow: ${error.message}`);
}
}
/**
* PARSER CHAMP CSV - Helper function
* @param {string} field - Champ à parser
* @returns {Array} Liste des éléments parsés
*/
function parseCSVField(field) {
if (!field || field.toString().trim() === '') return [];
return field.toString()
.split(',')
.map(item => item.trim())
.filter(item => item.length > 0);
}
/**
* Sélectionner un sous-ensemble aléatoire de personnalités
* @param {Array} allPersonalities - Liste complète des personnalités
* @param {number} percentage - Pourcentage à garder (0.6 = 60%)
* @returns {Array} Sous-ensemble aléatoire
*/
function selectRandomPersonalities(allPersonalities, percentage = 0.6) {
const count = Math.ceil(allPersonalities.length * percentage);
// Mélanger avec Fisher-Yates shuffle (meilleur que sort())
const shuffled = [...allPersonalities];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled.slice(0, count);
}
/**
* NOUVELLE FONCTION: Sélection de 4 personnalités complémentaires pour le pipeline multi-AI
* @param {string} mc0 - Mot-clé principal
* @param {string} t0 - Titre principal
* @param {Array} personalities - Liste des personnalités
* @returns {Array} 4 personnalités sélectionnées pour chaque étape
*/
async function selectMultiplePersonalitiesWithAI(mc0, t0, personalities) {
try {
logSh(`🎭 Sélection MULTI-personnalités IA pour: ${mc0}`, "INFO");
// Sélection aléatoire de 80% des personnalités (plus large pour 4 choix)
const randomPersonalities = selectRandomPersonalities(personalities, 0.8);
const totalCount = personalities.length;
const selectedCount = randomPersonalities.length;
logSh(`🎲 Pool aléatoire: ${selectedCount}/${totalCount} personnalités disponibles`, "DEBUG");
logSh(`📋 Personnalités dans le pool: ${randomPersonalities.map(p => p.nom).join(', ')}`, "DEBUG");
const prompt = `Choisis 4 personnalités COMPLÉMENTAIRES pour générer du contenu sur "${mc0}":
OBJECTIF: Créer une équipe de 4 rédacteurs avec styles différents mais cohérents
PERSONNALITÉS DISPONIBLES:
${randomPersonalities.map(p => `- ${p.nom}: ${p.description} (Style: ${p.style})`).join('\n')}
RÔLES À ATTRIBUER:
1. GÉNÉRATEUR BASE: Personnalité technique/experte pour la génération initiale
2. ENHANCER TECHNIQUE: Personnalité commerciale/précise pour améliorer les termes techniques
3. FLUIDITÉ: Personnalité créative/littéraire pour améliorer les transitions
4. STYLE FINAL: Personnalité terrain/accessible pour le style final
CRITÈRES:
- 4 personnalités aux styles DIFFÉRENTS mais complémentaires
- Adapté au secteur: ${mc0}
- Variabilité maximale pour anti-détection
- Éviter les doublons de style
FORMAT DE RÉPONSE (EXACTEMENT 4 noms séparés par des virgules):
Nom1, Nom2, Nom3, Nom4`;
const requestData = {
model: "gpt-4o-mini",
messages: [{"role": "user", "content": prompt}],
max_tokens: 100,
temperature: 1.0
};
const response = await axios.post(CONFIG.openai.endpoint, requestData, {
headers: {
'Authorization': `Bearer ${CONFIG.openai.apiKey}`,
'Content-Type': 'application/json'
},
timeout: 300000
});
const selectedNames = response.data.choices[0].message.content.trim()
.split(',')
.map(name => name.trim());
logSh(`🔍 Noms retournés par IA: ${selectedNames.join(', ')}`, "DEBUG");
// Mapper aux vraies personnalités
const selectedPersonalities = [];
selectedNames.forEach(name => {
const personality = randomPersonalities.find(p => p.nom === name);
if (personality) {
selectedPersonalities.push(personality);
}
});
// Compléter si pas assez de personnalités trouvées (sécurité)
while (selectedPersonalities.length < 4 && randomPersonalities.length > selectedPersonalities.length) {
const remaining = randomPersonalities.filter(p =>
!selectedPersonalities.some(selected => selected.nom === p.nom)
);
if (remaining.length > 0) {
const randomIndex = Math.floor(Math.random() * remaining.length);
selectedPersonalities.push(remaining[randomIndex]);
} else {
break;
}
}
// Garantir exactement 4 personnalités
const final4Personalities = selectedPersonalities.slice(0, 4);
logSh(`✅ Équipe de 4 personnalités sélectionnée:`, "INFO");
final4Personalities.forEach((p, index) => {
const roles = ['BASE', 'TECHNIQUE', 'FLUIDITÉ', 'STYLE'];
logSh(` ${index + 1}. ${roles[index]}: ${p.nom} (${p.style})`, "INFO");
});
return final4Personalities;
} catch (error) {
logSh(`❌ FATAL: Sélection multi-personnalités échouée: ${error.message}`, "ERROR");
throw new Error(`FATAL: Sélection multi-personnalités IA impossible - arrêt du workflow: ${error.message}`);
}
}
/**
* FONCTION LEGACY: Sélection personnalité unique (maintenue pour compatibilité)
* @param {string} mc0 - Mot-clé principal
* @param {string} t0 - Titre principal
* @param {Array} personalities - Liste des personnalités
* @returns {object} Personnalité sélectionnée
*/
async function selectPersonalityWithAI(mc0, t0, personalities) {
try {
logSh(`🤖 Sélection personnalité IA UNIQUE pour: ${mc0}`, "DEBUG");
// Appeler la fonction multi et prendre seulement la première
const multiPersonalities = await selectMultiplePersonalitiesWithAI(mc0, t0, personalities);
const selectedPersonality = multiPersonalities[0];
logSh(`✅ Personnalité IA sélectionnée (mode legacy): ${selectedPersonality.nom}`, "INFO");
return selectedPersonality;
} catch (error) {
logSh(`❌ FATAL: Sélection personnalité par IA échouée: ${error.message}`, "ERROR");
throw new Error(`FATAL: Sélection personnalité IA inaccessible - arrêt du workflow: ${error.message}`);
}
}
/**
* CRÉER TEMPLATE XML PAR DÉFAUT quand colonne I contient un nom de fichier
* Utilise les données CSV disponibles pour créer un template robuste
*/
function createDefaultXMLTemplate() {
return `
|Titre_Principal{{T0}}{Rédige un titre H1 accrocheur de maximum 10 mots pour {{MC0}}. Style {{personality.style}}}|
|Introduction{{MC0}}{Rédige une introduction engageante de 2-3 phrases sur {{MC0}}. Ton {{personality.style}}, utilise {{personality.vocabulairePref}}}|
|Titre_H2_1{{MC+1_1}}{Crée un titre H2 informatif sur {{MC+1_1}}. Style {{personality.style}}}|
|Paragraphe_1{{MC+1_1}}{Rédige un paragraphe détaillé de 4-5 phrases sur {{MC+1_1}}. Explique les avantages et caractéristiques. Ton {{personality.style}}}|
|Titre_H2_2{{MC+1_2}}{Titre H2 pour {{MC+1_2}}. Mets en valeur les points forts. Ton {{personality.style}}}|
|Paragraphe_2{{MC+1_2}}{Paragraphe de 4-5 phrases sur {{MC+1_2}}. Détaille pourquoi c'est important pour {{MC0}}. Ton {{personality.style}}}|
|Titre_H2_3{{MC+1_3}}{Titre H2 sur les bénéfices de {{MC+1_3}}. Accrocheur et informatif}|
|Paragraphe_3{{MC+1_3}}{Explique en 4-5 phrases les avantages de {{MC+1_3}} pour {{MC0}}. Ton {{personality.style}}}|
`;
}
/**
* CRÉER FICHIERS DE DONNÉES D'EXEMPLE
* Fonction utilitaire pour initialiser les fichiers JSON
*/
async function createSampleDataFiles() {
try {
// Créer répertoire data s'il n'existe pas
await fs.mkdir('./data', { recursive: true });
// Exemple instructions.json
const sampleInstructions = [
{
slug: "plaque-test",
t0: "Plaque test signalétique",
mc0: "plaque signalétique",
"t-1": "Signalétique",
"l-1": "/signaletique/",
"mc+1": "plaque dibond, plaque aluminium, plaque PVC",
"t+1": "Plaque dibond, Plaque alu, Plaque PVC",
"l+1": "/plaque-dibond/, /plaque-aluminium/, /plaque-pvc/",
xmlFileName: "template-plaque.xml"
}
];
// Exemple personalities.json
const samplePersonalities = [
{
nom: "Marc",
description: "Expert technique en signalétique",
style: "professionnel et précis",
motsClesSecteurs: "technique,dibond,aluminium,impression",
vocabulairePref: "précision,qualité,expertise,performance",
connecteursPref: "par ailleurs,en effet,notamment,cependant",
erreursTypiques: "accord_proximite,repetition_legere",
longueurPhrases: "moyennes",
niveauTechnique: "élevé",
ctaStyle: "découvrir,choisir,commander",
defautsSimules: "fatigue_cognitive,hesitation_technique"
},
{
nom: "Sophie",
description: "Passionnée de décoration et design",
style: "familier et chaleureux",
motsClesSecteurs: "décoration,design,esthétique,tendances",
vocabulairePref: "joli,magnifique,tendance,style",
connecteursPref: "du coup,en fait,sinon,au fait",
erreursTypiques: "familiarite_excessive,expression_populaire",
longueurPhrases: "courtes",
niveauTechnique: "moyen",
ctaStyle: "craquer,adopter,foncer",
defautsSimules: "enthousiasme_variable,anecdote_personnelle"
}
];
// Écrire les fichiers
await fs.writeFile('./data/instructions.json', JSON.stringify(sampleInstructions, null, 2));
await fs.writeFile('./data/personalities.json', JSON.stringify(samplePersonalities, null, 2));
logSh('✅ Fichiers de données d\'exemple créés dans ./data/', "INFO");
} catch (error) {
logSh(`❌ Erreur création fichiers exemple: ${error.message}`, "ERROR");
}
}
// ============= EXPORTS NODE.JS =============
module.exports = {
getBrainConfig,
getPersonalities,
selectPersonalityWithAI,
selectMultiplePersonalitiesWithAI, // NOUVEAU: Export de la fonction multi-personnalités
selectRandomPersonalities,
parseCSVField,
readInstructionsData,
createSampleDataFiles,
createDefaultXMLTemplate,
CONFIG
};
// ============= TEST RAPIDE SI LANCÉ DIRECTEMENT =============
if (require.main === module) {
(async () => {
try {
logSh('🧪 Test BrainConfig Node.js...', "INFO");
// Créer fichiers exemple si nécessaire
try {
await fs.access('./data/instructions.json');
} catch {
await createSampleDataFiles();
}
// Test de la fonction principale
const result = await getBrainConfig(2);
if (result.success) {
logSh(`✅ Test réussi: ${result.data.personality.nom} pour ${result.data.mc0}`, "INFO");
} else {
logSh(`❌ Test échoué: ${result.error}`, "ERROR");
}
} catch (error) {
logSh(`❌ Erreur test: ${error.message}`, "ERROR");
}
})();
}
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/LLMManager.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// FICHIER: LLMManager.js
// Description: Hub central pour tous les appels LLM (Version Node.js)
// Support: Claude, OpenAI, Gemini, Deepseek, Moonshot, Mistral
// ========================================
const fetch = globalThis.fetch.bind(globalThis);
const { logSh } = require('./ErrorReporting');
// ============= CONFIGURATION CENTRALISÉE =============
const LLM_CONFIG = {
openai: {
apiKey: process.env.OPENAI_API_KEY || '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',
endpoint: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4o-mini',
headers: {
'Authorization': 'Bearer {API_KEY}',
'Content-Type': 'application/json'
},
temperature: 0.7,
timeout: 300000, // 5 minutes
retries: 3
},
claude: {
apiKey: process.env.CLAUDE_API_KEY || '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',
endpoint: 'https://api.anthropic.com/v1/messages',
model: 'claude-sonnet-4-20250514',
headers: {
'x-api-key': '{API_KEY}',
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01'
},
temperature: 0.7,
timeout: 300000, // 5 minutes
retries: 6
},
gemini: {
apiKey: process.env.GEMINI_API_KEY || 'AIzaSyAMzmIGbW5nJlBG5Qyr35sdjb3U2bIBtoE',
endpoint: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent',
model: 'gemini-2.5-flash',
headers: {
'Content-Type': 'application/json'
},
temperature: 0.7,
maxTokens: 6000,
timeout: 300000, // 5 minutes
retries: 3
},
deepseek: {
apiKey: process.env.DEEPSEEK_API_KEY || 'sk-6e02bc9513884bb8b92b9920524e17b5',
endpoint: 'https://api.deepseek.com/v1/chat/completions',
model: 'deepseek-chat',
headers: {
'Authorization': 'Bearer {API_KEY}',
'Content-Type': 'application/json'
},
temperature: 0.7,
timeout: 300000, // 5 minutes
retries: 3
},
moonshot: {
apiKey: process.env.MOONSHOT_API_KEY || 'sk-zU9gyNkux2zcsj61cdKfztuP1Jozr6lFJ9viUJRPD8p8owhL',
endpoint: 'https://api.moonshot.ai/v1/chat/completions',
model: 'moonshot-v1-32k',
headers: {
'Authorization': 'Bearer {API_KEY}',
'Content-Type': 'application/json'
},
temperature: 0.7,
timeout: 300000, // 5 minutes
retries: 3
},
mistral: {
apiKey: process.env.MISTRAL_API_KEY || 'wESikMCIuixajSH8WHCiOV2z5sevgmVF',
endpoint: 'https://api.mistral.ai/v1/chat/completions',
model: 'mistral-small-latest',
headers: {
'Authorization': 'Bearer {API_KEY}',
'Content-Type': 'application/json'
},
max_tokens: 5000,
temperature: 0.7,
timeout: 300000, // 5 minutes
retries: 3
}
};
// ============= HELPER FUNCTIONS =============
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// ============= INTERFACE UNIVERSELLE =============
/**
* Fonction principale pour appeler n'importe quel LLM
* @param {string} llmProvider - claude|openai|gemini|deepseek|moonshot|mistral
* @param {string} prompt - Le prompt à envoyer
* @param {object} options - Options personnalisées (température, tokens, etc.)
* @param {object} personality - Personnalité pour contexte système
* @returns {Promise} - Réponse générée
*/
async function callLLM(llmProvider, prompt, options = {}, personality = null) {
const startTime = Date.now();
try {
// Vérifier si le provider existe
if (!LLM_CONFIG[llmProvider]) {
throw new Error(`Provider LLM inconnu: ${llmProvider}`);
}
// Vérifier si l'API key est configurée
const config = LLM_CONFIG[llmProvider];
if (!config.apiKey || config.apiKey.startsWith('VOTRE_CLE_')) {
throw new Error(`Clé API manquante pour ${llmProvider}`);
}
logSh(`🤖 Appel LLM: ${llmProvider.toUpperCase()} (${config.model}) | Personnalité: ${personality?.nom || 'aucune'}`, 'DEBUG');
// 📢 AFFICHAGE PROMPT COMPLET POUR DEBUG AVEC INFO IA
logSh(`\n🔍 ===== PROMPT ENVOYÉ À ${llmProvider.toUpperCase()} (${config.model}) | PERSONNALITÉ: ${personality?.nom || 'AUCUNE'} =====`, 'PROMPT');
logSh(prompt, 'PROMPT');
// 📤 LOG LLM REQUEST COMPLET
logSh(`📤 LLM REQUEST [${llmProvider.toUpperCase()}] (${config.model}) | Personnalité: ${personality?.nom || 'AUCUNE'}`, 'LLM');
logSh(prompt, 'LLM');
// Préparer la requête selon le provider
const requestData = buildRequestData(llmProvider, prompt, options, personality);
// Effectuer l'appel avec retry logic
const response = await callWithRetry(llmProvider, requestData, config);
// Parser la réponse selon le format du provider
const content = parseResponse(llmProvider, response);
// 📥 LOG LLM RESPONSE COMPLET
logSh(`📥 LLM RESPONSE [${llmProvider.toUpperCase()}] (${config.model}) | Durée: ${Date.now() - startTime}ms`, 'LLM');
logSh(content, 'LLM');
const duration = Date.now() - startTime;
logSh(`✅ ${llmProvider.toUpperCase()} (${personality?.nom || 'sans personnalité'}) réponse en ${duration}ms`, 'INFO');
// Enregistrer les stats d'usage
await recordUsageStats(llmProvider, prompt.length, content.length, duration);
return content;
} catch (error) {
const duration = Date.now() - startTime;
logSh(`❌ Erreur ${llmProvider.toUpperCase()} (${personality?.nom || 'sans personnalité'}): ${error.toString()}`, 'ERROR');
// Enregistrer l'échec
await recordUsageStats(llmProvider, prompt.length, 0, duration, error.toString());
throw error;
}
}
// ============= CONSTRUCTION DES REQUÊTES =============
function buildRequestData(provider, prompt, options, personality) {
const config = LLM_CONFIG[provider];
const temperature = options.temperature || config.temperature;
const maxTokens = options.maxTokens || config.maxTokens;
// Construire le système prompt si personnalité fournie
const systemPrompt = personality ?
`Tu es ${personality.nom}. ${personality.description}. Style: ${personality.style}` :
'Tu es un assistant expert.';
switch (provider) {
case 'openai':
case 'deepseek':
case 'moonshot':
case 'mistral':
return {
model: config.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }
],
max_tokens: maxTokens,
temperature: temperature,
stream: false
};
case 'claude':
return {
model: config.model,
max_tokens: maxTokens,
temperature: temperature,
system: systemPrompt,
messages: [
{ role: 'user', content: prompt }
]
};
case 'gemini':
return {
contents: [{
parts: [{
text: `${systemPrompt}\n\n${prompt}`
}]
}],
generationConfig: {
temperature: temperature,
maxOutputTokens: maxTokens
}
};
default:
throw new Error(`Format de requête non supporté pour ${provider}`);
}
}
// ============= APPELS AVEC RETRY =============
async function callWithRetry(provider, requestData, config) {
let lastError;
for (let attempt = 1; attempt <= config.retries; attempt++) {
try {
logSh(`🔄 Tentative ${attempt}/${config.retries} pour ${provider.toUpperCase()}`, 'DEBUG');
// Préparer les headers avec la clé API
const headers = {};
Object.keys(config.headers).forEach(key => {
headers[key] = config.headers[key].replace('{API_KEY}', config.apiKey);
});
// URL avec clé API pour Gemini (cas spécial)
let url = config.endpoint;
if (provider === 'gemini') {
url += `?key=${config.apiKey}`;
}
const options = {
method: 'POST',
headers: headers,
body: JSON.stringify(requestData),
timeout: config.timeout
};
const response = await fetch(url, options);
const responseText = await response.text();
if (response.ok) {
return JSON.parse(responseText);
} else if (response.status === 429) {
// Rate limiting - attendre plus longtemps
const waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff
logSh(`⏳ Rate limit ${provider.toUpperCase()}, attente ${waitTime}ms`, 'WARNING');
await sleep(waitTime);
continue;
} else {
throw new Error(`HTTP ${response.status}: ${responseText}`);
}
} catch (error) {
lastError = error;
if (attempt < config.retries) {
const waitTime = 1000 * attempt;
logSh(`⚠ Erreur tentative ${attempt}: ${error.toString()}, retry dans ${waitTime}ms`, 'WARNING');
await sleep(waitTime);
}
}
}
throw new Error(`Échec après ${config.retries} tentatives: ${lastError.toString()}`);
}
// ============= PARSING DES RÉPONSES =============
function parseResponse(provider, responseData) {
try {
switch (provider) {
case 'openai':
case 'deepseek':
case 'moonshot':
case 'mistral':
return responseData.choices[0].message.content.trim();
case 'claude':
return responseData.content[0].text.trim();
case 'gemini':
const candidate = responseData.candidates[0];
// Vérifications multiples pour Gemini 2.5
if (candidate && candidate.content && candidate.content.parts && candidate.content.parts[0] && candidate.content.parts[0].text) {
return candidate.content.parts[0].text.trim();
} else if (candidate && candidate.text) {
return candidate.text.trim();
} else if (candidate && candidate.content && candidate.content.text) {
return candidate.content.text.trim();
} else {
// Debug : logger la structure complète
logSh('Gemini structure complète: ' + JSON.stringify(responseData), 'DEBUG');
return '[Gemini: pas de texte généré - problème modèle]';
}
default:
throw new Error(`Parser non supporté pour ${provider}`);
}
} catch (error) {
logSh(`❌ Erreur parsing ${provider}: ${error.toString()}`, 'ERROR');
logSh(`Response brute: ${JSON.stringify(responseData)}`, 'DEBUG');
throw new Error(`Impossible de parser la réponse ${provider}: ${error.toString()}`);
}
}
// ============= GESTION DES STATISTIQUES =============
async function recordUsageStats(provider, promptTokens, responseTokens, duration, error = null) {
try {
// TODO: Adapter selon votre système de stockage Node.js
// Peut être une base de données, un fichier, MongoDB, etc.
const statsData = {
timestamp: new Date(),
provider: provider,
model: LLM_CONFIG[provider].model,
promptTokens: promptTokens,
responseTokens: responseTokens,
duration: duration,
error: error || ''
};
// Exemple: log vers console ou fichier
logSh(`📊 Stats: ${JSON.stringify(statsData)}`, 'DEBUG');
// TODO: Implémenter sauvegarde réelle (DB, fichier, etc.)
} catch (statsError) {
// Ne pas faire planter le workflow si les stats échouent
logSh(`⚠ Erreur enregistrement stats: ${statsError.toString()}`, 'WARNING');
}
}
// ============= FONCTIONS UTILITAIRES =============
/**
* Tester la connectivité de tous les LLMs
*/
async function testAllLLMs() {
const testPrompt = "Dis bonjour en 5 mots maximum.";
const results = {};
const allProviders = Object.keys(LLM_CONFIG);
for (const provider of allProviders) {
try {
logSh(`🧪 Test ${provider}...`, 'INFO');
const response = await callLLM(provider, testPrompt);
results[provider] = {
status: 'SUCCESS',
response: response,
model: LLM_CONFIG[provider].model
};
} catch (error) {
results[provider] = {
status: 'ERROR',
error: error.toString(),
model: LLM_CONFIG[provider].model
};
}
// Petit délai entre tests
await sleep(500);
}
logSh(`📊 Tests terminés: ${JSON.stringify(results, null, 2)}`, 'INFO');
return results;
}
/**
* Obtenir les providers disponibles (avec clés API valides)
*/
function getAvailableProviders() {
const available = [];
Object.keys(LLM_CONFIG).forEach(provider => {
const config = LLM_CONFIG[provider];
if (config.apiKey && !config.apiKey.startsWith('VOTRE_CLE_')) {
available.push(provider);
}
});
return available;
}
/**
* Obtenir des statistiques d'usage par provider
*/
async function getUsageStats() {
try {
// TODO: Adapter selon votre système de stockage
// Pour l'instant retourne un message par défaut
return { message: 'Statistiques non implémentées en Node.js' };
} catch (error) {
return { error: error.toString() };
}
}
// ============= MIGRATION DE L'ANCIEN CODE =============
/**
* Fonction de compatibilité pour remplacer votre ancien callOpenAI()
* Maintient la même signature pour ne pas casser votre code existant
*/
async function callOpenAI(prompt, personality) {
return await callLLM('openai', prompt, {}, personality);
}
// ============= EXPORTS POUR TESTS =============
/**
* Fonction de test rapide
*/
async function testLLMManager() {
logSh('🚀 Test du LLM Manager Node.js...', 'INFO');
// Test des providers disponibles
const available = getAvailableProviders();
logSh('Providers disponibles: ' + available.join(', ') + ' (' + available.length + '/6)', 'INFO');
// Test d'appel simple sur chaque provider disponible
for (const provider of available) {
try {
logSh(`🧪 Test ${provider}...`, 'DEBUG');
const startTime = Date.now();
const response = await callLLM(provider, 'Dis juste "Test OK"');
const duration = Date.now() - startTime;
logSh(`✅ Test ${provider} réussi: "${response}" (${duration}ms)`, 'INFO');
} catch (error) {
logSh(`❌ Test ${provider} échoué: ${error.toString()}`, 'ERROR');
}
// Petit délai pour éviter rate limits
await sleep(500);
}
// Test spécifique OpenAI (compatibilité avec ancien code)
try {
logSh('🎯 Test spécifique OpenAI (compatibilité)...', 'DEBUG');
const response = await callLLM('openai', 'Dis juste "Test OK"');
logSh('✅ Test OpenAI compatibilité: ' + response, 'INFO');
} catch (error) {
logSh('❌ Test OpenAI compatibilité échoué: ' + error.toString(), 'ERROR');
}
// Afficher les stats d'usage
try {
logSh('📊 Récupération statistiques d\'usage...', 'DEBUG');
const stats = await getUsageStats();
if (stats.error) {
logSh('⚠ Erreur récupération stats: ' + stats.error, 'WARNING');
} else if (stats.message) {
logSh('📊 Stats: ' + stats.message, 'INFO');
} else {
// Formatter les stats pour les logs
Object.keys(stats).forEach(provider => {
const s = stats[provider];
logSh(`📈 ${provider}: ${s.calls} appels, ${s.successRate}% succès, ${s.avgDuration}ms moyen`, 'INFO');
});
}
} catch (error) {
logSh('❌ Erreur lors de la récupération des stats: ' + error.toString(), 'ERROR');
}
// Résumé final
const workingCount = available.length;
const totalProviders = Object.keys(LLM_CONFIG).length;
if (workingCount === totalProviders) {
logSh(`✅ Test LLM Manager COMPLET: ${workingCount}/${totalProviders} providers opérationnels`, 'INFO');
} else if (workingCount >= 2) {
logSh(`✅ Test LLM Manager PARTIEL: ${workingCount}/${totalProviders} providers opérationnels (suffisant pour DNA Mixing)`, 'INFO');
} else {
logSh(`❌ Test LLM Manager INSUFFISANT: ${workingCount}/${totalProviders} providers opérationnels (minimum 2 requis)`, 'ERROR');
}
logSh('🏁 Test LLM Manager terminé', 'INFO');
}
/**
* Version complète avec test de tous les providers (même non configurés)
*/
async function testLLMManagerComplete() {
logSh('🚀 Test COMPLET du LLM Manager (tous providers)...', 'INFO');
const allProviders = Object.keys(LLM_CONFIG);
logSh(`Providers configurés: ${allProviders.join(', ')}`, 'INFO');
const results = {
configured: 0,
working: 0,
failed: 0
};
for (const provider of allProviders) {
const config = LLM_CONFIG[provider];
// Vérifier si configuré
if (!config.apiKey || config.apiKey.startsWith('VOTRE_CLE_')) {
logSh(`⚙️ ${provider}: NON CONFIGURÉ (clé API manquante)`, 'WARNING');
continue;
}
results.configured++;
try {
logSh(`🧪 Test ${provider} (${config.model})...`, 'DEBUG');
const startTime = Date.now();
const response = await callLLM(provider, 'Réponds "OK" seulement.', { maxTokens: 100 });
const duration = Date.now() - startTime;
results.working++;
logSh(`✅ ${provider}: "${response.trim()}" (${duration}ms)`, 'INFO');
} catch (error) {
results.failed++;
logSh(`❌ ${provider}: ${error.toString()}`, 'ERROR');
}
// Délai entre tests
await sleep(700);
}
// Résumé final complet
logSh(`📊 RÉSUMÉ FINAL:`, 'INFO');
logSh(` • Providers total: ${allProviders.length}`, 'INFO');
logSh(` • Configurés: ${results.configured}`, 'INFO');
logSh(` • Fonctionnels: ${results.working}`, 'INFO');
logSh(` • En échec: ${results.failed}`, 'INFO');
const status = results.working >= 4 ? 'EXCELLENT' :
results.working >= 2 ? 'BON' : 'INSUFFISANT';
logSh(`🏆 STATUS: ${status} (${results.working} LLMs opérationnels)`,
status === 'INSUFFISANT' ? 'ERROR' : 'INFO');
logSh('🏁 Test LLM Manager COMPLET terminé', 'INFO');
return {
total: allProviders.length,
configured: results.configured,
working: results.working,
failed: results.failed,
status: status
};
}
// ============= EXPORTS MODULE =============
module.exports = {
callLLM,
callOpenAI,
testAllLLMs,
getAvailableProviders,
getUsageStats,
testLLMManager,
testLLMManagerComplete,
LLM_CONFIG
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/ElementExtraction.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// FICHIER: lib/element-extraction.js - CONVERTI POUR NODE.JS
// Description: Extraction et parsing des éléments XML
// ========================================
// 🔄 NODE.JS IMPORTS
const { logSh } = require('./ErrorReporting');
// ============= EXTRACTION PRINCIPALE =============
async function extractElements(xmlTemplate, csvData) {
try {
await logSh('Extraction éléments avec séparation tag/contenu...', 'DEBUG');
const regex = /\|([^|]+)\|/g;
const elements = [];
let match;
while ((match = regex.exec(xmlTemplate)) !== null) {
const fullMatch = match[1]; // Ex: "Titre_H1_1{{T0}}" ou "Titre_H3_3{{MC+1_3}}"
// Séparer nom du tag et variables
const nameMatch = fullMatch.match(/^([^{]+)/);
const variablesMatch = fullMatch.match(/\{\{([^}]+)\}\}/g);
// FIX REGEX INSTRUCTIONS - Enlever d'abord les {{variables}} puis chercher {instructions}
const withoutVariables = fullMatch.replace(/\{\{[^}]+\}\}/g, '');
const instructionsMatch = withoutVariables.match(/\{([^}]+)\}/);
const tagName = nameMatch ? nameMatch[1].trim() : fullMatch.split('{')[0];
// TAG PUR (sans variables)
const pureTag = `|${tagName}|`;
// RÉSOUDRE le contenu des variables
const resolvedContent = resolveVariablesContent(variablesMatch, csvData);
elements.push({
originalTag: pureTag, // ← TAG PUR : |Titre_H3_3|
name: tagName, // ← Titre_H3_3
variables: variablesMatch || [], // ← [{{MC+1_3}}]
resolvedContent: resolvedContent, // ← "Plaque de rue en aluminium"
instructions: instructionsMatch ? instructionsMatch[1] : null,
type: getElementType(tagName),
originalFullMatch: fullMatch // ← Backup si besoin
});
await logSh(`Tag séparé: ${pureTag} → "${resolvedContent}"`, 'DEBUG');
}
await logSh(`${elements.length} éléments extraits avec séparation`, 'INFO');
return elements;
} catch (error) {
await logSh(`Erreur extractElements: ${error}`, 'ERROR');
return [];
}
}
// ============= RÉSOLUTION VARIABLES - IDENTIQUE =============
function resolveVariablesContent(variablesMatch, csvData) {
if (!variablesMatch || variablesMatch.length === 0) {
return ""; // Pas de variables à résoudre
}
let resolvedContent = "";
variablesMatch.forEach(variable => {
const cleanVar = variable.replace(/[{}]/g, ''); // Enlever {{ }}
switch (cleanVar) {
case 'T0':
resolvedContent += csvData.t0;
break;
case 'MC0':
resolvedContent += csvData.mc0;
break;
case 'T-1':
resolvedContent += csvData.tMinus1;
break;
case 'L-1':
resolvedContent += csvData.lMinus1;
break;
default:
// Gérer MC+1_1, MC+1_2, etc.
if (cleanVar.startsWith('MC+1_')) {
const index = parseInt(cleanVar.split('_')[1]) - 1;
const mcPlus1 = csvData.mcPlus1.split(',').map(s => s.trim());
resolvedContent += mcPlus1[index] || `[${cleanVar} non défini]`;
}
else if (cleanVar.startsWith('T+1_')) {
const index = parseInt(cleanVar.split('_')[1]) - 1;
const tPlus1 = csvData.tPlus1.split(',').map(s => s.trim());
resolvedContent += tPlus1[index] || `[${cleanVar} non défini]`;
}
else if (cleanVar.startsWith('L+1_')) {
const index = parseInt(cleanVar.split('_')[1]) - 1;
const lPlus1 = csvData.lPlus1.split(',').map(s => s.trim());
resolvedContent += lPlus1[index] || `[${cleanVar} non défini]`;
}
else {
resolvedContent += `[${cleanVar} non résolu]`;
}
break;
}
});
return resolvedContent;
}
// ============= CLASSIFICATION ÉLÉMENTS - IDENTIQUE =============
function getElementType(name) {
if (name.includes('Titre_H1')) return 'titre_h1';
if (name.includes('Titre_H2')) return 'titre_h2';
if (name.includes('Titre_H3')) return 'titre_h3';
if (name.includes('Intro_')) return 'intro';
if (name.includes('Txt_')) return 'texte';
if (name.includes('Faq_q')) return 'faq_question';
if (name.includes('Faq_a')) return 'faq_reponse';
if (name.includes('Faq_H3')) return 'faq_titre';
return 'autre';
}
// ============= GÉNÉRATION SÉQUENTIELLE - ADAPTÉE =============
async function generateAllContent(elements, csvData, xmlTemplate) {
await logSh(`Début génération pour ${elements.length} éléments`, 'INFO');
const generatedContent = {};
for (let index = 0; index < elements.length; index++) {
const element = elements[index];
try {
await logSh(`Élément ${index + 1}/${elements.length}: ${element.name}`, 'DEBUG');
const prompt = createPromptForElement(element, csvData);
await logSh(`Prompt créé: ${prompt}`, 'DEBUG');
// 🔄 NODE.JS : Import callOpenAI depuis LLM manager
const { callLLM } = require('./LLMManager');
const content = await callLLM('openai', prompt, {}, csvData.personality);
await logSh(`Contenu reçu: ${content}`, 'DEBUG');
generatedContent[element.originalTag] = content;
// 🔄 NODE.JS : Pas de Utilities.sleep(), les appels API gèrent leur rate limiting
} catch (error) {
await logSh(`ERREUR élément ${element.name}: ${error.toString()}`, 'ERROR');
generatedContent[element.originalTag] = `[Erreur génération: ${element.name}]`;
}
}
await logSh(`Génération terminée. ${Object.keys(generatedContent).length} éléments`, 'INFO');
return generatedContent;
}
// ============= PARSING STRUCTURE - IDENTIQUE =============
function parseElementStructure(element) {
// NETTOYER le nom : enlever , , {{...}}, {...}
let cleanName = element.name
.replace(/<\/?strong>/g, '') // ← ENLEVER
.replace(/\{\{[^}]*\}\}/g, '') // Enlever {{MC0}}
.replace(/\{[^}]*\}/g, ''); // Enlever {instructions}
const parts = cleanName.split('_');
return {
type: parts[0],
level: parts[1],
indices: parts.slice(2).map(Number),
hierarchyPath: parts.slice(1).join('_'),
originalElement: element,
variables: element.variables || [],
instructions: element.instructions
};
}
// ============= HIÉRARCHIE INTELLIGENTE - ADAPTÉE =============
async function buildSmartHierarchy(elements) {
const hierarchy = {};
elements.forEach(element => {
const structure = parseElementStructure(element);
const path = structure.hierarchyPath;
if (!hierarchy[path]) {
hierarchy[path] = {
title: null,
text: null,
questions: [],
children: {}
};
}
// Associer intelligemment
if (structure.type === 'Titre') {
hierarchy[path].title = structure; // Tout l'objet avec variables + instructions
} else if (structure.type === 'Txt') {
hierarchy[path].text = structure;
} else if (structure.type === 'Intro') {
hierarchy[path].text = structure;
} else if (structure.type === 'Faq') {
hierarchy[path].questions.push(structure);
}
});
// ← LIGNE COMPILÉE
const mappingSummary = Object.keys(hierarchy).map(path => {
const section = hierarchy[path];
return `${path}:[T:${section.title ? '✓' : '✗'} Txt:${section.text ? '✓' : '✗'} FAQ:${section.questions.length}]`;
}).join(' | ');
await logSh('Correspondances: ' + mappingSummary, 'DEBUG');
return hierarchy;
}
// ============= PARSERS RÉPONSES - ADAPTÉS =============
async function parseTitlesResponse(response, allTitles) {
const results = {};
// Utiliser regex pour extraire [TAG] contenu
const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs;
let match;
while ((match = regex.exec(response)) !== null) {
const tag = match[1].trim();
const content = match[2].trim();
// Nettoyer le contenu (enlever # et balises HTML si présentes)
const cleanContent = content
.replace(/^#+\s*/, '') // Enlever # du début
.replace(/<\/?[^>]+(>|$)/g, ""); // Enlever balises HTML
results[`|${tag}|`] = cleanContent;
await logSh(`✓ Titre parsé [${tag}]: "${cleanContent}"`, 'DEBUG');
}
// Fallback si parsing échoue
if (Object.keys(results).length === 0) {
await logSh('Parsing titres échoué, fallback ligne par ligne', 'WARNING');
const lines = response.split('\n').filter(line => line.trim());
allTitles.forEach((titleInfo, index) => {
if (lines[index]) {
results[titleInfo.tag] = lines[index].trim();
}
});
}
return results;
}
async function parseTextsResponse(response, allTexts) {
const results = {};
await logSh('Parsing réponse textes avec vrais tags...', 'DEBUG');
// Utiliser regex pour extraire [TAG] contenu avec les vrais noms
const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs;
let match;
while ((match = regex.exec(response)) !== null) {
const tag = match[1].trim();
const content = match[2].trim();
// Nettoyer le contenu
const cleanContent = content.replace(/^#+\s*/, '').replace(/<\/?[^>]+(>|$)/g, "");
results[`|${tag}|`] = cleanContent;
await logSh(`✓ Texte parsé [${tag}]: "${cleanContent}"`, 'DEBUG');
}
// Fallback si parsing échoue - mapper par position
if (Object.keys(results).length === 0) {
await logSh('Parsing textes échoué, fallback ligne par ligne', 'WARNING');
const lines = response.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0 && !line.startsWith('['));
for (let index = 0; index < allTexts.length; index++) {
const textInfo = allTexts[index];
if (index < lines.length) {
let content = lines[index];
content = content.replace(/^\d+\.\s*/, ''); // Enlever "1. " si présent
results[textInfo.tag] = content;
await logSh(`✓ Texte fallback ${index + 1} → ${textInfo.tag}: "${content}"`, 'DEBUG');
} else {
await logSh(`✗ Pas assez de lignes pour ${textInfo.tag}`, 'WARNING');
results[textInfo.tag] = `[Texte manquant ${index + 1}]`;
}
}
}
return results;
}
// ============= PARSER FAQ SPÉCIALISÉ - ADAPTÉ =============
async function parseFAQPairsResponse(response, faqPairs) {
const results = {};
await logSh('Parsing réponse paires FAQ...', 'DEBUG');
// Parser avec regex pour capturer question + réponse
const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs;
let match;
const parsedItems = {};
while ((match = regex.exec(response)) !== null) {
const tag = match[1].trim();
const content = match[2].trim();
const cleanContent = content.replace(/^#+\s*/, '').replace(/<\/?[^>]+(>|$)/g, "");
parsedItems[tag] = cleanContent;
await logSh(`✓ Item FAQ parsé [${tag}]: "${cleanContent}"`, 'DEBUG');
}
// Mapper aux tags originaux avec |
Object.keys(parsedItems).forEach(cleanTag => {
const content = parsedItems[cleanTag];
results[`|${cleanTag}|`] = content;
});
// Vérification de cohérence paires
let pairsCompletes = 0;
for (const pair of faqPairs) {
const hasQuestion = results[pair.question.tag];
const hasAnswer = results[pair.answer.tag];
if (hasQuestion && hasAnswer) {
pairsCompletes++;
await logSh(`✓ Paire FAQ ${pair.number} complète: Q+R`, 'DEBUG');
} else {
await logSh(`⚠ Paire FAQ ${pair.number} incomplète: Q=${!!hasQuestion} R=${!!hasAnswer}`, 'WARNING');
}
}
await logSh(`${pairsCompletes}/${faqPairs.length} paires FAQ complètes`, 'INFO');
// FATAL si paires FAQ manquantes
if (pairsCompletes < faqPairs.length) {
const manquantes = faqPairs.length - pairsCompletes;
await logSh(`❌ FATAL: ${manquantes} paires FAQ manquantes sur ${faqPairs.length}`, 'ERROR');
throw new Error(`FATAL: Génération FAQ incomplète (${manquantes}/${faqPairs.length} manquantes) - arrêt du workflow`);
}
return results;
}
async function parseOtherElementsResponse(response, allOtherElements) {
const results = {};
await logSh('Parsing réponse autres éléments...', 'DEBUG');
const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs;
let match;
while ((match = regex.exec(response)) !== null) {
const tag = match[1].trim();
const content = match[2].trim();
const cleanContent = content.replace(/^#+\s*/, '').replace(/<\/?[^>]+(>|$)/g, "");
results[`|${tag}|`] = cleanContent;
await logSh(`✓ Autre élément parsé [${tag}]: "${cleanContent}"`, 'DEBUG');
}
// Fallback si parsing partiel
if (Object.keys(results).length < allOtherElements.length) {
await logSh('Parsing autres éléments partiel, complétion fallback', 'WARNING');
const lines = response.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0 && !line.startsWith('['));
allOtherElements.forEach((element, index) => {
if (!results[element.tag] && lines[index]) {
results[element.tag] = lines[index];
}
});
}
return results;
}
// ============= HELPER FUNCTIONS - ADAPTÉES =============
function createPromptForElement(element, csvData) {
// Cette fonction sera probablement définie dans content-generation.js
// Pour l'instant, retour basique
return `Génère du contenu pour ${element.type}: ${element.resolvedContent}`;
}
// 🔄 NODE.JS EXPORTS
module.exports = {
extractElements,
resolveVariablesContent,
getElementType,
generateAllContent,
parseElementStructure,
buildSmartHierarchy,
parseTitlesResponse,
parseTextsResponse,
parseFAQPairsResponse,
parseOtherElementsResponse,
createPromptForElement
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/MissingKeywords.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// FICHIER: MissingKeywords.js - Version Node.js
// Description: Génération automatique des mots-clés manquants
// ========================================
const { logSh } = require('./ErrorReporting');
const { callLLM } = require('./LLMManager');
/**
* Génère automatiquement les mots-clés manquants pour les éléments non définis
* @param {Array} elements - Liste des éléments extraits
* @param {Object} csvData - Données CSV avec personnalité
* @returns {Object} Éléments mis à jour avec nouveaux mots-clés
*/
async function generateMissingKeywords(elements, csvData) {
logSh('>>> GÉNÉRATION MOTS-CLÉS MANQUANTS <<<', 'INFO');
// 1. IDENTIFIER tous les éléments manquants
const missingElements = [];
elements.forEach(element => {
if (element.resolvedContent.includes('non défini') ||
element.resolvedContent.includes('non résolu') ||
element.resolvedContent.trim() === '') {
missingElements.push({
tag: element.originalTag,
name: element.name,
type: element.type,
currentContent: element.resolvedContent,
context: getElementContext(element, elements, csvData)
});
}
});
if (missingElements.length === 0) {
logSh('Aucun mot-clé manquant détecté', 'INFO');
return {};
}
logSh(`${missingElements.length} mots-clés manquants détectés`, 'INFO');
// 2. ANALYSER le contexte global disponible
const contextAnalysis = analyzeAvailableContext(elements, csvData);
// 3. GÉNÉRER tous les manquants en UN SEUL appel IA
const generatedKeywords = await callOpenAIForMissingKeywords(missingElements, contextAnalysis, csvData);
// 4. METTRE À JOUR les éléments avec les nouveaux mots-clés
const updatedElements = updateElementsWithKeywords(elements, generatedKeywords);
logSh(`Mots-clés manquants générés: ${Object.keys(generatedKeywords).length}`, 'INFO');
return updatedElements;
}
/**
* Analyser le contexte disponible pour guider la génération
* @param {Array} elements - Tous les éléments
* @param {Object} csvData - Données CSV
* @returns {Object} Analyse contextuelle
*/
function analyzeAvailableContext(elements, csvData) {
const availableKeywords = [];
const availableContent = [];
// Récupérer tous les mots-clés/contenu déjà disponibles
elements.forEach(element => {
if (element.resolvedContent &&
!element.resolvedContent.includes('non défini') &&
!element.resolvedContent.includes('non résolu') &&
element.resolvedContent.trim() !== '') {
if (element.type.includes('titre')) {
availableKeywords.push(element.resolvedContent);
} else {
availableContent.push(element.resolvedContent.substring(0, 100));
}
}
});
return {
mainKeyword: csvData.mc0,
mainTitle: csvData.t0,
availableKeywords: availableKeywords,
availableContent: availableContent,
theme: csvData.mc0, // Thème principal
businessContext: "Autocollant.fr - signalétique personnalisée, plaques"
};
}
/**
* Obtenir le contexte spécifique d'un élément
* @param {Object} element - Élément à analyser
* @param {Array} allElements - Tous les éléments
* @param {Object} csvData - Données CSV
* @returns {Object} Contexte de l'élément
*/
function getElementContext(element, allElements, csvData) {
const context = {
elementType: element.type,
hierarchyLevel: element.name,
nearbyElements: []
};
// Trouver les éléments proches dans la hiérarchie
const elementParts = element.name.split('_');
if (elementParts.length >= 2) {
const baseLevel = elementParts.slice(0, 2).join('_'); // Ex: "Titre_H3"
allElements.forEach(otherElement => {
if (otherElement.name.startsWith(baseLevel) &&
otherElement.resolvedContent &&
!otherElement.resolvedContent.includes('non défini')) {
context.nearbyElements.push(otherElement.resolvedContent);
}
});
}
return context;
}
/**
* Appel IA pour générer tous les mots-clés manquants en un seul batch
* @param {Array} missingElements - Éléments manquants
* @param {Object} contextAnalysis - Analyse contextuelle
* @param {Object} csvData - Données CSV avec personnalité
* @returns {Object} Mots-clés générés
*/
async function callOpenAIForMissingKeywords(missingElements, contextAnalysis, csvData) {
const personality = csvData.personality;
let prompt = `Tu es ${personality.nom} (${personality.description}). Style: ${personality.style}
MISSION: GÉNÈRE ${missingElements.length} MOTS-CLÉS/EXPRESSIONS MANQUANTS pour ${contextAnalysis.mainKeyword}
CONTEXTE:
- Sujet: ${contextAnalysis.mainKeyword}
- Entreprise: Autocollant.fr (signalétique)
- Mots-clés existants: ${contextAnalysis.availableKeywords.slice(0, 3).join(', ')}
ÉLÉMENTS MANQUANTS:
`;
missingElements.forEach((missing, index) => {
prompt += `${index + 1}. [${missing.name}] → Mot-clé SEO\n`;
});
prompt += `\nCONSIGNES:
- Thème: ${contextAnalysis.mainKeyword}
- Mots-clés SEO naturels
- Varie les termes
- Évite répétitions
FORMAT:
[${missingElements[0].name}]
mot-clé
[${missingElements[1] ? missingElements[1].name : 'exemple'}]
mot-clé
etc...`;
try {
logSh('Génération mots-clés manquants...', 'DEBUG');
// Utilisation du LLM Manager avec fallback
const response = await callLLM('openai', prompt, {
temperature: 0.7,
maxTokens: 2000
}, personality);
// Parser la réponse
const generatedKeywords = parseMissingKeywordsResponse(response, missingElements);
return generatedKeywords;
} catch (error) {
logSh(`❌ FATAL: Génération mots-clés manquants échouée: ${error}`, 'ERROR');
throw new Error(`FATAL: Génération mots-clés LLM impossible - arrêt du workflow: ${error}`);
}
}
/**
* Parser la réponse IA pour extraire les mots-clés générés
* @param {string} response - Réponse de l'IA
* @param {Array} missingElements - Éléments manquants
* @returns {Object} Mots-clés parsés
*/
function parseMissingKeywordsResponse(response, missingElements) {
const results = {};
const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs;
let match;
while ((match = regex.exec(response)) !== null) {
const elementName = match[1].trim();
const generatedKeyword = match[2].trim();
results[elementName] = generatedKeyword;
logSh(`✓ Mot-clé généré [${elementName}]: "${generatedKeyword}"`, 'DEBUG');
}
// FATAL si parsing partiel
if (Object.keys(results).length < missingElements.length) {
const manquants = missingElements.length - Object.keys(results).length;
logSh(`❌ FATAL: Parsing mots-clés partiel - ${manquants}/${missingElements.length} manquants`, 'ERROR');
throw new Error(`FATAL: Parsing mots-clés incomplet (${manquants}/${missingElements.length} manquants) - arrêt du workflow`);
}
return results;
}
/**
* Mettre à jour les éléments avec les nouveaux mots-clés générés
* @param {Array} elements - Éléments originaux
* @param {Object} generatedKeywords - Nouveaux mots-clés
* @returns {Array} Éléments mis à jour
*/
function updateElementsWithKeywords(elements, generatedKeywords) {
const updatedElements = elements.map(element => {
const newKeyword = generatedKeywords[element.name];
if (newKeyword) {
return {
...element,
resolvedContent: newKeyword
};
}
return element;
});
logSh('Éléments mis à jour avec nouveaux mots-clés', 'INFO');
return updatedElements;
}
// Exports CommonJS
module.exports = {
generateMissingKeywords
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/trace.js │
└────────────────────────────────────────────────────────────────────┘
*/
// lib/trace.js
const { AsyncLocalStorage } = require('node:async_hooks');
const { randomUUID } = require('node:crypto');
const { logSh } = require('./ErrorReporting');
const als = new AsyncLocalStorage();
function now() { return performance.now(); }
function dur(ms) {
if (ms < 1e3) return `${ms.toFixed(1)}ms`;
const s = ms / 1e3;
return s < 60 ? `${s.toFixed(2)}s` : `${(s/60).toFixed(2)}m`;
}
class Span {
constructor({ name, parent = null, attrs = {} }) {
this.id = randomUUID();
this.name = name;
this.parent = parent;
this.children = [];
this.attrs = attrs;
this.start = now();
this.end = null;
this.status = 'ok';
this.error = null;
}
pathNames() {
const names = [];
let cur = this;
while (cur) { names.unshift(cur.name); cur = cur.parent; }
return names.join(' > ');
}
finish() { this.end = now(); }
duration() { return (this.end ?? now()) - this.start; }
}
class Tracer {
constructor() {
this.rootSpans = [];
}
current() { return als.getStore(); }
async startSpan(name, attrs = {}) {
const parent = this.current();
const span = new Span({ name, parent, attrs });
if (parent) parent.children.push(span);
else this.rootSpans.push(span);
// Formater les paramètres pour affichage
const paramsStr = this.formatParams(attrs);
await logSh(`▶ ${name}${paramsStr}`, 'TRACE');
return span;
}
async run(name, fn, attrs = {}) {
const parent = this.current();
const span = await this.startSpan(name, attrs);
return await als.run(span, async () => {
try {
const res = await fn();
span.finish();
const paramsStr = this.formatParams(span.attrs);
await logSh(`✔ ${name}${paramsStr} (${dur(span.duration())})`, 'TRACE');
return res;
} catch (err) {
span.status = 'error';
span.error = { message: err?.message, stack: err?.stack };
span.finish();
const paramsStr = this.formatParams(span.attrs);
await logSh(`✖ ${name}${paramsStr} FAILED (${dur(span.duration())})`, 'ERROR');
await logSh(`Stack trace: ${span.error.message}`, 'ERROR');
if (span.error.stack) {
const stackLines = span.error.stack.split('\n').slice(1, 6); // Première 5 lignes du stack
for (const line of stackLines) {
await logSh(` ${line.trim()}`, 'ERROR');
}
}
throw err;
}
});
}
async event(msg, extra = {}) {
const span = this.current();
const data = { trace: true, evt: 'span.event', ...extra };
if (span) {
data.span = span.id;
data.path = span.pathNames();
data.since_ms = +( (now() - span.start).toFixed(1) );
}
await logSh(`• ${msg}`, 'TRACE');
}
async annotate(fields = {}) {
const span = this.current();
if (span) Object.assign(span.attrs, fields);
await logSh('… annotate', 'TRACE');
}
formatParams(attrs = {}) {
const params = Object.entries(attrs)
.filter(([key, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
// Tronquer les valeurs trop longues
const strValue = String(value);
const truncated = strValue.length > 50 ? strValue.substring(0, 47) + '...' : strValue;
return `${key}=${truncated}`;
});
return params.length > 0 ? `(${params.join(', ')})` : '';
}
printSummary() {
const lines = [];
const draw = (node, depth = 0) => {
const pad = ' '.repeat(depth);
const icon = node.status === 'error' ? '✖' : '✔';
lines.push(`${pad}${icon} ${node.name} (${dur(node.duration())})`);
if (Object.keys(node.attrs ?? {}).length) {
lines.push(`${pad} attrs: ${JSON.stringify(node.attrs)}`);
}
for (const ch of node.children) draw(ch, depth + 1);
if (node.status === 'error' && node.error?.message) {
lines.push(`${pad} error: ${node.error.message}`);
if (node.error.stack) {
const stackLines = String(node.error.stack || '').split('\n').slice(1, 4).map(s => s.trim());
if (stackLines.length) {
lines.push(`${pad} stack:`);
stackLines.forEach(line => {
if (line) lines.push(`${pad} ${line}`);
});
}
}
}
};
for (const r of this.rootSpans) draw(r, 0);
const summary = lines.join('\n');
logSh(`\n—— TRACE SUMMARY ——\n${summary}\n—— END TRACE ——`, 'INFO');
return summary;
}
}
const tracer = new Tracer();
module.exports = {
Span,
Tracer,
tracer
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/generation/InitialGeneration.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// ÉTAPE 1: GÉNÉRATION INITIALE
// Responsabilité: Créer le contenu de base avec Claude uniquement
// LLM: Claude Sonnet (température 0.7)
// ========================================
const { callLLM } = require('../LLMManager');
const { logSh } = require('../ErrorReporting');
const { tracer } = require('../trace');
/**
* MAIN ENTRY POINT - GÉNÉRATION INITIALE
* Input: { content: {}, csvData: {}, context: {} }
* Output: { content: {}, stats: {}, debug: {} }
*/
async function generateInitialContent(input) {
return await tracer.run('InitialGeneration.generateInitialContent()', async () => {
const { hierarchy, csvData, context = {} } = input;
await tracer.annotate({
step: '1/4',
llmProvider: 'claude',
elementsCount: Object.keys(hierarchy).length,
mc0: csvData.mc0
});
const startTime = Date.now();
logSh(`🚀 ÉTAPE 1/4: Génération initiale (Claude)`, 'INFO');
logSh(` 📊 ${Object.keys(hierarchy).length} éléments à générer`, 'INFO');
try {
// Collecter tous les éléments dans l'ordre XML
const allElements = collectElementsInXMLOrder(hierarchy);
// Séparer FAQ pairs et autres éléments
const { faqPairs, otherElements } = separateElementTypes(allElements);
// Générer en chunks pour éviter timeouts
const results = {};
// 1. Générer éléments normaux (titres, textes, intro)
if (otherElements.length > 0) {
const normalResults = await generateNormalElements(otherElements, csvData);
Object.assign(results, normalResults);
}
// 2. Générer paires FAQ si présentes
if (faqPairs.length > 0) {
const faqResults = await generateFAQPairs(faqPairs, csvData);
Object.assign(results, faqResults);
}
const duration = Date.now() - startTime;
const stats = {
processed: Object.keys(results).length,
generated: Object.keys(results).length,
faqPairs: faqPairs.length,
duration
};
logSh(`✅ ÉTAPE 1/4 TERMINÉE: ${stats.generated} éléments générés (${duration}ms)`, 'INFO');
await tracer.event(`Génération initiale terminée`, stats);
return {
content: results,
stats,
debug: {
llmProvider: 'claude',
step: 1,
elementsGenerated: Object.keys(results)
}
};
} catch (error) {
const duration = Date.now() - startTime;
logSh(`❌ ÉTAPE 1/4 ÉCHOUÉE après ${duration}ms: ${error.message}`, 'ERROR');
throw new Error(`InitialGeneration failed: ${error.message}`);
}
}, input);
}
/**
* Générer éléments normaux (titres, textes, intro) en chunks
*/
async function generateNormalElements(elements, csvData) {
logSh(`📝 Génération éléments normaux: ${elements.length} éléments`, 'DEBUG');
const results = {};
const chunks = chunkArray(elements, 4); // Chunks de 4 pour éviter timeouts
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
logSh(` 📦 Chunk ${chunkIndex + 1}/${chunks.length}: ${chunk.length} éléments`, 'DEBUG');
try {
const prompt = createBatchPrompt(chunk, csvData);
const response = await callLLM('claude', prompt, {
temperature: 0.7,
maxTokens: 2000 * chunk.length
}, csvData.personality);
const chunkResults = parseBatchResponse(response, chunk);
Object.assign(results, chunkResults);
logSh(` ✅ Chunk ${chunkIndex + 1}: ${Object.keys(chunkResults).length} éléments générés`, 'DEBUG');
// Délai entre chunks
if (chunkIndex < chunks.length - 1) {
await sleep(1500);
}
} catch (error) {
logSh(` ❌ Chunk ${chunkIndex + 1} échoué: ${error.message}`, 'ERROR');
throw error;
}
}
return results;
}
/**
* Générer paires FAQ cohérentes
*/
async function generateFAQPairs(faqPairs, csvData) {
logSh(`❓ Génération paires FAQ: ${faqPairs.length} paires`, 'DEBUG');
const prompt = createFAQPairsPrompt(faqPairs, csvData);
const response = await callLLM('claude', prompt, {
temperature: 0.8,
maxTokens: 3000
}, csvData.personality);
return parseFAQResponse(response, faqPairs);
}
/**
* Créer prompt batch pour éléments normaux
*/
function createBatchPrompt(elements, csvData) {
const personality = csvData.personality;
let prompt = `=== GÉNÉRATION CONTENU INITIAL ===
Entreprise: Autocollant.fr - signalétique personnalisée
Sujet: ${csvData.mc0}
Rédacteur: ${personality.nom} (${personality.style})
ÉLÉMENTS À GÉNÉRER:
`;
elements.forEach((elementInfo, index) => {
const cleanTag = elementInfo.tag.replace(/\|/g, '');
prompt += `${index + 1}. [${cleanTag}] - ${getElementDescription(elementInfo)}\n`;
});
prompt += `
STYLE ${personality.nom.toUpperCase()}:
- Vocabulaire: ${personality.vocabulairePref}
- Phrases: ${personality.longueurPhrases}
- Niveau: ${personality.niveauTechnique}
CONSIGNES:
- Contenu SEO optimisé pour ${csvData.mc0}
- Style ${personality.style} naturel
- Pas de références techniques dans contenu
- RÉPONSE DIRECTE par le contenu
FORMAT:
[${elements[0].tag.replace(/\|/g, '')}]
Contenu généré...
[${elements[1] ? elements[1].tag.replace(/\|/g, '') : 'element2'}]
Contenu généré...`;
return prompt;
}
/**
* Parser réponse batch
*/
function parseBatchResponse(response, elements) {
const results = {};
const regex = /\[([^\]]+)\]\s*([^[]*?)(?=\n\[|$)/gs;
let match;
const parsedItems = {};
while ((match = regex.exec(response)) !== null) {
const tag = match[1].trim();
const content = cleanGeneratedContent(match[2].trim());
parsedItems[tag] = content;
}
// Mapper aux vrais tags
elements.forEach(element => {
const cleanTag = element.tag.replace(/\|/g, '');
if (parsedItems[cleanTag] && parsedItems[cleanTag].length > 10) {
results[element.tag] = parsedItems[cleanTag];
} else {
results[element.tag] = `Contenu professionnel pour ${element.element.name || cleanTag}`;
logSh(`⚠️ Fallback pour [${cleanTag}]`, 'WARNING');
}
});
return results;
}
/**
* Créer prompt pour paires FAQ
*/
function createFAQPairsPrompt(faqPairs, csvData) {
const personality = csvData.personality;
let prompt = `=== GÉNÉRATION PAIRES FAQ ===
Sujet: ${csvData.mc0}
Rédacteur: ${personality.nom} (${personality.style})
PAIRES À GÉNÉRER:
`;
faqPairs.forEach((pair, index) => {
const qTag = pair.question.tag.replace(/\|/g, '');
const aTag = pair.answer.tag.replace(/\|/g, '');
prompt += `${index + 1}. [${qTag}] + [${aTag}]\n`;
});
prompt += `
CONSIGNES:
- Questions naturelles de clients
- Réponses expertes ${personality.style}
- Couvrir: prix, livraison, personnalisation
FORMAT:
[${faqPairs[0].question.tag.replace(/\|/g, '')}]
Question client naturelle ?
[${faqPairs[0].answer.tag.replace(/\|/g, '')}]
Réponse utile et rassurante.`;
return prompt;
}
/**
* Parser réponse FAQ
*/
function parseFAQResponse(response, faqPairs) {
const results = {};
const regex = /\[([^\]]+)\]\s*([^[]*?)(?=\n\[|$)/gs;
let match;
const parsedItems = {};
while ((match = regex.exec(response)) !== null) {
const tag = match[1].trim();
const content = cleanGeneratedContent(match[2].trim());
parsedItems[tag] = content;
}
// Mapper aux paires FAQ
faqPairs.forEach(pair => {
const qCleanTag = pair.question.tag.replace(/\|/g, '');
const aCleanTag = pair.answer.tag.replace(/\|/g, '');
if (parsedItems[qCleanTag]) results[pair.question.tag] = parsedItems[qCleanTag];
if (parsedItems[aCleanTag]) results[pair.answer.tag] = parsedItems[aCleanTag];
});
return results;
}
// ============= HELPER FUNCTIONS =============
function collectElementsInXMLOrder(hierarchy) {
const allElements = [];
Object.keys(hierarchy).forEach(path => {
const section = hierarchy[path];
if (section.title) {
allElements.push({
tag: section.title.originalElement.originalTag,
element: section.title.originalElement,
type: section.title.originalElement.type
});
}
if (section.text) {
allElements.push({
tag: section.text.originalElement.originalTag,
element: section.text.originalElement,
type: section.text.originalElement.type
});
}
section.questions.forEach(q => {
allElements.push({
tag: q.originalElement.originalTag,
element: q.originalElement,
type: q.originalElement.type
});
});
});
return allElements;
}
function separateElementTypes(allElements) {
const faqPairs = [];
const otherElements = [];
const faqQuestions = {};
const faqAnswers = {};
// Collecter FAQ questions et answers
allElements.forEach(element => {
if (element.type === 'faq_question') {
const numberMatch = element.tag.match(/(\d+)/);
const faqNumber = numberMatch ? numberMatch[1] : '1';
faqQuestions[faqNumber] = element;
} else if (element.type === 'faq_reponse') {
const numberMatch = element.tag.match(/(\d+)/);
const faqNumber = numberMatch ? numberMatch[1] : '1';
faqAnswers[faqNumber] = element;
} else {
otherElements.push(element);
}
});
// Créer paires FAQ
Object.keys(faqQuestions).forEach(number => {
const question = faqQuestions[number];
const answer = faqAnswers[number];
if (question && answer) {
faqPairs.push({ number, question, answer });
} else if (question) {
otherElements.push(question);
} else if (answer) {
otherElements.push(answer);
}
});
return { faqPairs, otherElements };
}
function getElementDescription(elementInfo) {
switch (elementInfo.type) {
case 'titre_h1': return 'Titre principal accrocheur';
case 'titre_h2': return 'Titre de section';
case 'titre_h3': return 'Sous-titre';
case 'intro': return 'Introduction engageante';
case 'texte': return 'Paragraphe informatif';
default: return 'Contenu pertinent';
}
}
function cleanGeneratedContent(content) {
if (!content) return content;
// Supprimer préfixes indésirables
content = content.replace(/^(Bon,?\s*)?(alors,?\s*)?Titre_[HU]\d+_\d+[.,\s]*/gi, '');
content = content.replace(/\*\*[^*]+\*\*/g, '');
content = content.replace(/\s{2,}/g, ' ');
content = content.trim();
return content;
}
function chunkArray(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = {
generateInitialContent, // ← MAIN ENTRY POINT
generateNormalElements,
generateFAQPairs,
createBatchPrompt,
parseBatchResponse,
collectElementsInXMLOrder,
separateElementTypes
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/generation/TechnicalEnhancement.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// ÉTAPE 2: ENHANCEMENT TECHNIQUE
// Responsabilité: Améliorer la précision technique avec GPT-4
// LLM: GPT-4o-mini (température 0.4)
// ========================================
const { callLLM } = require('../LLMManager');
const { logSh } = require('../ErrorReporting');
const { tracer } = require('../trace');
/**
* MAIN ENTRY POINT - ENHANCEMENT TECHNIQUE
* Input: { content: {}, csvData: {}, context: {} }
* Output: { content: {}, stats: {}, debug: {} }
*/
async function enhanceTechnicalTerms(input) {
return await tracer.run('TechnicalEnhancement.enhanceTechnicalTerms()', async () => {
const { content, csvData, context = {} } = input;
await tracer.annotate({
step: '2/4',
llmProvider: 'gpt4',
elementsCount: Object.keys(content).length,
mc0: csvData.mc0
});
const startTime = Date.now();
logSh(`🔧 ÉTAPE 2/4: Enhancement technique (GPT-4)`, 'INFO');
logSh(` 📊 ${Object.keys(content).length} éléments à analyser`, 'INFO');
try {
// 1. Analyser tous les éléments pour détecter termes techniques
const technicalAnalysis = await analyzeTechnicalTerms(content, csvData);
// 2. Filter les éléments qui ont besoin d'enhancement
const elementsNeedingEnhancement = technicalAnalysis.filter(item => item.needsEnhancement);
logSh(` 📋 Analyse: ${elementsNeedingEnhancement.length}/${Object.keys(content).length} éléments nécessitent enhancement`, 'INFO');
if (elementsNeedingEnhancement.length === 0) {
logSh(`✅ ÉTAPE 2/4: Aucun enhancement nécessaire`, 'INFO');
return {
content,
stats: { processed: Object.keys(content).length, enhanced: 0, duration: Date.now() - startTime },
debug: { llmProvider: 'gpt4', step: 2, enhancementsApplied: [] }
};
}
// 3. Améliorer les éléments sélectionnés
const enhancedResults = await enhanceSelectedElements(elementsNeedingEnhancement, csvData);
// 4. Merger avec contenu original
const finalContent = { ...content };
let actuallyEnhanced = 0;
Object.keys(enhancedResults).forEach(tag => {
if (enhancedResults[tag] !== content[tag]) {
finalContent[tag] = enhancedResults[tag];
actuallyEnhanced++;
}
});
const duration = Date.now() - startTime;
const stats = {
processed: Object.keys(content).length,
enhanced: actuallyEnhanced,
candidate: elementsNeedingEnhancement.length,
duration
};
logSh(`✅ ÉTAPE 2/4 TERMINÉE: ${stats.enhanced} éléments améliorés (${duration}ms)`, 'INFO');
await tracer.event(`Enhancement technique terminé`, stats);
return {
content: finalContent,
stats,
debug: {
llmProvider: 'gpt4',
step: 2,
enhancementsApplied: Object.keys(enhancedResults),
technicalTermsFound: elementsNeedingEnhancement.map(e => e.technicalTerms)
}
};
} catch (error) {
const duration = Date.now() - startTime;
logSh(`❌ ÉTAPE 2/4 ÉCHOUÉE après ${duration}ms: ${error.message}`, 'ERROR');
throw new Error(`TechnicalEnhancement failed: ${error.message}`);
}
}, input);
}
/**
* Analyser tous les éléments pour détecter termes techniques
*/
async function analyzeTechnicalTerms(content, csvData) {
logSh(`🔍 Analyse termes techniques batch`, 'DEBUG');
const contentEntries = Object.keys(content);
const analysisPrompt = `MISSION: Analyser ces ${contentEntries.length} contenus et identifier leurs termes techniques.
CONTEXTE: ${csvData.mc0} - Secteur: signalétique/impression
CONTENUS À ANALYSER:
${contentEntries.map((tag, i) => `[${i + 1}] TAG: ${tag}
CONTENU: "${content[tag]}"`).join('\n\n')}
CONSIGNES:
- Identifie UNIQUEMENT les vrais termes techniques métier/industrie
- Évite mots génériques (qualité, service, pratique, personnalisé)
- Focus: matériaux, procédés, normes, dimensions, technologies
- Si aucun terme technique → "AUCUN"
EXEMPLES VALIDES: dibond, impression UV, fraisage CNC, épaisseur 3mm
EXEMPLES INVALIDES: durable, pratique, personnalisé, moderne
FORMAT RÉPONSE:
[1] dibond, impression UV OU AUCUN
[2] AUCUN
[3] aluminium, fraisage CNC OU AUCUN
etc...`;
try {
const analysisResponse = await callLLM('gpt4', analysisPrompt, {
temperature: 0.3,
maxTokens: 2000
}, csvData.personality);
return parseAnalysisResponse(analysisResponse, content, contentEntries);
} catch (error) {
logSh(`❌ Analyse termes techniques échouée: ${error.message}`, 'ERROR');
throw error;
}
}
/**
* Améliorer les éléments sélectionnés
*/
async function enhanceSelectedElements(elementsNeedingEnhancement, csvData) {
logSh(`🛠️ Enhancement ${elementsNeedingEnhancement.length} éléments`, 'DEBUG');
const enhancementPrompt = `MISSION: Améliore UNIQUEMENT la précision technique de ces contenus.
CONTEXTE: ${csvData.mc0} - Secteur signalétique/impression
PERSONNALITÉ: ${csvData.personality?.nom} (${csvData.personality?.style})
CONTENUS À AMÉLIORER:
${elementsNeedingEnhancement.map((item, i) => `[${i + 1}] TAG: ${item.tag}
CONTENU: "${item.content}"
TERMES TECHNIQUES: ${item.technicalTerms.join(', ')}`).join('\n\n')}
CONSIGNES:
- GARDE même longueur, structure et ton ${csvData.personality?.style}
- Intègre naturellement les termes techniques listés
- NE CHANGE PAS le fond du message
- Vocabulaire expert mais accessible
- Termes secteur: dibond, aluminium, impression UV, fraisage, PMMA
FORMAT RÉPONSE:
[1] Contenu avec amélioration technique
[2] Contenu avec amélioration technique
etc...`;
try {
const enhancedResponse = await callLLM('gpt4', enhancementPrompt, {
temperature: 0.4,
maxTokens: 5000
}, csvData.personality);
return parseEnhancementResponse(enhancedResponse, elementsNeedingEnhancement);
} catch (error) {
logSh(`❌ Enhancement éléments échoué: ${error.message}`, 'ERROR');
throw error;
}
}
/**
* Parser réponse analyse
*/
function parseAnalysisResponse(response, content, contentEntries) {
const results = [];
const regex = /\[(\d+)\]\s*([^[]*?)(?=\[\d+\]|$)/gs;
let match;
const parsedItems = {};
while ((match = regex.exec(response)) !== null) {
const index = parseInt(match[1]) - 1;
const termsText = match[2].trim();
parsedItems[index] = termsText;
}
contentEntries.forEach((tag, index) => {
const termsText = parsedItems[index] || 'AUCUN';
const hasTerms = !termsText.toUpperCase().includes('AUCUN');
const technicalTerms = hasTerms ?
termsText.split(',').map(t => t.trim()).filter(t => t.length > 0) :
[];
results.push({
tag,
content: content[tag],
technicalTerms,
needsEnhancement: hasTerms && technicalTerms.length > 0
});
logSh(`🔍 [${tag}]: ${hasTerms ? technicalTerms.join(', ') : 'aucun terme technique'}`, 'DEBUG');
});
return results;
}
/**
* Parser réponse enhancement
*/
function parseEnhancementResponse(response, elementsNeedingEnhancement) {
const results = {};
const regex = /\[(\d+)\]\s*([^[]*?)(?=\[\d+\]|$)/gs;
let match;
let index = 0;
while ((match = regex.exec(response)) && index < elementsNeedingEnhancement.length) {
let enhancedContent = match[2].trim();
const element = elementsNeedingEnhancement[index];
// Nettoyer le contenu généré
enhancedContent = cleanEnhancedContent(enhancedContent);
if (enhancedContent && enhancedContent.length > 10) {
results[element.tag] = enhancedContent;
logSh(`✅ Enhanced [${element.tag}]: "${enhancedContent.substring(0, 100)}..."`, 'DEBUG');
} else {
results[element.tag] = element.content;
logSh(`⚠️ Fallback [${element.tag}]: contenu invalide`, 'WARNING');
}
index++;
}
// Compléter les manquants
while (index < elementsNeedingEnhancement.length) {
const element = elementsNeedingEnhancement[index];
results[element.tag] = element.content;
index++;
}
return results;
}
/**
* Nettoyer contenu amélioré
*/
function cleanEnhancedContent(content) {
if (!content) return content;
// Supprimer préfixes indésirables
content = content.replace(/^(Bon,?\s*)?(alors,?\s*)?pour\s+/gi, '');
content = content.replace(/\*\*[^*]+\*\*/g, '');
content = content.replace(/\s{2,}/g, ' ');
content = content.trim();
return content;
}
module.exports = {
enhanceTechnicalTerms, // ← MAIN ENTRY POINT
analyzeTechnicalTerms,
enhanceSelectedElements,
parseAnalysisResponse,
parseEnhancementResponse
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/generation/TransitionEnhancement.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// ÉTAPE 3: ENHANCEMENT TRANSITIONS
// Responsabilité: Améliorer la fluidité avec Gemini
// LLM: Gemini (température 0.6)
// ========================================
const { callLLM } = require('../LLMManager');
const { logSh } = require('../ErrorReporting');
const { tracer } = require('../trace');
/**
* MAIN ENTRY POINT - ENHANCEMENT TRANSITIONS
* Input: { content: {}, csvData: {}, context: {} }
* Output: { content: {}, stats: {}, debug: {} }
*/
async function enhanceTransitions(input) {
return await tracer.run('TransitionEnhancement.enhanceTransitions()', async () => {
const { content, csvData, context = {} } = input;
await tracer.annotate({
step: '3/4',
llmProvider: 'gemini',
elementsCount: Object.keys(content).length,
mc0: csvData.mc0
});
const startTime = Date.now();
logSh(`🔗 ÉTAPE 3/4: Enhancement transitions (Gemini)`, 'INFO');
logSh(` 📊 ${Object.keys(content).length} éléments à analyser`, 'INFO');
try {
// 1. Analyser quels éléments ont besoin d'amélioration transitions
const elementsNeedingTransitions = analyzeTransitionNeeds(content);
logSh(` 📋 Analyse: ${elementsNeedingTransitions.length}/${Object.keys(content).length} éléments nécessitent fluidité`, 'INFO');
if (elementsNeedingTransitions.length === 0) {
logSh(`✅ ÉTAPE 3/4: Transitions déjà optimales`, 'INFO');
return {
content,
stats: { processed: Object.keys(content).length, enhanced: 0, duration: Date.now() - startTime },
debug: { llmProvider: 'gemini', step: 3, enhancementsApplied: [] }
};
}
// 2. Améliorer en chunks pour Gemini
const improvedResults = await improveTransitionsInChunks(elementsNeedingTransitions, csvData);
// 3. Merger avec contenu original
const finalContent = { ...content };
let actuallyImproved = 0;
Object.keys(improvedResults).forEach(tag => {
if (improvedResults[tag] !== content[tag]) {
finalContent[tag] = improvedResults[tag];
actuallyImproved++;
}
});
const duration = Date.now() - startTime;
const stats = {
processed: Object.keys(content).length,
enhanced: actuallyImproved,
candidate: elementsNeedingTransitions.length,
duration
};
logSh(`✅ ÉTAPE 3/4 TERMINÉE: ${stats.enhanced} éléments fluidifiés (${duration}ms)`, 'INFO');
await tracer.event(`Enhancement transitions terminé`, stats);
return {
content: finalContent,
stats,
debug: {
llmProvider: 'gemini',
step: 3,
enhancementsApplied: Object.keys(improvedResults),
transitionIssues: elementsNeedingTransitions.map(e => e.issues)
}
};
} catch (error) {
const duration = Date.now() - startTime;
logSh(`❌ ÉTAPE 3/4 ÉCHOUÉE après ${duration}ms: ${error.message}`, 'ERROR');
// Fallback: retourner contenu original si Gemini indisponible
logSh(`🔄 Fallback: contenu original conservé`, 'WARNING');
return {
content,
stats: { processed: Object.keys(content).length, enhanced: 0, duration },
debug: { llmProvider: 'gemini', step: 3, error: error.message, fallback: true }
};
}
}, input);
}
/**
* Analyser besoin d'amélioration transitions
*/
function analyzeTransitionNeeds(content) {
const elementsNeedingTransitions = [];
Object.keys(content).forEach(tag => {
const text = content[tag];
// Filtrer les éléments longs (>150 chars) qui peuvent bénéficier d'améliorations
if (text.length > 150) {
const needsTransitions = evaluateTransitionQuality(text);
if (needsTransitions.needsImprovement) {
elementsNeedingTransitions.push({
tag,
content: text,
issues: needsTransitions.issues,
score: needsTransitions.score
});
logSh(` 🔍 [${tag}]: Score=${needsTransitions.score.toFixed(2)}, Issues: ${needsTransitions.issues.join(', ')}`, 'DEBUG');
}
} else {
logSh(` ⏭️ [${tag}]: Trop court (${text.length}c), ignoré`, 'DEBUG');
}
});
// Trier par score (plus problématique en premier)
elementsNeedingTransitions.sort((a, b) => a.score - b.score);
return elementsNeedingTransitions;
}
/**
* Évaluer qualité transitions d'un texte
*/
function evaluateTransitionQuality(text) {
const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 10);
if (sentences.length < 2) {
return { needsImprovement: false, score: 1.0, issues: [] };
}
const issues = [];
let score = 1.0; // Score parfait = 1.0, problématique = 0.0
// Analyse 1: Connecteurs répétitifs
const repetitiveConnectors = analyzeRepetitiveConnectors(text);
if (repetitiveConnectors > 0.3) {
issues.push('connecteurs_répétitifs');
score -= 0.3;
}
// Analyse 2: Transitions abruptes
const abruptTransitions = analyzeAbruptTransitions(sentences);
if (abruptTransitions > 0.4) {
issues.push('transitions_abruptes');
score -= 0.4;
}
// Analyse 3: Manque de variété dans longueurs
const sentenceVariety = analyzeSentenceVariety(sentences);
if (sentenceVariety < 0.3) {
issues.push('phrases_uniformes');
score -= 0.2;
}
// Analyse 4: Trop formel ou trop familier
const formalityIssues = analyzeFormalityBalance(text);
if (formalityIssues > 0.5) {
issues.push('formalité_déséquilibrée');
score -= 0.1;
}
return {
needsImprovement: score < 0.6,
score: Math.max(0, score),
issues
};
}
/**
* Améliorer transitions en chunks
*/
async function improveTransitionsInChunks(elementsNeedingTransitions, csvData) {
logSh(`🔄 Amélioration transitions: ${elementsNeedingTransitions.length} éléments`, 'DEBUG');
const results = {};
const chunks = chunkArray(elementsNeedingTransitions, 6); // Chunks plus petits pour Gemini
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
try {
logSh(` 📦 Chunk ${chunkIndex + 1}/${chunks.length}: ${chunk.length} éléments`, 'DEBUG');
const improvementPrompt = createTransitionImprovementPrompt(chunk, csvData);
const improvedResponse = await callLLM('gemini', improvementPrompt, {
temperature: 0.6,
maxTokens: 2500
}, csvData.personality);
const chunkResults = parseTransitionResponse(improvedResponse, chunk);
Object.assign(results, chunkResults);
logSh(` ✅ Chunk ${chunkIndex + 1}: ${Object.keys(chunkResults).length} améliorés`, 'DEBUG');
// Délai entre chunks
if (chunkIndex < chunks.length - 1) {
await sleep(1500);
}
} catch (error) {
logSh(` ❌ Chunk ${chunkIndex + 1} échoué: ${error.message}`, 'ERROR');
// Fallback: garder contenu original pour ce chunk
chunk.forEach(element => {
results[element.tag] = element.content;
});
}
}
return results;
}
/**
* Créer prompt amélioration transitions
*/
function createTransitionImprovementPrompt(chunk, csvData) {
const personality = csvData.personality;
let prompt = `MISSION: Améliore UNIQUEMENT les transitions et fluidité de ces contenus.
CONTEXTE: Article SEO ${csvData.mc0}
PERSONNALITÉ: ${personality?.nom} (${personality?.style} web professionnel)
CONNECTEURS PRÉFÉRÉS: ${personality?.connecteursPref}
CONTENUS À FLUIDIFIER:
${chunk.map((item, i) => `[${i + 1}] TAG: ${item.tag}
PROBLÈMES: ${item.issues.join(', ')}
CONTENU: "${item.content}"`).join('\n\n')}
OBJECTIFS:
- Connecteurs plus naturels et variés: ${personality?.connecteursPref}
- Transitions fluides entre idées
- ÉVITE répétitions excessives ("du coup", "franchement", "par ailleurs")
- Style ${personality?.style} mais professionnel web
CONSIGNES STRICTES:
- NE CHANGE PAS le fond du message
- GARDE même structure et longueur
- Améliore SEULEMENT la fluidité
- RESPECTE le style ${personality?.nom}
FORMAT RÉPONSE:
[1] Contenu avec transitions améliorées
[2] Contenu avec transitions améliorées
etc...`;
return prompt;
}
/**
* Parser réponse amélioration transitions
*/
function parseTransitionResponse(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 improvedContent = match[2].trim();
const element = chunk[index];
// Nettoyer le contenu amélioré
improvedContent = cleanImprovedContent(improvedContent);
if (improvedContent && improvedContent.length > 10) {
results[element.tag] = improvedContent;
logSh(`✅ Improved [${element.tag}]: "${improvedContent.substring(0, 100)}..."`, 'DEBUG');
} else {
results[element.tag] = element.content;
logSh(`⚠️ Fallback [${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;
}
// ============= HELPER FUNCTIONS =============
function analyzeRepetitiveConnectors(content) {
const connectors = ['par ailleurs', 'en effet', 'de plus', 'cependant', 'ainsi', 'donc'];
let totalConnectors = 0;
let repetitions = 0;
connectors.forEach(connector => {
const matches = (content.match(new RegExp(`\\b${connector}\\b`, 'gi')) || []);
totalConnectors += matches.length;
if (matches.length > 1) repetitions += matches.length - 1;
});
return totalConnectors > 0 ? repetitions / totalConnectors : 0;
}
function analyzeAbruptTransitions(sentences) {
if (sentences.length < 2) return 0;
let abruptCount = 0;
for (let i = 1; i < sentences.length; i++) {
const current = sentences[i].trim();
const hasConnector = hasTransitionWord(current);
if (!hasConnector && current.length > 30) {
abruptCount++;
}
}
return abruptCount / (sentences.length - 1);
}
function analyzeSentenceVariety(sentences) {
if (sentences.length < 2) return 1;
const lengths = sentences.map(s => s.trim().length);
const avgLength = lengths.reduce((a, b) => a + b, 0) / lengths.length;
const variance = lengths.reduce((acc, len) => acc + Math.pow(len - avgLength, 2), 0) / lengths.length;
const stdDev = Math.sqrt(variance);
return Math.min(1, stdDev / avgLength);
}
function analyzeFormalityBalance(content) {
const formalIndicators = ['il convient de', 'par conséquent', 'néanmoins', 'toutefois'];
const casualIndicators = ['du coup', 'bon', 'franchement', 'nickel'];
let formalCount = 0;
let casualCount = 0;
formalIndicators.forEach(indicator => {
if (content.toLowerCase().includes(indicator)) formalCount++;
});
casualIndicators.forEach(indicator => {
if (content.toLowerCase().includes(indicator)) casualCount++;
});
const total = formalCount + casualCount;
if (total === 0) return 0;
// Déséquilibre si trop d'un côté
const balance = Math.abs(formalCount - casualCount) / total;
return balance;
}
function hasTransitionWord(sentence) {
const connectors = ['par ailleurs', 'en effet', 'de plus', 'cependant', 'ainsi', 'donc', 'ensuite', 'puis', 'également', 'aussi'];
return connectors.some(connector => sentence.toLowerCase().includes(connector));
}
function cleanImprovedContent(content) {
if (!content) return content;
content = content.replace(/^(Bon,?\s*)?(alors,?\s*)?/, '');
content = content.replace(/\s{2,}/g, ' ');
content = content.trim();
return content;
}
function chunkArray(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = {
enhanceTransitions, // ← MAIN ENTRY POINT
analyzeTransitionNeeds,
evaluateTransitionQuality,
improveTransitionsInChunks,
createTransitionImprovementPrompt,
parseTransitionResponse
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/generation/StyleEnhancement.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// ÉTAPE 4: ENHANCEMENT STYLE PERSONNALITÉ
// Responsabilité: Appliquer le style personnalité avec Mistral
// LLM: Mistral (température 0.8)
// ========================================
const { callLLM } = require('../LLMManager');
const { logSh } = require('../ErrorReporting');
const { tracer } = require('../trace');
/**
* MAIN ENTRY POINT - ENHANCEMENT STYLE
* Input: { content: {}, csvData: {}, context: {} }
* Output: { content: {}, stats: {}, debug: {} }
*/
async function applyPersonalityStyle(input) {
return await tracer.run('StyleEnhancement.applyPersonalityStyle()', async () => {
const { content, csvData, context = {} } = input;
await tracer.annotate({
step: '4/4',
llmProvider: 'mistral',
elementsCount: Object.keys(content).length,
personality: csvData.personality?.nom,
mc0: csvData.mc0
});
const startTime = Date.now();
logSh(`🎭 ÉTAPE 4/4: Enhancement style ${csvData.personality?.nom} (Mistral)`, 'INFO');
logSh(` 📊 ${Object.keys(content).length} éléments à styliser`, 'INFO');
try {
const personality = csvData.personality;
if (!personality) {
logSh(`⚠️ ÉTAPE 4/4: Aucune personnalité définie, style standard`, 'WARNING');
return {
content,
stats: { processed: Object.keys(content).length, enhanced: 0, duration: Date.now() - startTime },
debug: { llmProvider: 'mistral', step: 4, personalityApplied: 'none' }
};
}
// 1. Préparer éléments pour stylisation
const styleElements = prepareElementsForStyling(content);
// 2. Appliquer style en chunks
const styledResults = await applyStyleInChunks(styleElements, csvData);
// 3. Merger résultats
const finalContent = { ...content };
let actuallyStyled = 0;
Object.keys(styledResults).forEach(tag => {
if (styledResults[tag] !== content[tag]) {
finalContent[tag] = styledResults[tag];
actuallyStyled++;
}
});
const duration = Date.now() - startTime;
const stats = {
processed: Object.keys(content).length,
enhanced: actuallyStyled,
personality: personality.nom,
duration
};
logSh(`✅ ÉTAPE 4/4 TERMINÉE: ${stats.enhanced} éléments stylisés ${personality.nom} (${duration}ms)`, 'INFO');
await tracer.event(`Enhancement style terminé`, stats);
return {
content: finalContent,
stats,
debug: {
llmProvider: 'mistral',
step: 4,
personalityApplied: personality.nom,
styleCharacteristics: {
vocabulaire: personality.vocabulairePref,
connecteurs: personality.connecteursPref,
style: personality.style
}
}
};
} catch (error) {
const duration = Date.now() - startTime;
logSh(`❌ ÉTAPE 4/4 ÉCHOUÉE après ${duration}ms: ${error.message}`, 'ERROR');
// Fallback: retourner contenu original si Mistral indisponible
logSh(`🔄 Fallback: contenu original conservé`, 'WARNING');
return {
content,
stats: { processed: Object.keys(content).length, enhanced: 0, duration },
debug: { llmProvider: 'mistral', step: 4, error: error.message, fallback: true }
};
}
}, input);
}
/**
* Préparer éléments pour stylisation
*/
function prepareElementsForStyling(content) {
const styleElements = [];
Object.keys(content).forEach(tag => {
const text = content[tag];
// Tous les éléments peuvent bénéficier d'adaptation personnalité
// Même les courts (titres) peuvent être adaptés au style
styleElements.push({
tag,
content: text,
priority: calculateStylePriority(text, tag)
});
});
// Trier par priorité (titres d'abord, puis textes longs)
styleElements.sort((a, b) => b.priority - a.priority);
return styleElements;
}
/**
* Calculer priorité de stylisation
*/
function calculateStylePriority(text, tag) {
let priority = 1.0;
// Titres = haute priorité (plus visible)
if (tag.includes('Titre') || tag.includes('H1') || tag.includes('H2')) {
priority += 0.5;
}
// Textes longs = priorité selon longueur
if (text.length > 200) {
priority += 0.3;
} else if (text.length > 100) {
priority += 0.2;
}
// Introduction = haute priorité
if (tag.includes('intro') || tag.includes('Introduction')) {
priority += 0.4;
}
return priority;
}
/**
* Appliquer style en chunks
*/
async function applyStyleInChunks(styleElements, csvData) {
logSh(`🎨 Stylisation: ${styleElements.length} éléments selon ${csvData.personality.nom}`, 'DEBUG');
const results = {};
const chunks = chunkArray(styleElements, 8); // Chunks de 8 pour Mistral
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
try {
logSh(` 📦 Chunk ${chunkIndex + 1}/${chunks.length}: ${chunk.length} éléments`, 'DEBUG');
const stylePrompt = createStylePrompt(chunk, csvData);
const styledResponse = await callLLM('mistral', stylePrompt, {
temperature: 0.8,
maxTokens: 3000
}, csvData.personality);
const chunkResults = parseStyleResponse(styledResponse, chunk);
Object.assign(results, chunkResults);
logSh(` ✅ Chunk ${chunkIndex + 1}: ${Object.keys(chunkResults).length} stylisés`, 'DEBUG');
// Délai entre chunks
if (chunkIndex < chunks.length - 1) {
await sleep(1500);
}
} catch (error) {
logSh(` ❌ Chunk ${chunkIndex + 1} échoué: ${error.message}`, 'ERROR');
// Fallback: garder contenu original
chunk.forEach(element => {
results[element.tag] = element.content;
});
}
}
return results;
}
/**
* Créer prompt de stylisation
*/
function createStylePrompt(chunk, csvData) {
const personality = csvData.personality;
let prompt = `MISSION: Adapte UNIQUEMENT le style de ces contenus selon ${personality.nom}.
CONTEXTE: Article SEO e-commerce ${csvData.mc0}
PERSONNALITÉ: ${personality.nom}
DESCRIPTION: ${personality.description}
STYLE: ${personality.style} adapté web professionnel
VOCABULAIRE: ${personality.vocabulairePref}
CONNECTEURS: ${personality.connecteursPref}
NIVEAU TECHNIQUE: ${personality.niveauTechnique}
LONGUEUR PHRASES: ${personality.longueurPhrases}
CONTENUS À STYLISER:
${chunk.map((item, i) => `[${i + 1}] TAG: ${item.tag} (Priorité: ${item.priority.toFixed(1)})
CONTENU: "${item.content}"`).join('\n\n')}
OBJECTIFS STYLISATION ${personality.nom.toUpperCase()}:
- Adapte le TON selon ${personality.style}
- Vocabulaire: ${personality.vocabulairePref}
- Connecteurs variés: ${personality.connecteursPref}
- Phrases: ${personality.longueurPhrases}
- Niveau: ${personality.niveauTechnique}
CONSIGNES STRICTES:
- GARDE le même contenu informatif et technique
- Adapte SEULEMENT ton, expressions, vocabulaire selon ${personality.nom}
- RESPECTE longueur approximative (±20%)
- ÉVITE répétitions excessives
- Style ${personality.nom} reconnaissable mais NATUREL web
- PAS de messages d'excuse
FORMAT RÉPONSE:
[1] Contenu stylisé selon ${personality.nom}
[2] Contenu stylisé selon ${personality.nom}
etc...`;
return prompt;
}
/**
* Parser réponse stylisation
*/
function 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 le contenu stylisé
styledContent = cleanStyledContent(styledContent);
if (styledContent && styledContent.length > 10) {
results[element.tag] = styledContent;
logSh(`✅ Styled [${element.tag}]: "${styledContent.substring(0, 100)}..."`, 'DEBUG');
} else {
results[element.tag] = element.content;
logSh(`⚠️ Fallback [${element.tag}]: stylisation 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 stylisé
*/
function cleanStyledContent(content) {
if (!content) return content;
// Supprimer préfixes indésirables
content = content.replace(/^(Bon,?\s*)?(alors,?\s*)?voici\s+/gi, '');
content = content.replace(/^pour\s+ce\s+contenu[,\s]*/gi, '');
content = content.replace(/\*\*[^*]+\*\*/g, '');
// Réduire répétitions excessives mais garder le style personnalité
content = content.replace(/(du coup[,\s]+){4,}/gi, 'du coup ');
content = content.replace(/(bon[,\s]+){4,}/gi, 'bon ');
content = content.replace(/(franchement[,\s]+){3,}/gi, 'franchement ');
content = content.replace(/\s{2,}/g, ' ');
content = content.trim();
return content;
}
/**
* Obtenir instructions de style dynamiques
*/
function getPersonalityStyleInstructions(personality) {
if (!personality) return "Style professionnel standard";
return `STYLE ${personality.nom.toUpperCase()} (${personality.style}):
- Description: ${personality.description}
- Vocabulaire: ${personality.vocabulairePref || 'professionnel'}
- Connecteurs: ${personality.connecteursPref || 'par ailleurs, en effet'}
- Mots-clés: ${personality.motsClesSecteurs || 'technique, qualité'}
- Phrases: ${personality.longueurPhrases || 'Moyennes'}
- Niveau: ${personality.niveauTechnique || 'Accessible'}
- CTA: ${personality.ctaStyle || 'Professionnel'}`;
}
// ============= HELPER FUNCTIONS =============
function chunkArray(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = {
applyPersonalityStyle, // ← MAIN ENTRY POINT
prepareElementsForStyling,
calculateStylePriority,
applyStyleInChunks,
createStylePrompt,
parseStyleResponse,
getPersonalityStyleInstructions
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/post-processing/SentenceVariation.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// PATTERN BREAKING - TECHNIQUE 1: SENTENCE VARIATION
// Responsabilité: Varier les longueurs de phrases pour casser l'uniformité
// Anti-détection: Éviter patterns syntaxiques réguliers des LLMs
// ========================================
const { logSh } = require('../ErrorReporting');
const { tracer } = require('../trace');
/**
* MAIN ENTRY POINT - VARIATION LONGUEUR PHRASES
* @param {Object} input - { content: {}, config: {}, context: {} }
* @returns {Object} - { content: {}, stats: {}, debug: {} }
*/
async function applySentenceVariation(input) {
return await tracer.run('SentenceVariation.applySentenceVariation()', async () => {
const { content, config = {}, context = {} } = input;
const {
intensity = 0.3, // Probabilité de modification (30%)
splitThreshold = 100, // Chars pour split
mergeThreshold = 30, // Chars pour merge
preserveQuestions = true, // Préserver questions FAQ
preserveTitles = true // Préserver titres
} = config;
await tracer.annotate({
technique: 'sentence_variation',
intensity,
elementsCount: Object.keys(content).length
});
const startTime = Date.now();
logSh(`📐 TECHNIQUE 1/3: Variation longueur phrases (intensité: ${intensity})`, 'INFO');
logSh(` 📊 ${Object.keys(content).length} éléments à analyser`, 'DEBUG');
try {
const results = {};
let totalProcessed = 0;
let totalModified = 0;
let modificationsDetails = [];
// Traiter chaque élément de contenu
for (const [tag, text] of Object.entries(content)) {
totalProcessed++;
// Skip certains éléments selon config
if (shouldSkipElement(tag, text, { preserveQuestions, preserveTitles })) {
results[tag] = text;
logSh(` ⏭️ [${tag}]: Préservé (${getSkipReason(tag, text)})`, 'DEBUG');
continue;
}
// Appliquer variation si éligible
const variationResult = varyTextStructure(text, {
intensity,
splitThreshold,
mergeThreshold,
tag
});
results[tag] = variationResult.text;
if (variationResult.modified) {
totalModified++;
modificationsDetails.push({
tag,
modifications: variationResult.modifications,
originalLength: text.length,
newLength: variationResult.text.length
});
logSh(` ✏️ [${tag}]: ${variationResult.modifications.length} modifications`, 'DEBUG');
} else {
logSh(` ➡️ [${tag}]: Aucune modification`, 'DEBUG');
}
}
const duration = Date.now() - startTime;
const stats = {
processed: totalProcessed,
modified: totalModified,
modificationRate: Math.round((totalModified / totalProcessed) * 100),
duration,
technique: 'sentence_variation'
};
logSh(`✅ VARIATION PHRASES: ${stats.modified}/${stats.processed} éléments modifiés (${stats.modificationRate}%) en ${duration}ms`, 'INFO');
await tracer.event('Sentence variation terminée', stats);
return {
content: results,
stats,
debug: {
technique: 'sentence_variation',
config: { intensity, splitThreshold, mergeThreshold },
modifications: modificationsDetails
}
};
} catch (error) {
const duration = Date.now() - startTime;
logSh(`❌ VARIATION PHRASES échouée après ${duration}ms: ${error.message}`, 'ERROR');
throw new Error(`SentenceVariation failed: ${error.message}`);
}
}, input);
}
/**
* Appliquer variation structure à un texte
*/
function varyTextStructure(text, config) {
const { intensity, splitThreshold, mergeThreshold, tag } = config;
if (text.length < 50) {
return { text, modified: false, modifications: [] };
}
// Séparer en phrases
const sentences = splitIntoSentences(text);
if (sentences.length < 2) {
return { text, modified: false, modifications: [] };
}
let modifiedSentences = [...sentences];
const modifications = [];
// TECHNIQUE 1: SPLIT des phrases longues
for (let i = 0; i < modifiedSentences.length; i++) {
const sentence = modifiedSentences[i];
if (sentence.length > splitThreshold && Math.random() < intensity) {
const splitResult = splitLongSentence(sentence);
if (splitResult.success) {
modifiedSentences.splice(i, 1, splitResult.part1, splitResult.part2);
modifications.push({
type: 'split',
original: sentence.substring(0, 50) + '...',
result: `${splitResult.part1.substring(0, 25)}... | ${splitResult.part2.substring(0, 25)}...`
});
i++; // Skip la phrase suivante (qui est notre part2)
}
}
}
// TECHNIQUE 2: MERGE des phrases courtes
for (let i = 0; i < modifiedSentences.length - 1; i++) {
const current = modifiedSentences[i];
const next = modifiedSentences[i + 1];
if (current.length < mergeThreshold && next.length < mergeThreshold && Math.random() < intensity) {
const merged = mergeSentences(current, next);
if (merged.success) {
modifiedSentences.splice(i, 2, merged.result);
modifications.push({
type: 'merge',
original: `${current.substring(0, 20)}... + ${next.substring(0, 20)}...`,
result: merged.result.substring(0, 50) + '...'
});
}
}
}
const finalText = modifiedSentences.join(' ').trim();
return {
text: finalText,
modified: modifications.length > 0,
modifications
};
}
/**
* Diviser texte en phrases
*/
function splitIntoSentences(text) {
// Regex plus sophistiquée pour gérer les abréviations
const sentences = text.split(/(? s.trim())
.filter(s => s.length > 5);
return sentences;
}
/**
* Diviser une phrase longue en deux
*/
function splitLongSentence(sentence) {
// Points de rupture naturels
const breakPoints = [
', et ',
', mais ',
', car ',
', donc ',
', ainsi ',
', alors ',
', tandis que ',
', bien que '
];
// Chercher le meilleur point de rupture proche du milieu
const idealBreak = sentence.length / 2;
let bestBreak = null;
let bestDistance = Infinity;
for (const breakPoint of breakPoints) {
const index = sentence.indexOf(breakPoint, idealBreak - 50);
if (index > 0 && index < sentence.length - 20) {
const distance = Math.abs(index - idealBreak);
if (distance < bestDistance) {
bestDistance = distance;
bestBreak = { index, breakPoint };
}
}
}
if (bestBreak) {
const part1 = sentence.substring(0, bestBreak.index + 1).trim();
const part2 = sentence.substring(bestBreak.index + bestBreak.breakPoint.length).trim();
// Assurer que part2 commence par une majuscule
const capitalizedPart2 = part2.charAt(0).toUpperCase() + part2.slice(1);
return {
success: true,
part1,
part2: capitalizedPart2
};
}
return { success: false };
}
/**
* Fusionner deux phrases courtes
*/
function mergeSentences(sentence1, sentence2) {
// Connecteurs pour fusion naturelle
const connectors = [
'et',
'puis',
'aussi',
'également',
'de plus'
];
// Choisir connecteur aléatoire
const connector = connectors[Math.floor(Math.random() * connectors.length)];
// Nettoyer les phrases
let cleaned1 = sentence1.replace(/[.!?]+$/, '').trim();
let cleaned2 = sentence2.trim();
// Mettre sentence2 en minuscule sauf si nom propre
if (!/^[A-Z][a-z]*\s+[A-Z]/.test(cleaned2)) {
cleaned2 = cleaned2.charAt(0).toLowerCase() + cleaned2.slice(1);
}
const merged = `${cleaned1}, ${connector} ${cleaned2}`;
return {
success: merged.length < 200, // Éviter phrases trop longues
result: merged
};
}
/**
* Déterminer si un élément doit être skippé
*/
function shouldSkipElement(tag, text, config) {
// Skip titres si demandé
if (config.preserveTitles && (tag.includes('Titre') || tag.includes('H1') || tag.includes('H2'))) {
return true;
}
// Skip questions FAQ si demandé
if (config.preserveQuestions && (tag.includes('Faq_q') || text.includes('?'))) {
return true;
}
// Skip textes très courts
if (text.length < 50) {
return true;
}
return false;
}
/**
* Obtenir raison du skip pour debug
*/
function getSkipReason(tag, text) {
if (tag.includes('Titre') || tag.includes('H1') || tag.includes('H2')) return 'titre';
if (tag.includes('Faq_q') || text.includes('?')) return 'question';
if (text.length < 50) return 'trop court';
return 'autre';
}
/**
* Analyser les patterns de phrases d'un texte
*/
function analyzeSentencePatterns(text) {
const sentences = splitIntoSentences(text);
if (sentences.length < 2) {
return { needsVariation: false, patterns: [] };
}
const lengths = sentences.map(s => s.length);
const avgLength = lengths.reduce((a, b) => a + b, 0) / lengths.length;
// Calculer uniformité (variance faible = uniformité élevée)
const variance = lengths.reduce((acc, len) => acc + Math.pow(len - avgLength, 2), 0) / lengths.length;
const uniformity = 1 / (1 + Math.sqrt(variance) / avgLength); // 0-1, 1 = très uniforme
return {
needsVariation: uniformity > 0.7, // Seuil d'uniformité problématique
patterns: {
avgLength: Math.round(avgLength),
uniformity: Math.round(uniformity * 100),
sentenceCount: sentences.length,
variance: Math.round(variance)
}
};
}
module.exports = {
applySentenceVariation, // ← MAIN ENTRY POINT
varyTextStructure,
splitIntoSentences,
splitLongSentence,
mergeSentences,
analyzeSentencePatterns
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/post-processing/LLMFingerprintRemoval.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// PATTERN BREAKING - TECHNIQUE 2: LLM FINGERPRINT REMOVAL
// Responsabilité: Remplacer mots/expressions typiques des LLMs
// Anti-détection: Éviter vocabulaire détectable par les analyseurs IA
// ========================================
const { logSh } = require('../ErrorReporting');
const { tracer } = require('../trace');
/**
* DICTIONNAIRE ANTI-DÉTECTION
* Mots/expressions LLM → Alternatives humaines naturelles
*/
const LLM_FINGERPRINTS = {
// Mots techniques/corporate typiques IA
'optimal': ['idéal', 'parfait', 'adapté', 'approprié', 'convenable'],
'optimale': ['idéale', 'parfaite', 'adaptée', 'appropriée', 'convenable'],
'comprehensive': ['complet', 'détaillé', 'exhaustif', 'approfondi', 'global'],
'seamless': ['fluide', 'naturel', 'sans accroc', 'harmonieux', 'lisse'],
'robust': ['solide', 'fiable', 'résistant', 'costaud', 'stable'],
'robuste': ['solide', 'fiable', 'résistant', 'costaud', 'stable'],
// Expressions trop formelles/IA
'il convient de noter': ['on remarque', 'il faut savoir', 'à noter', 'important'],
'il convient de': ['il faut', 'on doit', 'mieux vaut', 'il est bon de'],
'par conséquent': ['du coup', 'donc', 'résultat', 'ainsi'],
'néanmoins': ['cependant', 'mais', 'pourtant', 'malgré tout'],
'toutefois': ['cependant', 'mais', 'pourtant', 'quand même'],
'de surcroît': ['de plus', 'en plus', 'aussi', 'également'],
// Superlatifs excessifs typiques IA
'extrêmement': ['très', 'super', 'vraiment', 'particulièrement'],
'particulièrement': ['très', 'vraiment', 'spécialement', 'surtout'],
'remarquablement': ['très', 'vraiment', 'sacrément', 'fichement'],
'exceptionnellement': ['très', 'vraiment', 'super', 'incroyablement'],
// Mots de liaison trop mécaniques
'en définitive': ['au final', 'finalement', 'bref', 'en gros'],
'il s\'avère que': ['on voit que', 'il se trouve que', 'en fait'],
'force est de constater': ['on constate', 'on voit bien', 'c\'est clair'],
// Expressions commerciales robotiques
'solution innovante': ['nouveauté', 'innovation', 'solution moderne', 'nouvelle approche'],
'approche holistique': ['approche globale', 'vision d\'ensemble', 'approche complète'],
'expérience utilisateur': ['confort d\'utilisation', 'facilité d\'usage', 'ergonomie'],
'retour sur investissement': ['rentabilité', 'bénéfices', 'profits'],
// Adjectifs surutilisés par IA
'révolutionnaire': ['nouveau', 'moderne', 'innovant', 'original'],
'game-changer': ['nouveauté', 'innovation', 'changement', 'révolution'],
'cutting-edge': ['moderne', 'récent', 'nouveau', 'avancé'],
'state-of-the-art': ['moderne', 'récent', 'performant', 'haut de gamme']
};
/**
* EXPRESSIONS CONTEXTUELLES SECTEUR SIGNALÉTIQUE
* Adaptées au domaine métier pour plus de naturel
*/
const CONTEXTUAL_REPLACEMENTS = {
'solution': {
'signalétique': ['plaque', 'panneau', 'support', 'réalisation'],
'impression': ['tirage', 'print', 'production', 'fabrication'],
'default': ['option', 'possibilité', 'choix', 'alternative']
},
'produit': {
'signalétique': ['plaque', 'panneau', 'enseigne', 'support'],
'default': ['article', 'réalisation', 'création']
},
'service': {
'signalétique': ['prestation', 'réalisation', 'travail', 'création'],
'default': ['prestation', 'travail', 'aide']
}
};
/**
* MAIN ENTRY POINT - SUPPRESSION EMPREINTES LLM
* @param {Object} input - { content: {}, config: {}, context: {} }
* @returns {Object} - { content: {}, stats: {}, debug: {} }
*/
async function removeLLMFingerprints(input) {
return await tracer.run('LLMFingerprintRemoval.removeLLMFingerprints()', async () => {
const { content, config = {}, context = {} } = input;
const {
intensity = 1.0, // Probabilité de remplacement (100%)
preserveKeywords = true, // Préserver mots-clés SEO
contextualMode = true, // Mode contextuel métier
csvData = null // Pour contexte métier
} = config;
await tracer.annotate({
technique: 'fingerprint_removal',
intensity,
elementsCount: Object.keys(content).length,
contextualMode
});
const startTime = Date.now();
logSh(`🔍 TECHNIQUE 2/3: Suppression empreintes LLM (intensité: ${intensity})`, 'INFO');
logSh(` 📊 ${Object.keys(content).length} éléments à nettoyer`, 'DEBUG');
try {
const results = {};
let totalProcessed = 0;
let totalReplacements = 0;
let replacementDetails = [];
// Préparer contexte métier
const businessContext = extractBusinessContext(csvData);
// Traiter chaque élément de contenu
for (const [tag, text] of Object.entries(content)) {
totalProcessed++;
if (text.length < 20) {
results[tag] = text;
continue;
}
// Appliquer suppression des empreintes
const cleaningResult = cleanTextFingerprints(text, {
intensity,
preserveKeywords,
contextualMode,
businessContext,
tag
});
results[tag] = cleaningResult.text;
if (cleaningResult.replacements.length > 0) {
totalReplacements += cleaningResult.replacements.length;
replacementDetails.push({
tag,
replacements: cleaningResult.replacements,
fingerprintsFound: cleaningResult.fingerprintsDetected
});
logSh(` 🧹 [${tag}]: ${cleaningResult.replacements.length} remplacements`, 'DEBUG');
} else {
logSh(` ✅ [${tag}]: Aucune empreinte détectée`, 'DEBUG');
}
}
const duration = Date.now() - startTime;
const stats = {
processed: totalProcessed,
totalReplacements,
avgReplacementsPerElement: Math.round(totalReplacements / totalProcessed * 100) / 100,
elementsWithFingerprints: replacementDetails.length,
duration,
technique: 'fingerprint_removal'
};
logSh(`✅ NETTOYAGE EMPREINTES: ${stats.totalReplacements} remplacements sur ${stats.elementsWithFingerprints}/${stats.processed} éléments en ${duration}ms`, 'INFO');
await tracer.event('Fingerprint removal terminée', stats);
return {
content: results,
stats,
debug: {
technique: 'fingerprint_removal',
config: { intensity, preserveKeywords, contextualMode },
replacements: replacementDetails,
businessContext
}
};
} catch (error) {
const duration = Date.now() - startTime;
logSh(`❌ NETTOYAGE EMPREINTES échoué après ${duration}ms: ${error.message}`, 'ERROR');
throw new Error(`LLMFingerprintRemoval failed: ${error.message}`);
}
}, input);
}
/**
* Nettoyer les empreintes LLM d'un texte
*/
function cleanTextFingerprints(text, config) {
const { intensity, preserveKeywords, contextualMode, businessContext, tag } = config;
let cleanedText = text;
const replacements = [];
const fingerprintsDetected = [];
// PHASE 1: Remplacements directs du dictionnaire
for (const [fingerprint, alternatives] of Object.entries(LLM_FINGERPRINTS)) {
const regex = new RegExp(`\\b${escapeRegex(fingerprint)}\\b`, 'gi');
const matches = text.match(regex);
if (matches) {
fingerprintsDetected.push(fingerprint);
// Appliquer remplacement selon intensité
if (Math.random() <= intensity) {
const alternative = selectBestAlternative(alternatives, businessContext, contextualMode);
cleanedText = cleanedText.replace(regex, (match) => {
// Préserver la casse originale
return preserveCase(match, alternative);
});
replacements.push({
type: 'direct',
original: fingerprint,
replacement: alternative,
occurrences: matches.length
});
}
}
}
// PHASE 2: Remplacements contextuels
if (contextualMode && businessContext) {
const contextualReplacements = applyContextualReplacements(cleanedText, businessContext);
cleanedText = contextualReplacements.text;
replacements.push(...contextualReplacements.replacements);
}
// PHASE 3: Détection patterns récurrents
const patternReplacements = replaceRecurringPatterns(cleanedText, intensity);
cleanedText = patternReplacements.text;
replacements.push(...patternReplacements.replacements);
return {
text: cleanedText,
replacements,
fingerprintsDetected
};
}
/**
* Sélectionner la meilleure alternative selon le contexte
*/
function selectBestAlternative(alternatives, businessContext, contextualMode) {
if (!contextualMode || !businessContext) {
// Mode aléatoire simple
return alternatives[Math.floor(Math.random() * alternatives.length)];
}
// Mode contextuel : privilégier alternatives adaptées au métier
const contextualAlternatives = alternatives.filter(alt =>
isContextuallyAppropriate(alt, businessContext)
);
const finalAlternatives = contextualAlternatives.length > 0 ? contextualAlternatives : alternatives;
return finalAlternatives[Math.floor(Math.random() * finalAlternatives.length)];
}
/**
* Vérifier si une alternative est contextuelle appropriée
*/
function isContextuallyAppropriate(alternative, businessContext) {
const { sector, vocabulary } = businessContext;
// Signalétique : privilégier vocabulaire technique/artisanal
if (sector === 'signalétique') {
const technicalWords = ['solide', 'fiable', 'costaud', 'résistant', 'adapté'];
return technicalWords.includes(alternative);
}
return true; // Par défaut accepter
}
/**
* Appliquer remplacements contextuels
*/
function applyContextualReplacements(text, businessContext) {
let processedText = text;
const replacements = [];
for (const [word, contexts] of Object.entries(CONTEXTUAL_REPLACEMENTS)) {
const regex = new RegExp(`\\b${word}\\b`, 'gi');
const matches = processedText.match(regex);
if (matches) {
const contextAlternatives = contexts[businessContext.sector] || contexts.default;
const replacement = contextAlternatives[Math.floor(Math.random() * contextAlternatives.length)];
processedText = processedText.replace(regex, (match) => {
return preserveCase(match, replacement);
});
replacements.push({
type: 'contextual',
original: word,
replacement,
occurrences: matches.length,
context: businessContext.sector
});
}
}
return { text: processedText, replacements };
}
/**
* Remplacer patterns récurrents
*/
function replaceRecurringPatterns(text, intensity) {
let processedText = text;
const replacements = [];
// Pattern 1: "très + adjectif" → variantes
const veryPattern = /\btrès\s+(\w+)/gi;
const veryMatches = [...text.matchAll(veryPattern)];
if (veryMatches.length > 2 && Math.random() < intensity) {
// Remplacer certains "très" par des alternatives
const alternatives = ['super', 'vraiment', 'particulièrement', 'assez'];
veryMatches.slice(1).forEach((match, index) => {
if (Math.random() < 0.5) {
const alternative = alternatives[Math.floor(Math.random() * alternatives.length)];
const fullMatch = match[0];
const adjective = match[1];
const replacement = `${alternative} ${adjective}`;
processedText = processedText.replace(fullMatch, replacement);
replacements.push({
type: 'pattern',
pattern: '"très + adjectif"',
original: fullMatch,
replacement
});
}
});
}
return { text: processedText, replacements };
}
/**
* Extraire contexte métier des données CSV
*/
function extractBusinessContext(csvData) {
if (!csvData) {
return { sector: 'general', vocabulary: [] };
}
const mc0 = csvData.mc0?.toLowerCase() || '';
// Détection secteur
let sector = 'general';
if (mc0.includes('plaque') || mc0.includes('panneau') || mc0.includes('enseigne')) {
sector = 'signalétique';
} else if (mc0.includes('impression') || mc0.includes('print')) {
sector = 'impression';
}
// Extraction vocabulaire clé
const vocabulary = [csvData.mc0, csvData.t0, csvData.tMinus1].filter(Boolean);
return { sector, vocabulary };
}
/**
* Préserver la casse originale
*/
function preserveCase(original, replacement) {
if (original === original.toUpperCase()) {
return replacement.toUpperCase();
} else if (original[0] === original[0].toUpperCase()) {
return replacement.charAt(0).toUpperCase() + replacement.slice(1).toLowerCase();
} else {
return replacement.toLowerCase();
}
}
/**
* Échapper caractères regex
*/
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Analyser les empreintes LLM dans un texte
*/
function analyzeLLMFingerprints(text) {
const detectedFingerprints = [];
let totalMatches = 0;
for (const fingerprint of Object.keys(LLM_FINGERPRINTS)) {
const regex = new RegExp(`\\b${escapeRegex(fingerprint)}\\b`, 'gi');
const matches = text.match(regex);
if (matches) {
detectedFingerprints.push({
fingerprint,
occurrences: matches.length,
category: categorizefingerprint(fingerprint)
});
totalMatches += matches.length;
}
}
return {
hasFingerprints: detectedFingerprints.length > 0,
fingerprints: detectedFingerprints,
totalMatches,
riskLevel: calculateRiskLevel(detectedFingerprints, text.length)
};
}
/**
* Catégoriser une empreinte LLM
*/
function categorizefingerprint(fingerprint) {
const categories = {
'technical': ['optimal', 'comprehensive', 'robust', 'seamless'],
'formal': ['il convient de', 'néanmoins', 'par conséquent'],
'superlative': ['extrêmement', 'particulièrement', 'remarquablement'],
'commercial': ['solution innovante', 'game-changer', 'révolutionnaire']
};
for (const [category, words] of Object.entries(categories)) {
if (words.some(word => fingerprint.includes(word))) {
return category;
}
}
return 'other';
}
/**
* Calculer niveau de risque de détection
*/
function calculateRiskLevel(fingerprints, textLength) {
if (fingerprints.length === 0) return 'low';
const fingerprintDensity = fingerprints.reduce((sum, fp) => sum + fp.occurrences, 0) / (textLength / 100);
if (fingerprintDensity > 3) return 'high';
if (fingerprintDensity > 1.5) return 'medium';
return 'low';
}
module.exports = {
removeLLMFingerprints, // ← MAIN ENTRY POINT
cleanTextFingerprints,
analyzeLLMFingerprints,
LLM_FINGERPRINTS,
CONTEXTUAL_REPLACEMENTS,
extractBusinessContext
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/post-processing/TransitionHumanization.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// PATTERN BREAKING - TECHNIQUE 3: TRANSITION HUMANIZATION
// Responsabilité: Remplacer connecteurs mécaniques par transitions naturelles
// Anti-détection: Éviter patterns de liaison typiques des LLMs
// ========================================
const { logSh } = require('../ErrorReporting');
const { tracer } = require('../trace');
/**
* DICTIONNAIRE CONNECTEURS HUMANISÉS
* Connecteurs LLM → Alternatives naturelles par contexte
*/
const TRANSITION_REPLACEMENTS = {
// Connecteurs trop formels → versions naturelles
'par ailleurs': {
alternatives: ['d\'ailleurs', 'au fait', 'soit dit en passant', 'à propos', 'sinon'],
weight: 0.8,
contexts: ['casual', 'conversational']
},
'en effet': {
alternatives: ['effectivement', 'c\'est vrai', 'tout à fait', 'absolument', 'exactement'],
weight: 0.9,
contexts: ['confirmative', 'agreement']
},
'de plus': {
alternatives: ['aussi', 'également', 'qui plus est', 'en plus', 'et puis'],
weight: 0.7,
contexts: ['additive', 'continuation']
},
'cependant': {
alternatives: ['mais', 'pourtant', 'néanmoins', 'malgré tout', 'quand même'],
weight: 0.6,
contexts: ['contrast', 'opposition']
},
'ainsi': {
alternatives: ['donc', 'du coup', 'comme ça', 'par conséquent', 'résultat'],
weight: 0.8,
contexts: ['consequence', 'result']
},
'donc': {
alternatives: ['du coup', 'alors', 'par conséquent', 'ainsi', 'résultat'],
weight: 0.5,
contexts: ['consequence', 'logical']
},
// Connecteurs de séquence
'ensuite': {
alternatives: ['puis', 'après', 'et puis', 'alors', 'du coup'],
weight: 0.6,
contexts: ['sequence', 'temporal']
},
'puis': {
alternatives: ['ensuite', 'après', 'et puis', 'alors'],
weight: 0.4,
contexts: ['sequence', 'temporal']
},
// Connecteurs d'emphase
'également': {
alternatives: ['aussi', 'de même', 'pareillement', 'en plus'],
weight: 0.6,
contexts: ['similarity', 'addition']
},
'aussi': {
alternatives: ['également', 'de même', 'en plus', 'pareillement'],
weight: 0.3,
contexts: ['similarity', 'addition']
},
// Connecteurs de conclusion
'enfin': {
alternatives: ['finalement', 'au final', 'pour finir', 'en dernier'],
weight: 0.5,
contexts: ['conclusion', 'final']
},
'finalement': {
alternatives: ['au final', 'en fin de compte', 'pour finir', 'enfin'],
weight: 0.4,
contexts: ['conclusion', 'final']
}
};
/**
* PATTERNS DE TRANSITION NATURELLE
* Selon le style de personnalité
*/
const PERSONALITY_TRANSITIONS = {
'décontracté': {
preferred: ['du coup', 'alors', 'bon', 'après', 'sinon'],
avoided: ['par conséquent', 'néanmoins', 'toutefois']
},
'technique': {
preferred: ['donc', 'ainsi', 'par conséquent', 'résultat'],
avoided: ['du coup', 'bon', 'franchement']
},
'commercial': {
preferred: ['aussi', 'de plus', 'également', 'qui plus est'],
avoided: ['du coup', 'bon', 'franchement']
},
'familier': {
preferred: ['du coup', 'bon', 'alors', 'après', 'franchement'],
avoided: ['par conséquent', 'néanmoins', 'de surcroît']
}
};
/**
* MAIN ENTRY POINT - HUMANISATION TRANSITIONS
* @param {Object} input - { content: {}, config: {}, context: {} }
* @returns {Object} - { content: {}, stats: {}, debug: {} }
*/
async function humanizeTransitions(input) {
return await tracer.run('TransitionHumanization.humanizeTransitions()', async () => {
const { content, config = {}, context = {} } = input;
const {
intensity = 0.6, // Probabilité de remplacement (60%)
personalityStyle = null, // Style de personnalité pour guidage
avoidRepetition = true, // Éviter répétitions excessives
preserveFormal = false, // Préserver style formel
csvData = null // Données pour personnalité
} = config;
await tracer.annotate({
technique: 'transition_humanization',
intensity,
personalityStyle: personalityStyle || csvData?.personality?.style,
elementsCount: Object.keys(content).length
});
const startTime = Date.now();
logSh(`🔗 TECHNIQUE 3/3: Humanisation transitions (intensité: ${intensity})`, 'INFO');
logSh(` 📊 ${Object.keys(content).length} éléments à humaniser`, 'DEBUG');
try {
const results = {};
let totalProcessed = 0;
let totalReplacements = 0;
let humanizationDetails = [];
// Extraire style de personnalité
const effectivePersonalityStyle = personalityStyle || csvData?.personality?.style || 'neutral';
// Analyser patterns globaux pour éviter répétitions
const globalPatterns = analyzeGlobalTransitionPatterns(content);
// Traiter chaque élément de contenu
for (const [tag, text] of Object.entries(content)) {
totalProcessed++;
if (text.length < 30) {
results[tag] = text;
continue;
}
// Appliquer humanisation des transitions
const humanizationResult = humanizeTextTransitions(text, {
intensity,
personalityStyle: effectivePersonalityStyle,
avoidRepetition,
preserveFormal,
globalPatterns,
tag
});
results[tag] = humanizationResult.text;
if (humanizationResult.replacements.length > 0) {
totalReplacements += humanizationResult.replacements.length;
humanizationDetails.push({
tag,
replacements: humanizationResult.replacements,
transitionsDetected: humanizationResult.transitionsFound
});
logSh(` 🔄 [${tag}]: ${humanizationResult.replacements.length} transitions humanisées`, 'DEBUG');
} else {
logSh(` ➡️ [${tag}]: Transitions déjà naturelles`, 'DEBUG');
}
}
const duration = Date.now() - startTime;
const stats = {
processed: totalProcessed,
totalReplacements,
avgReplacementsPerElement: Math.round(totalReplacements / totalProcessed * 100) / 100,
elementsWithTransitions: humanizationDetails.length,
personalityStyle: effectivePersonalityStyle,
duration,
technique: 'transition_humanization'
};
logSh(`✅ HUMANISATION TRANSITIONS: ${stats.totalReplacements} remplacements sur ${stats.elementsWithTransitions}/${stats.processed} éléments en ${duration}ms`, 'INFO');
await tracer.event('Transition humanization terminée', stats);
return {
content: results,
stats,
debug: {
technique: 'transition_humanization',
config: { intensity, personalityStyle: effectivePersonalityStyle, avoidRepetition },
humanizations: humanizationDetails,
globalPatterns
}
};
} catch (error) {
const duration = Date.now() - startTime;
logSh(`❌ HUMANISATION TRANSITIONS échouée après ${duration}ms: ${error.message}`, 'ERROR');
throw new Error(`TransitionHumanization failed: ${error.message}`);
}
}, input);
}
/**
* Humaniser les transitions d'un texte
*/
function humanizeTextTransitions(text, config) {
const { intensity, personalityStyle, avoidRepetition, preserveFormal, globalPatterns, tag } = config;
let humanizedText = text;
const replacements = [];
const transitionsFound = [];
// Statistiques usage pour éviter répétitions
const usageStats = {};
// Traiter chaque connecteur du dictionnaire
for (const [transition, transitionData] of Object.entries(TRANSITION_REPLACEMENTS)) {
const { alternatives, weight, contexts } = transitionData;
// Rechercher occurrences (insensible à la casse, mais préserver limites mots)
const regex = new RegExp(`\\b${escapeRegex(transition)}\\b`, 'gi');
const matches = [...text.matchAll(regex)];
if (matches.length > 0) {
transitionsFound.push(transition);
// Décider si on remplace selon intensité et poids
const shouldReplace = Math.random() < (intensity * weight);
if (shouldReplace && !preserveFormal) {
// Sélectionner meilleure alternative
const selectedAlternative = selectBestTransitionAlternative(
alternatives,
personalityStyle,
usageStats,
avoidRepetition
);
// Appliquer remplacement en préservant la casse
humanizedText = humanizedText.replace(regex, (match) => {
return preserveCase(match, selectedAlternative);
});
// Enregistrer usage
usageStats[selectedAlternative] = (usageStats[selectedAlternative] || 0) + matches.length;
replacements.push({
original: transition,
replacement: selectedAlternative,
occurrences: matches.length,
contexts,
personalityMatch: isPersonalityAppropriate(selectedAlternative, personalityStyle)
});
}
}
}
// Post-processing : éviter accumulations
if (avoidRepetition) {
const repetitionCleaned = reduceTransitionRepetition(humanizedText, usageStats);
humanizedText = repetitionCleaned.text;
replacements.push(...repetitionCleaned.additionalChanges);
}
return {
text: humanizedText,
replacements,
transitionsFound
};
}
/**
* Sélectionner meilleure alternative de transition
*/
function selectBestTransitionAlternative(alternatives, personalityStyle, usageStats, avoidRepetition) {
// Filtrer selon personnalité
const personalityFiltered = alternatives.filter(alt =>
isPersonalityAppropriate(alt, personalityStyle)
);
const candidateList = personalityFiltered.length > 0 ? personalityFiltered : alternatives;
if (!avoidRepetition) {
return candidateList[Math.floor(Math.random() * candidateList.length)];
}
// Éviter les alternatives déjà trop utilisées
const lessUsedAlternatives = candidateList.filter(alt =>
(usageStats[alt] || 0) < 2
);
const finalList = lessUsedAlternatives.length > 0 ? lessUsedAlternatives : candidateList;
return finalList[Math.floor(Math.random() * finalList.length)];
}
/**
* Vérifier si alternative appropriée pour personnalité
*/
function isPersonalityAppropriate(alternative, personalityStyle) {
if (!personalityStyle || personalityStyle === 'neutral') return true;
const styleMapping = {
'décontracté': PERSONALITY_TRANSITIONS.décontracté,
'technique': PERSONALITY_TRANSITIONS.technique,
'commercial': PERSONALITY_TRANSITIONS.commercial,
'familier': PERSONALITY_TRANSITIONS.familier
};
const styleConfig = styleMapping[personalityStyle.toLowerCase()];
if (!styleConfig) return true;
// Éviter les connecteurs inappropriés
if (styleConfig.avoided.includes(alternative)) return false;
// Privilégier les connecteurs préférés
if (styleConfig.preferred.includes(alternative)) return true;
return true;
}
/**
* Réduire répétitions excessives de transitions
*/
function reduceTransitionRepetition(text, usageStats) {
let processedText = text;
const additionalChanges = [];
// Identifier connecteurs surutilisés (>3 fois)
const overusedTransitions = Object.entries(usageStats)
.filter(([transition, count]) => count > 3)
.map(([transition]) => transition);
for (const overusedTransition of overusedTransitions) {
// Remplacer quelques occurrences par des alternatives
const regex = new RegExp(`\\b${escapeRegex(overusedTransition)}\\b`, 'g');
let replacements = 0;
processedText = processedText.replace(regex, (match, offset) => {
// Remplacer 1 occurrence sur 3 environ
if (Math.random() < 0.33 && replacements < 2) {
replacements++;
const alternatives = findAlternativesFor(overusedTransition);
const alternative = alternatives[Math.floor(Math.random() * alternatives.length)];
additionalChanges.push({
type: 'repetition_reduction',
original: overusedTransition,
replacement: alternative,
reason: 'overuse'
});
return preserveCase(match, alternative);
}
return match;
});
}
return { text: processedText, additionalChanges };
}
/**
* Trouver alternatives pour un connecteur donné
*/
function findAlternativesFor(transition) {
// Chercher dans le dictionnaire
for (const [key, data] of Object.entries(TRANSITION_REPLACEMENTS)) {
if (data.alternatives.includes(transition)) {
return data.alternatives.filter(alt => alt !== transition);
}
}
// Alternatives génériques
const genericAlternatives = {
'du coup': ['alors', 'donc', 'ainsi'],
'alors': ['du coup', 'donc', 'ensuite'],
'donc': ['du coup', 'alors', 'ainsi'],
'aussi': ['également', 'de plus', 'en plus'],
'mais': ['cependant', 'pourtant', 'néanmoins']
};
return genericAlternatives[transition] || ['donc', 'alors'];
}
/**
* Analyser patterns globaux de transitions
*/
function analyzeGlobalTransitionPatterns(content) {
const allText = Object.values(content).join(' ');
const transitionCounts = {};
const repetitionPatterns = [];
// Compter occurrences globales
for (const transition of Object.keys(TRANSITION_REPLACEMENTS)) {
const regex = new RegExp(`\\b${escapeRegex(transition)}\\b`, 'gi');
const matches = allText.match(regex);
if (matches) {
transitionCounts[transition] = matches.length;
}
}
// Identifier patterns de répétition problématiques
const sortedTransitions = Object.entries(transitionCounts)
.sort(([,a], [,b]) => b - a)
.slice(0, 5); // Top 5 plus utilisées
sortedTransitions.forEach(([transition, count]) => {
if (count > 5) {
repetitionPatterns.push({
transition,
count,
severity: count > 10 ? 'high' : count > 7 ? 'medium' : 'low'
});
}
});
return {
transitionCounts,
repetitionPatterns,
diversityScore: Object.keys(transitionCounts).length / Math.max(1, Object.values(transitionCounts).reduce((a,b) => a+b, 0))
};
}
/**
* Préserver la casse originale
*/
function preserveCase(original, replacement) {
if (original === original.toUpperCase()) {
return replacement.toUpperCase();
} else if (original[0] === original[0].toUpperCase()) {
return replacement.charAt(0).toUpperCase() + replacement.slice(1).toLowerCase();
} else {
return replacement.toLowerCase();
}
}
/**
* Échapper caractères regex
*/
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Analyser qualité des transitions d'un texte
*/
function analyzeTransitionQuality(text) {
const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 5);
if (sentences.length < 2) {
return { score: 100, issues: [], naturalness: 'high' };
}
let mechanicalTransitions = 0;
let totalTransitions = 0;
const issues = [];
// Analyser chaque transition
sentences.forEach((sentence, index) => {
if (index === 0) return;
const trimmed = sentence.trim();
const startsWithTransition = Object.keys(TRANSITION_REPLACEMENTS).some(transition =>
trimmed.toLowerCase().startsWith(transition.toLowerCase())
);
if (startsWithTransition) {
totalTransitions++;
// Vérifier si transition mécanique
const transition = Object.keys(TRANSITION_REPLACEMENTS).find(t =>
trimmed.toLowerCase().startsWith(t.toLowerCase())
);
if (transition && TRANSITION_REPLACEMENTS[transition].weight > 0.7) {
mechanicalTransitions++;
issues.push({
type: 'mechanical_transition',
transition,
suggestion: TRANSITION_REPLACEMENTS[transition].alternatives[0]
});
}
}
});
const mechanicalRatio = totalTransitions > 0 ? mechanicalTransitions / totalTransitions : 0;
const score = Math.max(0, 100 - (mechanicalRatio * 100));
let naturalness = 'high';
if (mechanicalRatio > 0.5) naturalness = 'low';
else if (mechanicalRatio > 0.25) naturalness = 'medium';
return { score: Math.round(score), issues, naturalness, mechanicalRatio };
}
module.exports = {
humanizeTransitions, // ← MAIN ENTRY POINT
humanizeTextTransitions,
analyzeTransitionQuality,
analyzeGlobalTransitionPatterns,
TRANSITION_REPLACEMENTS,
PERSONALITY_TRANSITIONS
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/post-processing/PatternBreaking.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// ORCHESTRATEUR PATTERN BREAKING - NIVEAU 2
// Responsabilité: Coordonner les 3 techniques anti-détection
// Objectif: -20% détection IA vs Niveau 1
// ========================================
const { logSh } = require('../ErrorReporting');
const { tracer } = require('../trace');
// Import des 3 techniques Pattern Breaking
const { applySentenceVariation } = require('./SentenceVariation');
const { removeLLMFingerprints } = require('./LLMFingerprintRemoval');
const { humanizeTransitions } = require('./TransitionHumanization');
/**
* MAIN ENTRY POINT - PATTERN BREAKING COMPLET
* @param {Object} input - { content: {}, csvData: {}, options: {} }
* @returns {Object} - { content: {}, stats: {}, debug: {} }
*/
async function applyPatternBreaking(input) {
return await tracer.run('PatternBreaking.applyPatternBreaking()', async () => {
const { content, csvData, options = {} } = input;
const config = {
// Configuration globale
intensity: 0.6, // Intensité générale (60%)
// Contrôle par technique
sentenceVariation: true, // Activer variation phrases
fingerprintRemoval: true, // Activer suppression empreintes
transitionHumanization: true, // Activer humanisation transitions
// Configuration spécifique par technique
sentenceVariationConfig: {
intensity: 0.3,
splitThreshold: 100,
mergeThreshold: 30,
preserveQuestions: true,
preserveTitles: true
},
fingerprintRemovalConfig: {
intensity: 1.0,
preserveKeywords: true,
contextualMode: true,
csvData
},
transitionHumanizationConfig: {
intensity: 0.6,
personalityStyle: csvData?.personality?.style,
avoidRepetition: true,
preserveFormal: false,
csvData
},
// Options avancées
qualityPreservation: true, // Préserver qualité contenu
seoIntegrity: true, // Maintenir intégrité SEO
readabilityCheck: true, // Vérifier lisibilité
...options // Override avec options fournies
};
await tracer.annotate({
level: 2,
technique: 'pattern_breaking',
elementsCount: Object.keys(content).length,
personality: csvData?.personality?.nom,
config: {
sentenceVariation: config.sentenceVariation,
fingerprintRemoval: config.fingerprintRemoval,
transitionHumanization: config.transitionHumanization,
intensity: config.intensity
}
});
const startTime = Date.now();
logSh(`🎯 NIVEAU 2: PATTERN BREAKING (3 techniques)`, 'INFO');
logSh(` 🎭 Personnalité: ${csvData?.personality?.nom} (${csvData?.personality?.style})`, 'INFO');
logSh(` 📊 ${Object.keys(content).length} éléments à traiter`, 'INFO');
logSh(` ⚙️ Techniques actives: ${[config.sentenceVariation && 'Variation', config.fingerprintRemoval && 'Empreintes', config.transitionHumanization && 'Transitions'].filter(Boolean).join(' + ')}`, 'INFO');
try {
let currentContent = { ...content };
const pipelineStats = {
techniques: [],
totalDuration: 0,
qualityMetrics: {}
};
// Analyse initiale de qualité
if (config.qualityPreservation) {
pipelineStats.qualityMetrics.initial = analyzeContentQuality(currentContent);
}
// TECHNIQUE 1: VARIATION LONGUEUR PHRASES
if (config.sentenceVariation) {
const step1Result = await applySentenceVariation({
content: currentContent,
config: config.sentenceVariationConfig,
context: { step: 1, totalSteps: 3 }
});
currentContent = step1Result.content;
pipelineStats.techniques.push({
name: 'SentenceVariation',
...step1Result.stats,
qualityImpact: calculateQualityImpact(content, step1Result.content)
});
logSh(` ✅ 1/3: Variation phrases - ${step1Result.stats.modified}/${step1Result.stats.processed} éléments`, 'INFO');
}
// TECHNIQUE 2: SUPPRESSION EMPREINTES LLM
if (config.fingerprintRemoval) {
const step2Result = await removeLLMFingerprints({
content: currentContent,
config: config.fingerprintRemovalConfig,
context: { step: 2, totalSteps: 3 }
});
currentContent = step2Result.content;
pipelineStats.techniques.push({
name: 'FingerprintRemoval',
...step2Result.stats,
qualityImpact: calculateQualityImpact(content, step2Result.content)
});
logSh(` ✅ 2/3: Suppression empreintes - ${step2Result.stats.totalReplacements} remplacements`, 'INFO');
}
// TECHNIQUE 3: HUMANISATION TRANSITIONS
if (config.transitionHumanization) {
const step3Result = await humanizeTransitions({
content: currentContent,
config: config.transitionHumanizationConfig,
context: { step: 3, totalSteps: 3 }
});
currentContent = step3Result.content;
pipelineStats.techniques.push({
name: 'TransitionHumanization',
...step3Result.stats,
qualityImpact: calculateQualityImpact(content, step3Result.content)
});
logSh(` ✅ 3/3: Humanisation transitions - ${step3Result.stats.totalReplacements} améliorations`, 'INFO');
}
// POST-PROCESSING: Vérifications qualité
if (config.qualityPreservation || config.readabilityCheck) {
const qualityCheck = performQualityChecks(content, currentContent, config);
pipelineStats.qualityMetrics.final = qualityCheck;
// Rollback si qualité trop dégradée
if (qualityCheck.shouldRollback) {
logSh(`⚠️ ROLLBACK: Qualité dégradée, retour contenu original`, 'WARNING');
currentContent = content;
pipelineStats.rollback = true;
}
}
// RÉSULTATS FINAUX
const totalDuration = Date.now() - startTime;
pipelineStats.totalDuration = totalDuration;
const totalModifications = pipelineStats.techniques.reduce((sum, tech) => {
return sum + (tech.modified || tech.totalReplacements || 0);
}, 0);
const stats = {
level: 2,
technique: 'pattern_breaking',
processed: Object.keys(content).length,
totalModifications,
techniquesUsed: pipelineStats.techniques.length,
duration: totalDuration,
techniques: pipelineStats.techniques,
qualityPreserved: !pipelineStats.rollback,
rollback: pipelineStats.rollback || false
};
logSh(`🎯 NIVEAU 2 TERMINÉ: ${totalModifications} modifications sur ${stats.processed} éléments (${totalDuration}ms)`, 'INFO');
// Log détaillé par technique
pipelineStats.techniques.forEach(tech => {
const modificationsCount = tech.modified || tech.totalReplacements || 0;
logSh(` • ${tech.name}: ${modificationsCount} modifications (${tech.duration}ms)`, 'DEBUG');
});
await tracer.event('Pattern breaking terminé', stats);
return {
content: currentContent,
stats,
debug: {
level: 2,
technique: 'pattern_breaking',
config,
pipeline: pipelineStats,
qualityMetrics: pipelineStats.qualityMetrics
}
};
} catch (error) {
const totalDuration = Date.now() - startTime;
logSh(`❌ NIVEAU 2 ÉCHOUÉ après ${totalDuration}ms: ${error.message}`, 'ERROR');
// Fallback: retourner contenu original
logSh(`🔄 Fallback: contenu original conservé`, 'WARNING');
await tracer.event('Pattern breaking échoué', {
error: error.message,
duration: totalDuration,
fallback: true
});
return {
content,
stats: {
level: 2,
technique: 'pattern_breaking',
processed: Object.keys(content).length,
totalModifications: 0,
duration: totalDuration,
error: error.message,
fallback: true
},
debug: { error: error.message, fallback: true }
};
}
}, input);
}
/**
* MODE DIAGNOSTIC - Test individuel des techniques
*/
async function diagnosticPatternBreaking(content, csvData) {
logSh(`🔬 DIAGNOSTIC NIVEAU 2: Test individuel des techniques`, 'INFO');
const diagnostics = {
techniques: [],
errors: [],
performance: {},
recommendations: []
};
const techniques = [
{ name: 'SentenceVariation', func: applySentenceVariation },
{ name: 'FingerprintRemoval', func: removeLLMFingerprints },
{ name: 'TransitionHumanization', func: humanizeTransitions }
];
for (const technique of techniques) {
try {
const startTime = Date.now();
const result = await technique.func({
content,
config: { csvData },
context: { diagnostic: true }
});
diagnostics.techniques.push({
name: technique.name,
success: true,
duration: Date.now() - startTime,
stats: result.stats,
effectivenessScore: calculateEffectivenessScore(result.stats)
});
} catch (error) {
diagnostics.errors.push({
technique: technique.name,
error: error.message
});
diagnostics.techniques.push({
name: technique.name,
success: false,
error: error.message
});
}
}
// Générer recommandations
diagnostics.recommendations = generateRecommendations(diagnostics.techniques);
const successfulTechniques = diagnostics.techniques.filter(t => t.success);
diagnostics.performance.totalDuration = diagnostics.techniques.reduce((sum, t) => sum + (t.duration || 0), 0);
diagnostics.performance.successRate = Math.round((successfulTechniques.length / techniques.length) * 100);
logSh(`🔬 DIAGNOSTIC TERMINÉ: ${successfulTechniques.length}/${techniques.length} techniques opérationnelles`, 'INFO');
return diagnostics;
}
/**
* Analyser qualité du contenu
*/
function analyzeContentQuality(content) {
const allText = Object.values(content).join(' ');
const wordCount = allText.split(/\s+/).length;
const avgWordsPerElement = wordCount / Object.keys(content).length;
// Métrique de lisibilité approximative (Flesch simplifié)
const sentences = allText.split(/[.!?]+/).filter(s => s.trim().length > 5);
const avgWordsPerSentence = wordCount / Math.max(1, sentences.length);
const readabilityScore = Math.max(0, 100 - (avgWordsPerSentence * 1.5));
return {
wordCount,
elementCount: Object.keys(content).length,
avgWordsPerElement: Math.round(avgWordsPerElement),
avgWordsPerSentence: Math.round(avgWordsPerSentence),
readabilityScore: Math.round(readabilityScore),
sentenceCount: sentences.length
};
}
/**
* Calculer impact qualité entre avant/après
*/
function calculateQualityImpact(originalContent, modifiedContent) {
const originalQuality = analyzeContentQuality(originalContent);
const modifiedQuality = analyzeContentQuality(modifiedContent);
const wordCountChange = ((modifiedQuality.wordCount - originalQuality.wordCount) / originalQuality.wordCount) * 100;
const readabilityChange = modifiedQuality.readabilityScore - originalQuality.readabilityScore;
return {
wordCountChange: Math.round(wordCountChange * 100) / 100,
readabilityChange: Math.round(readabilityChange),
severe: Math.abs(wordCountChange) > 10 || Math.abs(readabilityChange) > 15
};
}
/**
* Effectuer vérifications qualité
*/
function performQualityChecks(originalContent, modifiedContent, config) {
const originalQuality = analyzeContentQuality(originalContent);
const modifiedQuality = analyzeContentQuality(modifiedContent);
const qualityThresholds = {
maxWordCountChange: 15, // % max changement nombre mots
minReadabilityScore: 50, // Score lisibilité minimum
maxReadabilityDrop: 20 // Baisse max lisibilité
};
const issues = [];
// Vérification nombre de mots
const wordCountChange = Math.abs(modifiedQuality.wordCount - originalQuality.wordCount) / originalQuality.wordCount * 100;
if (wordCountChange > qualityThresholds.maxWordCountChange) {
issues.push({
type: 'word_count_change',
severity: 'high',
change: wordCountChange,
threshold: qualityThresholds.maxWordCountChange
});
}
// Vérification lisibilité
if (modifiedQuality.readabilityScore < qualityThresholds.minReadabilityScore) {
issues.push({
type: 'low_readability',
severity: 'medium',
score: modifiedQuality.readabilityScore,
threshold: qualityThresholds.minReadabilityScore
});
}
const readabilityDrop = originalQuality.readabilityScore - modifiedQuality.readabilityScore;
if (readabilityDrop > qualityThresholds.maxReadabilityDrop) {
issues.push({
type: 'readability_drop',
severity: 'high',
drop: readabilityDrop,
threshold: qualityThresholds.maxReadabilityDrop
});
}
// Décision rollback
const highSeverityIssues = issues.filter(issue => issue.severity === 'high');
const shouldRollback = highSeverityIssues.length > 0 && config.qualityPreservation;
return {
originalQuality,
modifiedQuality,
issues,
shouldRollback,
qualityScore: calculateOverallQualityScore(issues, modifiedQuality)
};
}
/**
* Calculer score de qualité global
*/
function calculateOverallQualityScore(issues, quality) {
let baseScore = 100;
issues.forEach(issue => {
const penalty = issue.severity === 'high' ? 30 : issue.severity === 'medium' ? 15 : 5;
baseScore -= penalty;
});
// Bonus pour bonne lisibilité
if (quality.readabilityScore > 70) baseScore += 10;
return Math.max(0, Math.min(100, baseScore));
}
/**
* Calculer score d'efficacité d'une technique
*/
function calculateEffectivenessScore(stats) {
if (!stats) return 0;
const modificationsCount = stats.modified || stats.totalReplacements || 0;
const processedCount = stats.processed || 1;
const modificationRate = (modificationsCount / processedCount) * 100;
// Score basé sur taux de modification et durée
const baseScore = Math.min(100, modificationRate * 2); // Max 50% modification = score 100
const durationPenalty = Math.max(0, (stats.duration - 1000) / 100); // Pénalité si > 1s
return Math.max(0, Math.round(baseScore - durationPenalty));
}
/**
* Générer recommandations basées sur diagnostic
*/
function generateRecommendations(techniqueResults) {
const recommendations = [];
techniqueResults.forEach(tech => {
if (!tech.success) {
recommendations.push({
type: 'error',
technique: tech.name,
message: `${tech.name} a échoué: ${tech.error}`,
action: 'Vérifier configuration et dépendances'
});
return;
}
const effectiveness = tech.effectivenessScore || 0;
if (effectiveness < 30) {
recommendations.push({
type: 'low_effectiveness',
technique: tech.name,
message: `${tech.name} peu efficace (score: ${effectiveness})`,
action: 'Augmenter intensité ou réviser configuration'
});
} else if (effectiveness > 80) {
recommendations.push({
type: 'high_effectiveness',
technique: tech.name,
message: `${tech.name} très efficace (score: ${effectiveness})`,
action: 'Configuration optimale'
});
}
if (tech.duration > 3000) {
recommendations.push({
type: 'performance',
technique: tech.name,
message: `${tech.name} lent (${tech.duration}ms)`,
action: 'Considérer réduction intensité ou optimisation'
});
}
});
return recommendations;
}
module.exports = {
applyPatternBreaking, // ← MAIN ENTRY POINT
diagnosticPatternBreaking, // ← Mode diagnostic
analyzeContentQuality,
performQualityChecks,
calculateQualityImpact,
calculateEffectivenessScore
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/ContentGeneration.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// ORCHESTRATEUR GÉNÉRATION - ARCHITECTURE REFACTORISÉE
// Responsabilité: Coordonner les 4 étapes de génération
// ========================================
const { logSh } = require('./ErrorReporting');
const { tracer } = require('./trace');
// Import des 4 étapes séparées
const { generateInitialContent } = require('./generation/InitialGeneration');
const { enhanceTechnicalTerms } = require('./generation/TechnicalEnhancement');
const { enhanceTransitions } = require('./generation/TransitionEnhancement');
const { applyPersonalityStyle } = require('./generation/StyleEnhancement');
// Import Pattern Breaking (Niveau 2)
const { applyPatternBreaking } = require('./post-processing/PatternBreaking');
/**
* MAIN ENTRY POINT - GÉNÉRATION AVEC SELECTIVE ENHANCEMENT
* @param {Object} hierarchy - Hiérarchie des éléments extraits
* @param {Object} csvData - Données CSV avec personnalité
* @param {Object} options - Options de génération
* @returns {Object} - Contenu généré final
*/
async function generateWithContext(hierarchy, csvData, options = {}) {
return await tracer.run('ContentGeneration.generateWithContext()', async () => {
const startTime = Date.now();
const pipelineName = options.patternBreaking ? 'selective_enhancement_with_pattern_breaking' : 'selective_enhancement';
const totalSteps = options.patternBreaking ? 5 : 4;
await tracer.annotate({
pipeline: pipelineName,
elementsCount: Object.keys(hierarchy).length,
personality: csvData.personality?.nom,
mc0: csvData.mc0,
options,
totalSteps
});
logSh(`🚀 DÉBUT PIPELINE ${options.patternBreaking ? 'NIVEAU 2' : 'NIVEAU 1'}`, 'INFO');
logSh(` 🎭 Personnalité: ${csvData.personality?.nom} (${csvData.personality?.style})`, 'INFO');
logSh(` 📊 ${Object.keys(hierarchy).length} éléments à traiter`, 'INFO');
logSh(` 🔧 Options: ${JSON.stringify(options)}`, 'DEBUG');
try {
let pipelineResults = {
content: {},
stats: { stages: [], totalDuration: 0 },
debug: { pipeline: 'selective_enhancement', stages: [] }
};
// ÉTAPE 1: GÉNÉRATION INITIALE (Claude)
const step1Result = await generateInitialContent({
hierarchy,
csvData,
context: { step: 1, totalSteps, options }
});
pipelineResults.content = step1Result.content;
pipelineResults.stats.stages.push({ stage: 1, name: 'InitialGeneration', ...step1Result.stats });
pipelineResults.debug.stages.push(step1Result.debug);
// ÉTAPE 2: ENHANCEMENT TECHNIQUE (GPT-4) - Optionnel
if (!options.skipTechnical) {
const step2Result = await enhanceTechnicalTerms({
content: pipelineResults.content,
csvData,
context: { step: 2, totalSteps, options }
});
pipelineResults.content = step2Result.content;
pipelineResults.stats.stages.push({ stage: 2, name: 'TechnicalEnhancement', ...step2Result.stats });
pipelineResults.debug.stages.push(step2Result.debug);
} else {
logSh(`⏭️ ÉTAPE 2/4 IGNORÉE: Enhancement technique désactivé`, 'INFO');
}
// ÉTAPE 3: ENHANCEMENT TRANSITIONS (Gemini) - Optionnel
if (!options.skipTransitions) {
const step3Result = await enhanceTransitions({
content: pipelineResults.content,
csvData,
context: { step: 3, totalSteps, options }
});
pipelineResults.content = step3Result.content;
pipelineResults.stats.stages.push({ stage: 3, name: 'TransitionEnhancement', ...step3Result.stats });
pipelineResults.debug.stages.push(step3Result.debug);
} else {
logSh(`⏭️ ÉTAPE 3/4 IGNORÉE: Enhancement transitions désactivé`, 'INFO');
}
// ÉTAPE 4: ENHANCEMENT STYLE (Mistral) - Optionnel
if (!options.skipStyle) {
const step4Result = await applyPersonalityStyle({
content: pipelineResults.content,
csvData,
context: { step: 4, totalSteps, options }
});
pipelineResults.content = step4Result.content;
pipelineResults.stats.stages.push({ stage: 4, name: 'StyleEnhancement', ...step4Result.stats });
pipelineResults.debug.stages.push(step4Result.debug);
} else {
logSh(`⏭️ ÉTAPE 4/${totalSteps} IGNORÉE: Enhancement style désactivé`, 'INFO');
}
// ÉTAPE 5: PATTERN BREAKING (NIVEAU 2) - Optionnel
if (options.patternBreaking) {
const step5Result = await applyPatternBreaking({
content: pipelineResults.content,
csvData,
options: options.patternBreakingConfig || {}
});
pipelineResults.content = step5Result.content;
pipelineResults.stats.stages.push({ stage: 5, name: 'PatternBreaking', ...step5Result.stats });
pipelineResults.debug.stages.push(step5Result.debug);
} else if (totalSteps === 5) {
logSh(`⏭️ ÉTAPE 5/5 IGNORÉE: Pattern Breaking désactivé`, 'INFO');
}
// RÉSULTATS FINAUX
const totalDuration = Date.now() - startTime;
pipelineResults.stats.totalDuration = totalDuration;
const totalProcessed = pipelineResults.stats.stages.reduce((sum, stage) => sum + (stage.processed || 0), 0);
const totalEnhanced = pipelineResults.stats.stages.reduce((sum, stage) => sum + (stage.enhanced || 0), 0);
logSh(`✅ PIPELINE TERMINÉ: ${Object.keys(pipelineResults.content).length} éléments générés`, 'INFO');
logSh(` ⏱️ Durée totale: ${totalDuration}ms`, 'INFO');
logSh(` 📈 Enhancements: ${totalEnhanced} sur ${totalProcessed} éléments traités`, 'INFO');
// Log détaillé par étape
pipelineResults.stats.stages.forEach(stage => {
const enhancementRate = stage.processed > 0 ? Math.round((stage.enhanced / stage.processed) * 100) : 0;
logSh(` ${stage.stage}. ${stage.name}: ${stage.enhanced}/${stage.processed} (${enhancementRate}%) en ${stage.duration}ms`, 'DEBUG');
});
await tracer.event(`Pipeline ${pipelineName} terminé`, {
totalElements: Object.keys(pipelineResults.content).length,
totalEnhanced,
totalDuration,
stagesExecuted: pipelineResults.stats.stages.length
});
// Retourner uniquement le contenu pour compatibilité
return pipelineResults.content;
} catch (error) {
const totalDuration = Date.now() - startTime;
logSh(`❌ PIPELINE ÉCHOUÉ après ${totalDuration}ms: ${error.message}`, 'ERROR');
logSh(`❌ Stack trace: ${error.stack}`, 'DEBUG');
await tracer.event(`Pipeline ${pipelineName} échoué`, {
error: error.message,
duration: totalDuration
});
throw new Error(`ContentGeneration pipeline failed: ${error.message}`);
}
}, { hierarchy, csvData, options });
}
/**
* GÉNÉRATION SIMPLE (ÉTAPE 1 UNIQUEMENT)
* Pour tests ou fallback rapide
*/
async function generateSimple(hierarchy, csvData) {
logSh(`🔥 GÉNÉRATION SIMPLE: Claude uniquement`, 'INFO');
const result = await generateInitialContent({
hierarchy,
csvData,
context: { step: 1, totalSteps: 1, simple: true }
});
return result.content;
}
/**
* GÉNÉRATION AVANCÉE AVEC CONTRÔLE GRANULAIRE
* Permet de choisir exactement quelles étapes exécuter
*/
async function generateAdvanced(hierarchy, csvData, stageConfig = {}) {
const {
initial = true,
technical = true,
transitions = true,
style = true,
patternBreaking = false, // ✨ NOUVEAU: Niveau 2
patternBreakingConfig = {} // ✨ NOUVEAU: Config Pattern Breaking
} = stageConfig;
const options = {
skipTechnical: !technical,
skipTransitions: !transitions,
skipStyle: !style,
patternBreaking, // ✨ NOUVEAU
patternBreakingConfig // ✨ NOUVEAU
};
const activeStages = [
initial && 'Initial',
technical && 'Technical',
transitions && 'Transitions',
style && 'Style',
patternBreaking && 'PatternBreaking' // ✨ NOUVEAU
].filter(Boolean);
logSh(`🎛️ GÉNÉRATION AVANCÉE: ${activeStages.join(' + ')}`, 'INFO');
return await generateWithContext(hierarchy, csvData, options);
}
/**
* GÉNÉRATION NIVEAU 2 (AVEC PATTERN BREAKING)
* Shortcut pour activer Pattern Breaking facilement
*/
async function generateWithPatternBreaking(hierarchy, csvData, patternConfig = {}) {
logSh(`🎯 GÉNÉRATION NIVEAU 2: Pattern Breaking activé`, 'INFO');
const options = {
patternBreaking: true,
patternBreakingConfig: {
intensity: 0.6,
sentenceVariation: true,
fingerprintRemoval: true,
transitionHumanization: true,
...patternConfig
}
};
return await generateWithContext(hierarchy, csvData, options);
}
/**
* DIAGNOSTIC PIPELINE
* Exécute chaque étape avec mesures détaillées
*/
async function diagnosticPipeline(hierarchy, csvData) {
logSh(`🔬 MODE DIAGNOSTIC: Analyse détaillée pipeline`, 'INFO');
const diagnostics = {
stages: [],
errors: [],
performance: {},
content: {}
};
let currentContent = {};
try {
// Test étape 1
const step1Start = Date.now();
const step1Result = await generateInitialContent({ hierarchy, csvData });
diagnostics.stages.push({
stage: 1,
name: 'InitialGeneration',
success: true,
duration: Date.now() - step1Start,
elementsGenerated: Object.keys(step1Result.content).length,
stats: step1Result.stats
});
currentContent = step1Result.content;
} catch (error) {
diagnostics.errors.push({ stage: 1, error: error.message });
diagnostics.stages.push({ stage: 1, name: 'InitialGeneration', success: false });
return diagnostics;
}
// Test étapes 2-4 individuellement
const stages = [
{ stage: 2, name: 'TechnicalEnhancement', func: enhanceTechnicalTerms },
{ stage: 3, name: 'TransitionEnhancement', func: enhanceTransitions },
{ stage: 4, name: 'StyleEnhancement', func: applyPersonalityStyle }
];
for (const stageInfo of stages) {
try {
const stageStart = Date.now();
const stageResult = await stageInfo.func({ content: currentContent, csvData });
diagnostics.stages.push({
...stageInfo,
success: true,
duration: Date.now() - stageStart,
stats: stageResult.stats
});
currentContent = stageResult.content;
} catch (error) {
diagnostics.errors.push({ stage: stageInfo.stage, error: error.message });
diagnostics.stages.push({ ...stageInfo, success: false });
}
}
diagnostics.content = currentContent;
diagnostics.performance.totalDuration = diagnostics.stages.reduce((sum, stage) => sum + (stage.duration || 0), 0);
logSh(`🔬 DIAGNOSTIC TERMINÉ: ${diagnostics.stages.filter(s => s.success).length}/4 étapes réussies`, 'INFO');
return diagnostics;
}
module.exports = {
generateWithContext, // ← MAIN ENTRY POINT (compatible ancien code)
generateSimple, // ← Génération rapide
generateAdvanced, // ← Contrôle granulaire
generateWithPatternBreaking, // ← NOUVEAU: Niveau 2 shortcut
diagnosticPipeline // ← Tests et debug
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/ContentAssembly.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// FICHIER: ContentAssembly.js
// Description: Assemblage et nettoyage du contenu XML
// ========================================
const { logSh } = require('./ErrorReporting'); // Using unified logSh from ErrorReporting
/**
* Nettoie les balises du template XML
* @param {string} xmlString - Le contenu XML à nettoyer
* @returns {string} - XML nettoyé
*/
function cleanStrongTags(xmlString) {
logSh('Nettoyage balises du template...', 'DEBUG');
// Enlever toutes les balises et
let cleaned = xmlString.replace(/<\/?strong>/g, '');
// Log du nettoyage
const strongCount = (xmlString.match(/<\/?strong>/g) || []).length;
if (strongCount > 0) {
logSh(`${strongCount} balises supprimées`, 'INFO');
}
return cleaned;
}
/**
* Remplace toutes les variables CSV dans le XML
* @param {string} xmlString - Le contenu XML
* @param {object} csvData - Les données CSV
* @returns {string} - XML avec variables remplacées
*/
function replaceAllCSVVariables(xmlString, csvData) {
logSh('Remplacement variables CSV...', 'DEBUG');
let result = xmlString;
// Variables simples
result = result.replace(/\{\{T0\}\}/g, csvData.t0 || '');
result = result.replace(/\{\{MC0\}\}/g, csvData.mc0 || '');
result = result.replace(/\{\{T-1\}\}/g, csvData.tMinus1 || '');
result = result.replace(/\{\{L-1\}\}/g, csvData.lMinus1 || '');
logSh(`Variables simples remplacées: T0="${csvData.t0}", MC0="${csvData.mc0}"`, 'DEBUG');
// Variables multiples
const mcPlus1 = (csvData.mcPlus1 || '').split(',').map(s => s.trim());
const tPlus1 = (csvData.tPlus1 || '').split(',').map(s => s.trim());
const lPlus1 = (csvData.lPlus1 || '').split(',').map(s => s.trim());
logSh(`Variables multiples: MC+1[${mcPlus1.length}], T+1[${tPlus1.length}], L+1[${lPlus1.length}]`, 'DEBUG');
// Remplacer MC+1_1, MC+1_2, etc.
for (let i = 1; i <= 6; i++) {
const mcValue = mcPlus1[i-1] || `[MC+1_${i} non défini]`;
const tValue = tPlus1[i-1] || `[T+1_${i} non défini]`;
const lValue = lPlus1[i-1] || `[L+1_${i} non défini]`;
result = result.replace(new RegExp(`\\{\\{MC\\+1_${i}\\}\\}`, 'g'), mcValue);
result = result.replace(new RegExp(`\\{\\{T\\+1_${i}\\}\\}`, 'g'), tValue);
result = result.replace(new RegExp(`\\{\\{L\\+1_${i}\\}\\}`, 'g'), lValue);
if (mcPlus1[i-1]) {
logSh(`MC+1_${i} = "${mcValue}"`, 'DEBUG');
}
}
// Vérifier qu'il ne reste pas de variables non remplacées
const remainingVars = (result.match(/\{\{[^}]+\}\}/g) || []);
if (remainingVars.length > 0) {
logSh(`ATTENTION: Variables non remplacées: ${remainingVars.join(', ')}`, 'WARNING');
}
logSh('Toutes les variables CSV remplacées', 'INFO');
return result;
}
/**
* Injecte le contenu généré dans le XML final
* @param {string} cleanXML - XML nettoyé
* @param {object} generatedContent - Contenu généré par tag
* @param {array} elements - Éléments extraits
* @returns {string} - XML final avec contenu injecté
*/
function injectGeneratedContent(cleanXML, generatedContent, elements) {
logSh('🔍 === DEBUG INJECTION MAPPING ===', 'DEBUG');
logSh(`XML reçu: ${cleanXML.length} caractères`, 'DEBUG');
logSh(`Contenu généré: ${Object.keys(generatedContent).length} éléments`, 'DEBUG');
logSh(`Éléments fournis: ${elements.length} éléments`, 'DEBUG');
// Debug: montrer le XML
logSh(`🔍 XML début: ${cleanXML}`, 'DEBUG');
// Debug: montrer le contenu généré
Object.keys(generatedContent).forEach(key => {
logSh(`🔍 Généré [${key}]: "${generatedContent[key]}"`, 'DEBUG');
});
// Debug: montrer les éléments
elements.forEach((element, i) => {
logSh(`🔍 Element ${i+1}: originalTag="${element.originalTag}", originalFullMatch="${element.originalFullMatch}"`, 'DEBUG');
});
let finalXML = cleanXML;
// Créer un mapping tag pur → tag original complet
const tagMapping = {};
elements.forEach(element => {
tagMapping[element.originalTag] = element.originalFullMatch || element.originalTag;
});
logSh(`🔍 TagMapping créé: ${JSON.stringify(tagMapping, null, 2)}`, 'DEBUG');
// Remplacer en utilisant les tags originaux complets
Object.keys(generatedContent).forEach(pureTag => {
const content = generatedContent[pureTag];
logSh(`🔍 === TRAITEMENT TAG: ${pureTag} ===`, 'DEBUG');
logSh(`🔍 Contenu à injecter: "${content}"`, 'DEBUG');
// Trouver le tag original complet dans le XML
const originalTag = findOriginalTagInXML(finalXML, pureTag);
logSh(`🔍 Tag original trouvé: ${originalTag ? originalTag : 'AUCUN'}`, 'DEBUG');
if (originalTag) {
const beforeLength = finalXML.length;
finalXML = finalXML.replace(originalTag, content);
const afterLength = finalXML.length;
if (beforeLength !== afterLength) {
logSh(`✅ SUCCÈS: Remplacé ${originalTag} par contenu (${afterLength - beforeLength + originalTag.length} chars)`, 'DEBUG');
} else {
logSh(`❌ ÉCHEC: Replace n'a pas fonctionné pour ${originalTag}`, 'DEBUG');
}
} else {
// Fallback : essayer avec le tag pur
const beforeLength = finalXML.length;
finalXML = finalXML.replace(pureTag, content);
const afterLength = finalXML.length;
logSh(`⚠ FALLBACK ${pureTag}: remplacement ${beforeLength !== afterLength ? 'RÉUSSI' : 'ÉCHOUÉ'}`, 'DEBUG');
logSh(`⚠ Contenu fallback: "${content}"`, 'DEBUG');
}
});
// Vérifier les tags restants
const remainingTags = (finalXML.match(/\|[^|]*\|/g) || []);
if (remainingTags.length > 0) {
logSh(`ATTENTION: ${remainingTags.length} tags non remplacés: ${remainingTags.slice(0, 3).join(', ')}...`, 'WARNING');
}
logSh('Injection terminée', 'INFO');
return finalXML;
}
/**
* Helper pour trouver le tag original complet dans le XML
* @param {string} xmlString - Contenu XML
* @param {string} pureTag - Tag pur à rechercher
* @returns {string|null} - Tag original trouvé ou null
*/
function findOriginalTagInXML(xmlString, pureTag) {
logSh(`🔍 === RECHERCHE TAG DANS XML ===`, 'DEBUG');
logSh(`🔍 Tag pur recherché: "${pureTag}"`, 'DEBUG');
// Extraire le nom du tag pur : |Titre_H1_1| → Titre_H1_1
const tagName = pureTag.replace(/\|/g, '');
logSh(`🔍 Nom tag extrait: "${tagName}"`, 'DEBUG');
// Chercher tous les tags qui commencent par ce nom (avec espaces optionnels)
const regex = new RegExp(`\\|\\s*${tagName}[^|]*\\|`, 'g');
logSh(`🔍 Regex utilisée: ${regex}`, 'DEBUG');
// Debug: montrer tous les tags présents dans le XML
const allTags = xmlString.match(/\|[^|]*\|/g) || [];
logSh(`🔍 Tags présents dans XML: ${allTags.length}`, 'DEBUG');
allTags.forEach((tag, i) => {
logSh(`🔍 ${i+1}. "${tag}"`, 'DEBUG');
});
const matches = xmlString.match(regex);
logSh(`🔍 Matches trouvés: ${matches ? matches.length : 0}`, 'DEBUG');
if (matches && matches.length > 0) {
logSh(`🔍 Premier match: "${matches[0]}"`, 'DEBUG');
logSh(`✅ Tag original trouvé pour ${pureTag}: ${matches[0]}`, 'DEBUG');
return matches[0];
}
logSh(`❌ Aucun tag original trouvé pour ${pureTag}`, 'DEBUG');
return null;
}
// ============= EXPORTS =============
module.exports = {
cleanStrongTags,
replaceAllCSVVariables,
injectGeneratedContent,
findOriginalTagInXML
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/ArticleStorage.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// FICHIER: ArticleStorage.js
// Description: Système de sauvegarde articles avec texte compilé uniquement
// ========================================
require('dotenv').config();
const { google } = require('googleapis');
const { logSh } = require('./ErrorReporting');
// Configuration Google Sheets
const SHEET_CONFIG = {
sheetId: '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c'
};
/**
* NOUVELLE FONCTION : Compiler le contenu de manière organique
* Respecte la hiérarchie et les associations naturelles
*/
async function compileGeneratedTextsOrganic(generatedTexts, elements) {
if (!generatedTexts || Object.keys(generatedTexts).length === 0) {
return '';
}
logSh(`🌱 Compilation ORGANIQUE de ${Object.keys(generatedTexts).length} éléments...`, 'DEBUG');
let compiledParts = [];
// 1. DÉTECTER et GROUPER les sections organiques
const organicSections = buildOrganicSections(generatedTexts, elements);
// 2. COMPILER dans l'ordre naturel
organicSections.forEach(section => {
if (section.type === 'header_with_content') {
// H1, H2, H3 avec leur contenu associé
if (section.title) {
compiledParts.push(cleanIndividualContent(section.title));
}
if (section.content) {
compiledParts.push(cleanIndividualContent(section.content));
}
}
else if (section.type === 'standalone_content') {
// Contenu sans titre associé
compiledParts.push(cleanIndividualContent(section.content));
}
else if (section.type === 'faq_pair') {
// Paire question + réponse
if (section.question && section.answer) {
compiledParts.push(cleanIndividualContent(section.question));
compiledParts.push(cleanIndividualContent(section.answer));
}
}
});
// 3. Joindre avec espacement naturel
const finalText = compiledParts.join('\n\n');
logSh(`✅ Compilation organique terminée: ${finalText.length} caractères`, 'INFO');
return finalText.trim();
}
/**
* Construire les sections organiques en analysant les associations
*/
function buildOrganicSections(generatedTexts, elements) {
const sections = [];
const usedTags = new Set();
// 1. ANALYSER l'ordre original des éléments
const originalOrder = elements ? elements.map(el => el.originalTag) : Object.keys(generatedTexts);
logSh(`📋 Analyse de ${originalOrder.length} éléments dans l'ordre original...`, 'DEBUG');
// 2. DÉTECTER les associations naturelles
for (let i = 0; i < originalOrder.length; i++) {
const currentTag = originalOrder[i];
const currentContent = generatedTexts[currentTag];
if (!currentContent || usedTags.has(currentTag)) continue;
const currentType = identifyElementType(currentTag);
if (currentType === 'titre_h1' || currentType === 'titre_h2' || currentType === 'titre_h3') {
// CHERCHER le contenu associé qui suit
const associatedContent = findAssociatedContent(originalOrder, i, generatedTexts, usedTags);
sections.push({
type: 'header_with_content',
title: currentContent,
content: associatedContent.content,
titleTag: currentTag,
contentTag: associatedContent.tag
});
usedTags.add(currentTag);
if (associatedContent.tag) {
usedTags.add(associatedContent.tag);
}
logSh(` ✓ Section: ${currentType} + contenu associé`, 'DEBUG');
}
else if (currentType === 'faq_question') {
// CHERCHER la réponse correspondante
const matchingAnswer = findMatchingFAQAnswer(currentTag, generatedTexts);
if (matchingAnswer) {
sections.push({
type: 'faq_pair',
question: currentContent,
answer: matchingAnswer.content,
questionTag: currentTag,
answerTag: matchingAnswer.tag
});
usedTags.add(currentTag);
usedTags.add(matchingAnswer.tag);
logSh(` ✓ Paire FAQ: ${currentTag} + ${matchingAnswer.tag}`, 'DEBUG');
}
}
else if (currentType !== 'faq_reponse') {
// CONTENU STANDALONE (pas une réponse FAQ déjà traitée)
sections.push({
type: 'standalone_content',
content: currentContent,
contentTag: currentTag
});
usedTags.add(currentTag);
logSh(` ✓ Contenu standalone: ${currentType}`, 'DEBUG');
}
}
logSh(`🏗️ ${sections.length} sections organiques construites`, 'INFO');
return sections;
}
/**
* Trouver le contenu associé à un titre (paragraphe qui suit)
*/
function findAssociatedContent(originalOrder, titleIndex, generatedTexts, usedTags) {
// Chercher dans les éléments suivants
for (let j = titleIndex + 1; j < originalOrder.length; j++) {
const nextTag = originalOrder[j];
const nextContent = generatedTexts[nextTag];
if (!nextContent || usedTags.has(nextTag)) continue;
const nextType = identifyElementType(nextTag);
// Si on trouve un autre titre, on s'arrête
if (nextType === 'titre_h1' || nextType === 'titre_h2' || nextType === 'titre_h3') {
break;
}
// Si on trouve du contenu (texte, intro), c'est probablement associé
if (nextType === 'texte' || nextType === 'intro') {
return {
content: nextContent,
tag: nextTag
};
}
}
return { content: null, tag: null };
}
/**
* Extraire le numéro d'une FAQ : |Faq_q_1| ou |Faq_a_2| → "1" ou "2"
*/
function extractFAQNumber(tag) {
const match = tag.match(/(\d+)/);
return match ? match[1] : null;
}
/**
* Trouver la réponse FAQ correspondant à une question
*/
function findMatchingFAQAnswer(questionTag, generatedTexts) {
// Extraire le numéro : |Faq_q_1| → 1
const questionNumber = extractFAQNumber(questionTag);
if (!questionNumber) return null;
// Chercher la réponse correspondante
for (const tag in generatedTexts) {
const tagType = identifyElementType(tag);
if (tagType === 'faq_reponse') {
const answerNumber = extractFAQNumber(tag);
if (answerNumber === questionNumber) {
return {
content: generatedTexts[tag],
tag: tag
};
}
}
}
return null;
}
/**
* Nouvelle fonction de sauvegarde avec compilation organique
*/
async function saveGeneratedArticleOrganic(articleData, csvData, config = {}) {
try {
logSh('💾 Sauvegarde article avec compilation organique...', 'INFO');
const sheets = await getSheetsClient();
// Vérifier si la sheet existe, sinon la créer
let articlesSheet = await getOrCreateSheet(sheets, 'Generated_Articles');
// ===== COMPILATION ORGANIQUE =====
const compiledText = await compileGeneratedTextsOrganic(
articleData.generatedTexts,
articleData.originalElements // Passer les éléments originaux si disponibles
);
logSh(`📝 Texte compilé organiquement: ${compiledText.length} caractères`, 'INFO');
// Métadonnées avec format français
const now = new Date();
const frenchTimestamp = formatDateToFrench(now);
// UTILISER le slug du CSV (colonne A du Google Sheet source)
// Le slug doit venir de csvData.slug (récupéré via getBrainConfig)
const slug = csvData.slug || generateSlugFromContent(csvData.mc0, csvData.t0);
const metadata = {
timestamp: frenchTimestamp,
slug: slug,
mc0: csvData.mc0,
t0: csvData.t0,
personality: csvData.personality?.nom || 'Unknown',
antiDetectionLevel: config.antiDetectionLevel || 'MVP',
elementsCount: Object.keys(articleData.generatedTexts || {}).length,
textLength: compiledText.length,
wordCount: countWords(compiledText),
llmUsed: config.llmUsed || 'openai',
validationStatus: articleData.validationReport?.status || 'unknown'
};
// Préparer la ligne de données
const row = [
metadata.timestamp,
metadata.slug,
metadata.mc0,
metadata.t0,
metadata.personality,
metadata.antiDetectionLevel,
compiledText, // ← TEXTE ORGANIQUE
metadata.textLength,
metadata.wordCount,
metadata.elementsCount,
metadata.llmUsed,
metadata.validationStatus,
'', '', '', '',
JSON.stringify({
csvData: csvData,
config: config,
stats: metadata
})
];
// DEBUG: Vérifier le slug généré
logSh(`💾 Sauvegarde avec slug: "${metadata.slug}" (colonne B)`, 'DEBUG');
// Ajouter la ligne aux données
await sheets.spreadsheets.values.append({
spreadsheetId: SHEET_CONFIG.sheetId,
range: 'Generated_Articles!A:Q',
valueInputOption: 'USER_ENTERED',
resource: {
values: [row]
}
});
// Récupérer le numéro de ligne pour l'ID article
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SHEET_CONFIG.sheetId,
range: 'Generated_Articles!A:A'
});
const articleId = response.data.values ? response.data.values.length - 1 : 1;
logSh(`✅ Article organique sauvé: ID ${articleId}, ${metadata.wordCount} mots`, 'INFO');
return {
articleId: articleId,
textLength: metadata.textLength,
wordCount: metadata.wordCount,
sheetRow: response.data.values ? response.data.values.length : 2
};
} catch (error) {
logSh(`❌ Erreur sauvegarde organique: ${error.toString()}`, 'ERROR');
throw error;
}
}
/**
* Générer un slug à partir du contenu MC0 et T0
*/
function generateSlugFromContent(mc0, t0) {
if (!mc0 && !t0) return 'article-generated';
const source = mc0 || t0;
return source
.toString()
.toLowerCase()
.replace(/[àáâäã]/g, 'a')
.replace(/[èéêë]/g, 'e')
.replace(/[ìíîï]/g, 'i')
.replace(/[òóôöõ]/g, 'o')
.replace(/[ùúûü]/g, 'u')
.replace(/[ç]/g, 'c')
.replace(/[ñ]/g, 'n')
.replace(/[^a-z0-9\s-]/g, '') // Enlever caractères spéciaux
.replace(/\s+/g, '-') // Espaces -> tirets
.replace(/-+/g, '-') // Éviter doubles tirets
.replace(/^-+|-+$/g, '') // Enlever tirets début/fin
.substring(0, 50); // Limiter longueur
}
/**
* Identifier le type d'élément par son tag
*/
function identifyElementType(tag) {
const cleanTag = tag.toLowerCase().replace(/[|{}]/g, '');
if (cleanTag.includes('titre_h1') || cleanTag.includes('h1')) return 'titre_h1';
if (cleanTag.includes('titre_h2') || cleanTag.includes('h2')) return 'titre_h2';
if (cleanTag.includes('titre_h3') || cleanTag.includes('h3')) return 'titre_h3';
if (cleanTag.includes('intro')) return 'intro';
if (cleanTag.includes('faq_q') || cleanTag.includes('faq_question')) return 'faq_question';
if (cleanTag.includes('faq_a') || cleanTag.includes('faq_reponse')) return 'faq_reponse';
return 'texte'; // Par défaut
}
/**
* Nettoyer un contenu individuel
*/
function cleanIndividualContent(content) {
if (!content) return '';
let cleaned = content.toString();
// 1. Supprimer les balises HTML
cleaned = cleaned.replace(/<[^>]*>/g, '');
// 2. Décoder les entités HTML
cleaned = cleaned.replace(/</g, '<');
cleaned = cleaned.replace(/>/g, '>');
cleaned = cleaned.replace(/&/g, '&');
cleaned = cleaned.replace(/"/g, '"');
cleaned = cleaned.replace(/'/g, "'");
cleaned = cleaned.replace(/ /g, ' ');
// 3. Nettoyer les espaces
cleaned = cleaned.replace(/\s+/g, ' ');
cleaned = cleaned.replace(/\n\s+/g, '\n');
// 4. Supprimer les caractères de contrôle étranges
cleaned = cleaned.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
return cleaned.trim();
}
/**
* Créer la sheet de stockage avec headers appropriés
*/
async function createArticlesStorageSheet(sheets) {
logSh('🗄️ Création sheet Generated_Articles...', 'INFO');
try {
// Créer la nouvelle sheet
await sheets.spreadsheets.batchUpdate({
spreadsheetId: SHEET_CONFIG.sheetId,
resource: {
requests: [{
addSheet: {
properties: {
title: 'Generated_Articles'
}
}
}]
}
});
// Headers
const headers = [
'Timestamp',
'Slug',
'MC0',
'T0',
'Personality',
'AntiDetection_Level',
'Compiled_Text', // ← COLONNE PRINCIPALE
'Text_Length',
'Word_Count',
'Elements_Count',
'LLM_Used',
'Validation_Status',
'GPTZero_Score', // Scores détecteurs (à remplir)
'Originality_Score',
'CopyLeaks_Score',
'Human_Quality_Score',
'Full_Metadata_JSON' // Backup complet
];
// Ajouter les headers
await sheets.spreadsheets.values.update({
spreadsheetId: SHEET_CONFIG.sheetId,
range: 'Generated_Articles!A1:Q1',
valueInputOption: 'USER_ENTERED',
resource: {
values: [headers]
}
});
// Formatter les headers
await sheets.spreadsheets.batchUpdate({
spreadsheetId: SHEET_CONFIG.sheetId,
resource: {
requests: [{
repeatCell: {
range: {
sheetId: await getSheetIdByName(sheets, 'Generated_Articles'),
startRowIndex: 0,
endRowIndex: 1,
startColumnIndex: 0,
endColumnIndex: headers.length
},
cell: {
userEnteredFormat: {
textFormat: {
bold: true
},
backgroundColor: {
red: 0.878,
green: 0.878,
blue: 0.878
},
horizontalAlignment: 'CENTER'
}
},
fields: 'userEnteredFormat(textFormat,backgroundColor,horizontalAlignment)'
}
}]
}
});
logSh('✅ Sheet Generated_Articles créée avec succès', 'INFO');
return true;
} catch (error) {
logSh(`❌ Erreur création sheet: ${error.toString()}`, 'ERROR');
throw error;
}
}
/**
* Formater date au format français DD/MM/YYYY HH:mm:ss
*/
function formatDateToFrench(date) {
// Utiliser toLocaleString avec le format français
return date.toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: 'Europe/Paris'
}).replace(',', '');
}
/**
* Compter les mots dans un texte
*/
function countWords(text) {
if (!text || text.trim() === '') return 0;
return text.trim().split(/\s+/).length;
}
/**
* Récupérer un article sauvé par ID
*/
async function getStoredArticle(articleId) {
try {
const sheets = await getSheetsClient();
const rowNumber = articleId + 2; // +2 car header + 0-indexing
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SHEET_CONFIG.sheetId,
range: `Generated_Articles!A${rowNumber}:Q${rowNumber}`
});
if (!response.data.values || response.data.values.length === 0) {
throw new Error(`Article ${articleId} non trouvé`);
}
const data = response.data.values[0];
return {
articleId: articleId,
timestamp: data[0],
slug: data[1],
mc0: data[2],
t0: data[3],
personality: data[4],
antiDetectionLevel: data[5],
compiledText: data[6], // ← TEXTE PUR
textLength: data[7],
wordCount: data[8],
elementsCount: data[9],
llmUsed: data[10],
validationStatus: data[11],
gptZeroScore: data[12],
originalityScore: data[13],
copyLeaksScore: data[14],
humanScore: data[15],
fullMetadata: data[16] ? JSON.parse(data[16]) : null
};
} catch (error) {
logSh(`❌ Erreur récupération article ${articleId}: ${error.toString()}`, 'ERROR');
throw error;
}
}
/**
* Lister les derniers articles générés
*/
async function getRecentArticles(limit = 10) {
try {
const sheets = await getSheetsClient();
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SHEET_CONFIG.sheetId,
range: 'Generated_Articles!A:L'
});
if (!response.data.values || response.data.values.length <= 1) {
return []; // Pas de données ou seulement headers
}
const data = response.data.values.slice(1); // Exclure headers
const startIndex = Math.max(0, data.length - limit);
const recentData = data.slice(startIndex);
return recentData.map((row, index) => ({
articleId: startIndex + index,
timestamp: row[0],
slug: row[1],
mc0: row[2],
personality: row[4],
antiDetectionLevel: row[5],
wordCount: row[8],
validationStatus: row[11]
})).reverse(); // Plus récents en premier
} catch (error) {
logSh(`❌ Erreur liste articles récents: ${error.toString()}`, 'ERROR');
return [];
}
}
/**
* Mettre à jour les scores de détection d'un article
*/
async function updateDetectionScores(articleId, scores) {
try {
const sheets = await getSheetsClient();
const rowNumber = articleId + 2;
const updates = [];
// Colonnes des scores : M, N, O (GPTZero, Originality, CopyLeaks)
if (scores.gptzero !== undefined) {
updates.push({
range: `Generated_Articles!M${rowNumber}`,
values: [[scores.gptzero]]
});
}
if (scores.originality !== undefined) {
updates.push({
range: `Generated_Articles!N${rowNumber}`,
values: [[scores.originality]]
});
}
if (scores.copyleaks !== undefined) {
updates.push({
range: `Generated_Articles!O${rowNumber}`,
values: [[scores.copyleaks]]
});
}
if (updates.length > 0) {
await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: SHEET_CONFIG.sheetId,
resource: {
valueInputOption: 'USER_ENTERED',
data: updates
}
});
}
logSh(`✅ Scores détection mis à jour pour article ${articleId}`, 'INFO');
} catch (error) {
logSh(`❌ Erreur maj scores article ${articleId}: ${error.toString()}`, 'ERROR');
throw error;
}
}
// ============= HELPERS GOOGLE SHEETS =============
/**
* Obtenir le client Google Sheets authentifié
*/
async function getSheetsClient() {
const auth = new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n')
},
scopes: ['https://www.googleapis.com/auth/spreadsheets']
});
const authClient = await auth.getClient();
const sheets = google.sheets({ version: 'v4', auth: authClient });
return sheets;
}
/**
* Obtenir ou créer une sheet
*/
async function getOrCreateSheet(sheets, sheetName) {
try {
// Vérifier si la sheet existe
const response = await sheets.spreadsheets.get({
spreadsheetId: SHEET_CONFIG.sheetId
});
const existingSheet = response.data.sheets.find(
sheet => sheet.properties.title === sheetName
);
if (existingSheet) {
return existingSheet;
} else {
// Créer la sheet si elle n'existe pas
if (sheetName === 'Generated_Articles') {
await createArticlesStorageSheet(sheets);
return await getOrCreateSheet(sheets, sheetName); // Récursif pour récupérer la sheet créée
}
throw new Error(`Sheet ${sheetName} non supportée pour création automatique`);
}
} catch (error) {
logSh(`❌ Erreur accès/création sheet ${sheetName}: ${error.toString()}`, 'ERROR');
throw error;
}
}
/**
* Obtenir l'ID d'une sheet par son nom
*/
async function getSheetIdByName(sheets, sheetName) {
const response = await sheets.spreadsheets.get({
spreadsheetId: SHEET_CONFIG.sheetId
});
const sheet = response.data.sheets.find(
s => s.properties.title === sheetName
);
return sheet ? sheet.properties.sheetId : null;
}
// ============= EXPORTS =============
module.exports = {
compileGeneratedTextsOrganic,
buildOrganicSections,
findAssociatedContent,
extractFAQNumber,
findMatchingFAQAnswer,
saveGeneratedArticleOrganic,
identifyElementType,
cleanIndividualContent,
createArticlesStorageSheet,
formatDateToFrench,
countWords,
getStoredArticle,
getRecentArticles,
updateDetectionScores,
getSheetsClient,
getOrCreateSheet,
getSheetIdByName,
generateSlugFromContent
};
/*
┌────────────────────────────────────────────────────────────────────┐
│ File: lib/DigitalOceanWorkflow.js │
└────────────────────────────────────────────────────────────────────┘
*/
// ========================================
// FICHIER: DigitalOceanWorkflow.js - REFACTORISÉ POUR NODE.JS
// RESPONSABILITÉ: Orchestration + Interface Digital Ocean UNIQUEMENT
// ========================================
const crypto = require('crypto');
const axios = require('axios');
const { GoogleSpreadsheet } = require('google-spreadsheet');
const { JWT } = require('google-auth-library');
// Import des autres modules du projet (à adapter selon votre structure)
const { logSh } = require('./ErrorReporting');
const { handleFullWorkflow } = require('./Main');
const { getPersonalities, selectPersonalityWithAI } = require('./BrainConfig');
// ============= CONFIGURATION DIGITAL OCEAN =============
const DO_CONFIG = {
endpoint: 'https://autocollant.fra1.digitaloceanspaces.com',
bucketName: 'autocollant',
accessKeyId: 'DO801XTYPE968NZGAQM3',
secretAccessKey: '5aCCBiS9K+J8gsAe3M3/0GlliHCNjtLntwla1itCN1s',
region: 'fra1'
};
// Configuration Google Sheets
const SHEET_CONFIG = {
sheetId: '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c',
serviceAccountEmail: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
privateKey: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
// Alternative: utiliser fichier JSON directement
keyFile: './seo-generator-470715-85d4a971c1af.json'
};
async function deployArticle({ path, html, dryRun = false, ...rest }) {
if (!path || typeof html !== 'string') {
const err = new Error('deployArticle: invalid payload (requires {path, html})');
err.code = 'E_PAYLOAD';
throw err;
}
if (dryRun) {
return {
ok: true,
dryRun: true,
length: html.length,
path,
meta: rest || {}
};
}
// --- Impl réelle à toi ici (upload DO Spaces / API / SSH etc.) ---
// return await realDeploy({ path, html, ...rest });
// Placeholder pour ne pas casser l'appel si pas encore implémenté
return { ok: true, dryRun: false, path, length: html.length };
}
module.exports.deployArticle = module.exports.deployArticle || deployArticle;
// ============= TRIGGER PRINCIPAL REMPLACÉ PAR WEBHOOK/API =============
/**
* Point d'entrée pour déclencher le workflow
* Remplace le trigger onEdit d'Apps Script
* @param {number} rowNumber - Numéro de ligne à traiter
* @returns {Promise