seo-generator-server/lib/MissingKeywords.js
StillHammer 3751ab047b feat(keywords): Add hierarchical context to missing keywords prompt and fix LLM response format
This commit improves keyword generation by providing hierarchical context for each element and fixing the LLM response format parsing.

Changes:
1. lib/MissingKeywords.js:
   - Add buildHierarchicalContext() to generate compact contextual info for each element
   - Display hierarchy in prompt (e.g., "H2 existants: 'Titre1', 'Titre2'")
   - For Txt elements: show associated MC keyword + parent title
   - For FAQ elements: count existing FAQs
   - Fix LLM response format by providing 3 concrete examples from actual list
   - Add explicit warning to use exact tag names [Titre_H2_3], [Txt_H2_6]
   - Improve getElementContext() to better retrieve hierarchical elements

2. lib/selective-enhancement/SelectiveUtils.js:
   - Fix createTypedPrompt() to use specific keyword from resolvedContent
   - Remove fallback to csvData.mc0 (log error if no specific keyword)

3. lib/pipeline/PipelineExecutor.js:
   - Integrate generateMissingSheetVariables() as "Étape 0" before extraction

Prompt format now:
  1. [Titre_H2_3] (titre) — H2 existants: "Titre1", "Titre2"
  2. [Txt_H2_6] (texte) — MC: "Plaque dibond" | Parent: "Guide dibond"
  3. [Faq_q_1] (question) — 3 FAQ existantes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 14:51:01 +08:00

1031 lines
39 KiB
JavaScript

// ========================================
// FICHIER: MissingKeywords.js - Version Node.js
// Description: Génération automatique des mots-clés manquants
// ========================================
const { logSh } = require('./ErrorReporting');
const { callLLM } = require('./LLMManager');
const { logElementsList } = require('./selective-enhancement/SelectiveUtils');
// ============================================
// OPTION B: Détection et génération multi-variables
// Ajout conservatif - ne remplace pas l'existant
// ============================================
/**
* Extraire les variables individuelles d'un resolvedContent
* Ex: "[T+1_5 non défini][MC+1_5 non défini]" → ["T+1_5", "MC+1_5"]
*/
function extractIndividualPlaceholders(resolvedContent) {
if (!resolvedContent || typeof resolvedContent !== 'string') {
return [];
}
const regex = /\[([A-Z]+[+-]\d+_\d+) non (défini|résolu)\]/g;
const placeholders = [];
let match;
while ((match = regex.exec(resolvedContent)) !== null) {
const varName = match[1];
if (!placeholders.includes(varName)) {
placeholders.push(varName);
}
}
return placeholders;
}
/**
* Déterminer le type d'une variable (pour contexte de génération)
*/
function getVariableType(varName) {
if (varName.startsWith('T+') || varName.startsWith('T-')) {
return 'titre'; // Titre court
} else if (varName.startsWith('MC+') || varName.startsWith('MC-')) {
return 'mot-clé'; // Mot-clé SEO
} else if (varName.startsWith('L+') || varName.startsWith('L-')) {
return 'lien'; // Slug URL
}
return 'texte'; // Fallback
}
/**
* Analyser quels éléments ont plusieurs placeholders
* Retourne: { elementName: [variables], ... }
*/
function analyzeMultiPlaceholderElements(missingElements) {
const multiVarElements = {};
missingElements.forEach(element => {
const placeholders = extractIndividualPlaceholders(element.currentContent);
if (placeholders.length > 1) {
multiVarElements[element.name] = placeholders;
logSh(` 🔍 Élément multi-variables détecté: [${element.name}] a ${placeholders.length} variables: ${placeholders.join(', ')}`, 'DEBUG');
}
});
return multiVarElements;
}
/**
* 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 => {
// Détecter les patterns de contenu manquant
const isNonDefini = element.resolvedContent.includes('non défini');
const isNonResolu = element.resolvedContent.includes('non résolu');
const isEmpty = element.resolvedContent.trim() === '';
// Détecter aussi les patterns comme [MC+1_5 non défini], [T+1_3 non défini], etc.
const hasPlaceholder = /\[(MC|T|L)[+-]\d+_\d+ non défini\]/i.test(element.resolvedContent);
if (isNonDefini || isNonResolu || isEmpty || hasPlaceholder) {
logSh(`🔍 Élément manquant détecté: [${element.name}] = "${element.resolvedContent}"`, 'DEBUG');
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 elements; // Retourner les éléments inchangés
}
logSh(`⚠️ ${missingElements.length} mots-clés manquants détectés:`, 'INFO');
missingElements.forEach((missing, index) => {
logSh(` ${index + 1}. [${missing.name}] = "${missing.currentContent}"`, 'INFO');
});
// 2. ANALYSER le contexte global disponible
const contextAnalysis = analyzeAvailableContext(elements, csvData);
// 3. GÉNÉRER tous les manquants en UN SEUL appel IA
let generatedKeywords = await callOpenAIForMissingKeywords(missingElements, contextAnalysis, csvData);
// ✅ 3.5. RETRY pour éléments encore manquants
if (generatedKeywords._missingElements && generatedKeywords._missingElements.length > 0) {
const stillMissingNames = generatedKeywords._missingElements;
logSh(`🔄 RETRY: Génération ciblée pour ${stillMissingNames.length} éléments manquants`, 'INFO');
// Filtrer les éléments encore manquants
const retryElements = missingElements.filter(e => stillMissingNames.includes(e.name));
try {
// Retry avec un prompt simplifié
const retryKeywords = await callOpenAIForMissingKeywords(retryElements, contextAnalysis, csvData);
// Merger les résultats du retry avec les résultats existants
Object.keys(retryKeywords).forEach(key => {
if (key !== '_missingElements' && key !== '_subVariables') {
generatedKeywords[key] = retryKeywords[key];
}
});
logSh(`✅ RETRY réussi: ${Object.keys(retryKeywords).length - (retryKeywords._subVariables ? 1 : 0)} éléments supplémentaires générés`, 'INFO');
} catch (retryError) {
logSh(`⚠️ RETRY échoué: ${retryError.message}`, 'WARN');
logSh(`⚠️ Le workflow continuera avec les éléments disponibles`, 'WARN');
}
}
// 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');
// 📊 LOG COMPLET: Afficher TOUS les mots-clés (Google Sheets + Générés)
logSh(`\n📋 RÉCAPITULATIF COMPLET DES MOTS-CLÉS AVANT GÉNÉRATION:`, 'INFO');
// 1. Mots-clés récupérés depuis Google Sheets
logSh(`\n🔹 MOTS-CLÉS GOOGLE SHEETS:`, 'INFO');
logSh(` MC0 (principal): "${csvData.mc0 || 'VIDE'}"`, 'INFO');
logSh(` T0 (titre principal): "${csvData.t0 || 'VIDE'}"`, 'INFO');
logSh(` T-1: "${csvData.tMinus1 || 'VIDE'}"`, 'INFO');
logSh(` L-1: "${csvData.lMinus1 || 'VIDE'}"`, 'INFO');
if (csvData.mcPlus1) {
const mcPlus1Array = csvData.mcPlus1.split(',').map(s => s.trim());
logSh(` MC+1 (${mcPlus1Array.length} valeurs):`, 'INFO');
mcPlus1Array.forEach((val, idx) => {
logSh(` [${idx + 1}] "${val}"`, 'INFO');
});
}
if (csvData.tPlus1) {
const tPlus1Array = csvData.tPlus1.split(',').map(s => s.trim());
logSh(` T+1 (${tPlus1Array.length} valeurs):`, 'INFO');
tPlus1Array.forEach((val, idx) => {
logSh(` [${idx + 1}] "${val}"`, 'INFO');
});
}
if (csvData.lPlus1) {
const lPlus1Array = csvData.lPlus1.split(',').map(s => s.trim());
logSh(` L+1 (${lPlus1Array.length} valeurs):`, 'INFO');
lPlus1Array.forEach((val, idx) => {
logSh(` [${idx + 1}] "${val}"`, 'INFO');
});
}
// 2. Mots-clés générés par l'IA
logSh(`\n🔹 MOTS-CLÉS GÉNÉRÉS PAR IA:`, 'INFO');
const subVariables = generatedKeywords._subVariables || {};
Object.keys(generatedKeywords).forEach(key => {
if (key === '_subVariables' || key === '_missingElements') return;
logSh(` [${key}]: "${generatedKeywords[key]}"`, 'INFO');
});
if (Object.keys(subVariables).length > 0) {
logSh(`\n 📌 Sous-variables (${Object.keys(subVariables).length}):`, 'INFO');
Object.keys(subVariables).forEach(key => {
logSh(` [${key}]: "${subVariables[key]}"`, 'INFO');
});
}
// 📊 LOG COMPLET: Afficher TOUS les éléments combinés (Google Sheets + IA)
logElementsList(updatedElements, 'LISTE COMPLÈTE COMBINÉE (Google Sheets + IA)', generatedKeywords);
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 = [];
// ✅ NOUVEAU : Inclure les mots-clés de csvData (incluant ceux générés à l'étape 0)
if (csvData.mcPlus1) {
availableKeywords.push(...csvData.mcPlus1.split(',').map(s => s.trim()).filter(s => s));
}
if (csvData.tPlus1) {
availableKeywords.push(...csvData.tPlus1.split(',').map(s => s.trim()).filter(s => s));
}
if (csvData.lPlus1) {
availableKeywords.push(...csvData.lPlus1.split(',').map(s => s.trim()).filter(s => s));
}
if (csvData.tMinus1) {
availableKeywords.push(csvData.tMinus1);
}
// Récupérer tous les mots-clés/contenu déjà disponibles depuis les éléments
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, // ✅ Maintenant inclut csvData + elements
availableContent: availableContent,
theme: csvData.mc0 // Thème principal
};
}
/**
* 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_H2", "Txt_H2"
// Récupérer TOUS les éléments du même type (même baseLevel) qui sont déjà résolus
allElements.forEach(otherElement => {
// Vérifier que l'élément est du même type et déjà résolu
if (otherElement.name !== element.name && // Pas l'élément lui-même
otherElement.name.startsWith(baseLevel) &&
otherElement.resolvedContent &&
!otherElement.resolvedContent.includes('non défini') &&
!otherElement.resolvedContent.includes('non résolu') &&
otherElement.resolvedContent.trim() !== '') {
context.nearbyElements.push(otherElement.resolvedContent);
}
});
}
return context;
}
/**
* Construire le contexte hiérarchique compact pour un élément
* @param {Object} missing - Élément manquant
* @param {Array} allMissingElements - Tous les éléments manquants
* @param {Object} csvData - Données CSV
* @returns {string} Contexte compact (ex: "H2 existants: 'Titre1', 'Titre2'")
*/
function buildHierarchicalContext(missing, allMissingElements, csvData) {
const elementName = missing.name;
const parts = elementName.split('_');
// Récupérer le contexte depuis missing.context.nearbyElements (déjà calculé par getElementContext)
const nearbyElements = missing.context?.nearbyElements || [];
// Déterminer le type d'élément
if (elementName.startsWith('Titre_H2')) {
// Pour un titre H2 : afficher les autres titres H2 existants
const existingH2 = nearbyElements.filter(el => el && el.length > 0).slice(0, 3);
if (existingH2.length > 0) {
const shortened = existingH2.map(t => t.length > 30 ? t.substring(0, 30) + '...' : t);
return `H2 existants: ${shortened.map(t => `"${t}"`).join(', ')}`;
}
return 'Premier H2 du document';
} else if (elementName.startsWith('Titre_H3')) {
// Pour un titre H3 : afficher les autres H3 + parent H2
const existingH3 = nearbyElements.filter(el => el && el.length > 0).slice(0, 2);
if (existingH3.length > 0) {
const shortened = existingH3.map(t => t.length > 30 ? t.substring(0, 30) + '...' : t);
return `H3 existants: ${shortened.map(t => `"${t}"`).join(', ')}`;
}
return 'Premier H3 dans cette section';
} else if (elementName.startsWith('Txt_')) {
// Pour un texte : afficher MC associé + titre parent + autres textes
const level = elementName.match(/Txt_(H\d+)_(\d+)/);
if (level) {
const hierarchyLevel = level[1]; // H2, H3, etc.
const index = parseInt(level[2]); // 6, 7, etc.
// Chercher le MC correspondant (MC+1_6 pour Txt_H2_6)
const mcKey = `MC+1_${index}`;
let mcValue = null;
if (csvData.mcPlus1) {
const mcArray = csvData.mcPlus1.split(',').map(s => s.trim());
if (mcArray[index - 1]) {
mcValue = mcArray[index - 1];
}
}
// Chercher le titre parent
const titleName = `Titre_${hierarchyLevel}_${index}`;
const parentTitle = nearbyElements.find(el => el.includes(titleName)) || nearbyElements[0];
let context = '';
if (mcValue) {
const shortMc = mcValue.length > 25 ? mcValue.substring(0, 25) + '...' : mcValue;
context = `MC: "${shortMc}"`;
}
if (parentTitle) {
const shortTitle = parentTitle.length > 25 ? parentTitle.substring(0, 25) + '...' : parentTitle;
context += (context ? ' | ' : '') + `Parent: "${shortTitle}"`;
}
if (nearbyElements.length > 1) {
context += (context ? ' | ' : '') + `${nearbyElements.length - 1} autres textes`;
}
return context || 'Premier texte';
}
} else if (elementName.startsWith('Faq_')) {
// Pour une FAQ : compter les FAQ existantes
const faqCount = nearbyElements.filter(el => el && el.length > 0).length;
if (faqCount > 0) {
return `${faqCount} FAQ existantes`;
}
return 'Première FAQ';
}
// Fallback : afficher le nombre d'éléments proches
if (nearbyElements.length > 0) {
return `${nearbyElements.length} éléments similaires existants`;
}
return '';
}
/**
* 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;
// ✅ OPTION B: Détecter les éléments avec plusieurs variables
const multiVarElements = analyzeMultiPlaceholderElements(missingElements);
const hasMultiVarElements = Object.keys(multiVarElements).length > 0;
if (hasMultiVarElements) {
logSh(`🔍 ${Object.keys(multiVarElements).length} éléments avec variables multiples détectés`, 'INFO');
}
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 principal: ${contextAnalysis.mainKeyword}
- Mots-clés existants: ${contextAnalysis.availableKeywords.slice(0, 3).join(', ')}
🎯 IMPÉRATIF DIVERSITÉ ET SPÉCIFICITÉ:
- VARIE les angles: technique, usage, avantages, comparaisons, stratégies, communauté, équipement
- ÉVITE les répétitions "${contextAnalysis.mainKeyword} X, ${contextAnalysis.mainKeyword} Y, ${contextAnalysis.mainKeyword} Z"
- EXPLORE différents aspects selon le domaine du sujet
- Exemples d'angles variés:
* Produits physiques: matériaux, formats, finitions, usages, durabilité
* Jeux vidéo: stratégies, maps, armes, personnages, équipes, tournois, guides
* Services: méthodes, outils, processus, résultats, comparaisons
- Sois SPÉCIFIQUE, pas générique
ÉLÉMENTS MANQUANTS:
`;
missingElements.forEach((missing, index) => {
const placeholders = extractIndividualPlaceholders(missing.currentContent);
// Construire le contexte hiérarchique compact
const hierarchyContext = buildHierarchicalContext(missing, missingElements, csvData);
// ✅ OPTION B: Si l'élément a plusieurs variables, détailler chacune
if (placeholders.length > 1) {
prompt += `${index + 1}. [${missing.name}] a ${placeholders.length} variables distinctes:\n`;
placeholders.forEach(varName => {
const varType = getVariableType(varName);
prompt += ` - [${missing.name}_${varName}] (type: ${varType}) → Génère un ${varType}\n`;
});
} else {
// Format compact avec hiérarchie
const elementType = missing.type || 'élément';
if (hierarchyContext) {
prompt += `${index + 1}. [${missing.name}] (${elementType}) — ${hierarchyContext}\n`;
} else {
prompt += `${index + 1}. [${missing.name}] (${elementType})\n`;
}
}
});
prompt += `\nCONSIGNES:
- Thème principal: ${contextAnalysis.mainKeyword}
- Génère des mots-clés/expressions SEO naturels et VARIÉS
- 🎯 DIVERSITÉ MAXIMALE: Chaque mot-clé doit explorer un angle DIFFÉRENT
- ÉVITE absolument les patterns répétitifs comme "${contextAnalysis.mainKeyword} + adjectif"
- Si un élément a plusieurs variables, génère des valeurs COMPLÈTEMENT DIFFÉRENTES
- Pour les liens (type: lien): slugs URL courts (ex: /guide-debutant, /meilleurs-conseils)
- Pour les titres (type: titre): courts et accrocheurs (5-10 mots max)
- Pour les mots-clés (type: mot-clé): expressions SEO spécifiques et variées
EXEMPLES DE DIVERSITÉ ATTENDUE:
- ❌ MAL: "Plaque dibond", "Plaque aluminium", "Plaque personnalisée"
- ✅ BIEN: "Dibond haute résistance", "Finitions gravure laser", "Sur-mesure extérieur"
⚠️ CRITIQUE: Tu DOIS générer TOUS les ${missingElements.length} éléments listés ci-dessus.
Ne saute aucun élément, même si tu génères des sous-variables pour certains.
FORMAT DE RÉPONSE EXACT (RESPECTE LES CROCHETS):`;
// ✅ Donner 3 exemples concrets de la vraie liste
const exampleCount = Math.min(3, missingElements.length);
for (let i = 0; i < exampleCount; i++) {
const example = missingElements[i];
prompt += `
[${example.name}]
${example.type.includes('titre') ? 'Titre accrocheur 5-10 mots' : example.type.includes('question') ? 'Question naturelle?' : example.type.includes('reponse') ? 'Réponse claire et concise' : 'Expression SEO spécifique'}
`;
}
prompt += `
... Continue avec TOUS les ${missingElements.length} éléments de la liste ci-dessus ...
⚠️ IMPORTANT: Utilise EXACTEMENT les noms entre crochets [Titre_H2_3], [Txt_H2_6], etc. Ne change RIEN aux noms!`;
try {
logSh('🤖 Appel LLM pour génération mots-clés manquants...', 'INFO');
// Utilisation du LLM Manager avec fallback (le prompt/réponse seront loggés par LLMManager)
const response = await callLLM('gpt-4o-mini', prompt, {
temperature: 0.7,
maxTokens: 3000 // ✅ Augmenté de 2000 à 3000 pour éviter les coupures
}, personality);
// Parser la réponse
const generatedKeywords = parseMissingKeywordsResponse(response, missingElements);
logSh(`${Object.keys(generatedKeywords).length} mots-clés générés avec succès`, 'INFO');
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) {
logSh('🔍 Début du parsing de la réponse LLM...', 'DEBUG');
const results = {};
const subVariableResults = {}; // ✅ OPTION B: Stocker les sous-variables séparément
const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs;
let match;
while ((match = regex.exec(response)) !== null) {
const elementName = match[1].trim();
let generatedKeyword = match[2].trim();
// ✅ Nettoyer les commentaires markdown et lignes parasites
// Supprimer les commentaires qui commencent par #
generatedKeyword = generatedKeyword
.split('\n')
.filter(line => !line.trim().startsWith('#')) // Supprimer lignes commençant par #
.filter(line => line.trim() !== '') // Supprimer lignes vides
.join('\n')
.trim();
// Ignorer si vide après nettoyage
if (!generatedKeyword) {
logSh(` ⚠️ Élément [${elementName}] ignoré (vide après nettoyage)`, 'DEBUG');
continue;
}
// ✅ OPTION B: Détecter si c'est une sous-variable (format: Txt_H2_6_T+1_5)
if (elementName.includes('_T+') || elementName.includes('_MC+') || elementName.includes('_L+') ||
elementName.includes('_T-') || elementName.includes('_MC-') || elementName.includes('_L-')) {
// C'est une sous-variable
subVariableResults[elementName] = generatedKeyword;
logSh(` ✓ Sous-variable générée [${elementName}]: "${generatedKeyword}"`, 'INFO');
} else {
// Comportement normal
results[elementName] = generatedKeyword;
logSh(` ✓ Mot-clé généré [${elementName}]: "${generatedKeyword}"`, 'INFO');
}
}
// ✅ OPTION B: Stocker les sous-variables dans results pour y accéder plus tard
if (Object.keys(subVariableResults).length > 0) {
results._subVariables = subVariableResults;
logSh(`📊 ${Object.keys(subVariableResults).length} sous-variables extraites`, 'INFO');
}
logSh(`📊 Parsing terminé: ${Object.keys(results).length} mots-clés extraits`, 'INFO');
// ✅ OPTION B: Validation améliorée pour gérer les sous-variables
const uniqueNames = [...new Set(missingElements.map(e => e.name))];
const parsedCount = Object.keys(results).length - (results._subVariables ? 1 : 0); // Exclure _subVariables du compte
const subVarCount = Object.keys(subVariableResults).length;
logSh(`🔢 Validation: ${parsedCount} éléments principaux, ${subVarCount} sous-variables / ${uniqueNames.length} tags uniques attendus`, 'INFO');
// Vérifier qu'on a au moins récupéré QUELQUE CHOSE
if (parsedCount === 0 && subVarCount === 0) {
logSh(`❌ FATAL: Aucun mot-clé parsé de la réponse LLM`, 'ERROR');
logSh(`Réponse reçue: ${response.substring(0, 500)}...`, 'ERROR');
throw new Error(`FATAL: Parsing mots-clés échoué complètement - arrêt du workflow`);
}
// ✅ OPTION B: Si on a des sous-variables, vérifier qu'on couvre tous les éléments
if (subVarCount > 0) {
// Pour chaque élément unique, vérifier qu'il a soit une valeur principale, soit des sous-variables
const elementsManquants = [];
uniqueNames.forEach(name => {
const hasMainValue = results[name];
const hasSubVariables = Object.keys(subVariableResults).some(key => key.startsWith(`${name}_`));
if (!hasMainValue && !hasSubVariables) {
elementsManquants.push(name);
}
});
if (elementsManquants.length > 0) {
logSh(`⚠️ AVERTISSEMENT: ${elementsManquants.length}/${uniqueNames.length} éléments n'ont ni valeur principale ni sous-variables`, 'WARN');
logSh(`Éléments manquants:`, 'WARN');
elementsManquants.forEach(name => logSh(` - [${name}]`, 'WARN'));
// ✅ Retourner les éléments manquants pour permettre un retry
results._missingElements = elementsManquants;
logSh(`💡 Ces éléments seront réessayés dans un second appel LLM`, 'INFO');
}
logSh(`✅ Validation OK: ${parsedCount} éléments + ${subVarCount} sous-variables couvrent ${uniqueNames.length} tags uniques`, 'INFO');
} else {
// Comportement classique si pas de sous-variables
if (parsedCount < uniqueNames.length) {
const manquants = uniqueNames.length - parsedCount;
logSh(`⚠️ AVERTISSEMENT: Parsing incomplet - ${manquants}/${uniqueNames.length} tags uniques non parsés`, 'WARN');
const elementsNonParses = uniqueNames.filter(name => !results[name]);
logSh(`Éléments non parsés:`, 'WARN');
elementsNonParses.forEach(name => logSh(` - [${name}]`, 'WARN'));
// ✅ Retourner les éléments manquants pour permettre un retry
results._missingElements = elementsNonParses;
logSh(`💡 Ces éléments seront réessayés dans un second appel LLM`, 'INFO');
} else {
logSh(`✅ Validation OK: ${parsedCount} mots-clés parsés pour ${uniqueNames.length} tags uniques`, 'INFO');
}
}
return results;
}
/**
* Re-résoudre les instructions en remplaçant tous les placeholders
* SOLUTION B: Fix synchronisation resolvedContent <-> instructions
* @param {string} instructionTemplate - Instructions avec placeholders
* @param {Object} allGeneratedKeywords - Tous les mots-clés générés (tag => keyword)
* @param {Object} element - L'élément courant pour obtenir son resolvedContent
* @returns {string} Instructions avec placeholders remplacés
*/
function reResolveInstructions(instructionTemplate, allGeneratedKeywords, element) {
if (!instructionTemplate || typeof instructionTemplate !== 'string') {
return instructionTemplate;
}
// ✅ OPTION B: Récupérer les sous-variables si disponibles
const subVariables = allGeneratedKeywords._subVariables || {};
const hasSubVariables = Object.keys(subVariables).length > 0;
let resolved = instructionTemplate;
// ✅ OPTION B: Stratégie améliorée pour multi-variables
resolved = resolved.replace(
/\[([^\]]+) non (défini|résolu)\]/g,
(match, varName) => {
// 1. Chercher d'abord dans les sous-variables spécifiques à cet élément
if (hasSubVariables && element && element.name) {
const subVarKey = `${element.name}_${varName}`;
if (subVariables[subVarKey]) {
logSh(` 🔄 Placeholder résolu via sous-variable: "${match}" → "${subVariables[subVarKey]}"`, 'DEBUG');
return subVariables[subVarKey];
}
}
// 2. Chercher dans les mots-clés générés par nom de variable exact
for (const [tagName, generatedKeyword] of Object.entries(allGeneratedKeywords)) {
if (tagName === '_subVariables' || tagName === '_missingElements') continue; // Skip metadata
// Match exact du varName dans le tagName
if (tagName.includes(varName)) {
logSh(` 🔄 Placeholder "${match}" résolu via tag exact [${tagName}] → "${generatedKeyword}"`, 'INFO');
return generatedKeyword;
}
}
// 3. Fallback: utiliser le resolvedContent global de l'élément (⚠️ peut causer des répétitions)
if (element && element.resolvedContent && !element.resolvedContent.includes('non défini')) {
logSh(` ⚠️ Placeholder "${match}" résolu via resolvedContent fallback → "${element.resolvedContent}"`, 'WARNING');
return element.resolvedContent;
}
// 4. Dernier fallback: garder le placeholder
return match;
}
);
return resolved;
}
/**
* 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) {
logSh('🔄 Mise à jour des éléments avec les nouveaux mots-clés...', 'INFO');
// 🔍 DEBUG: Afficher les clés disponibles dans generatedKeywords
logSh(`🔍 DEBUG: Clés disponibles dans generatedKeywords:`, 'DEBUG');
Object.keys(generatedKeywords).forEach(key => {
if (key !== '_subVariables' && key !== '_missingElements') {
logSh(` - "${key}" → "${generatedKeywords[key]}"`, 'DEBUG');
}
});
let updatedCount = 0;
let instructionsUpdatedCount = 0;
const subVariables = generatedKeywords._subVariables || {};
const updatedElements = elements.map(element => {
const newKeyword = generatedKeywords[element.name];
// 🔍 DEBUG: Log détaillé pour TOUS les éléments manquants
const isManquant = element.resolvedContent.includes('non défini') ||
element.resolvedContent.includes('non résolu') ||
element.resolvedContent.trim() === '';
if (isManquant) {
logSh(` 🔍 DEBUG MANQUANT: [${element.name}]`, 'WARN');
logSh(` 📄 resolvedContent: "${element.resolvedContent}"`, 'WARN');
logSh(` 🔑 Cherche dans generatedKeywords["${element.name}"]: ${newKeyword ? `TROUVÉ = "${newKeyword}"` : '❌ PAS TROUVÉ'}`, 'WARN');
// Chercher une clé similaire
const similarKeys = Object.keys(generatedKeywords).filter(key =>
key !== '_subVariables' && key !== '_missingElements' &&
(key.includes(element.name) || element.name.includes(key))
);
if (similarKeys.length > 0) {
logSh(` 💡 Clés similaires trouvées: ${similarKeys.join(', ')}`, 'WARN');
}
}
// ✅ OPTION B: Si pas de valeur principale mais des sous-variables existent
const hasSubVariables = Object.keys(subVariables).some(key => key.startsWith(`${element.name}_`));
if (newKeyword || hasSubVariables) {
updatedCount++;
// ✅ OPTION B: Si on a des sous-variables, construire une valeur résumée
let finalKeyword = newKeyword;
if (!finalKeyword && hasSubVariables) {
// Combiner les sous-variables en une seule valeur pour resolvedContent
const subVarKeys = Object.keys(subVariables).filter(key => key.startsWith(`${element.name}_`));
finalKeyword = subVarKeys.map(key => subVariables[key]).join(' | ');
logSh(` ✓ Élément [${element.name}] construit depuis ${subVarKeys.length} sous-variables: "${finalKeyword.substring(0, 80)}..."`, 'DEBUG');
} else {
logSh(` ✓ Élément [${element.name}] mis à jour: "${element.resolvedContent}" → "${newKeyword}"`, 'DEBUG');
}
// ✅ SOLUTION B: Re-résoudre aussi les instructions si elles existent
const updatedElement = {
...element,
resolvedContent: finalKeyword
};
if (element.instructions) {
const beforeInstructions = element.instructions;
updatedElement.instructions = reResolveInstructions(
element.instructions,
generatedKeywords,
updatedElement // ✅ Passer l'élément avec le nouveau resolvedContent
);
// Log si les instructions ont changé
if (beforeInstructions !== updatedElement.instructions) {
instructionsUpdatedCount++;
logSh(` ✨ Instructions mises à jour pour [${element.name}]`, 'DEBUG');
}
}
return updatedElement;
}
return element;
});
logSh(`${updatedCount} éléments mis à jour avec nouveaux mots-clés`, 'INFO');
if (instructionsUpdatedCount > 0) {
logSh(`${instructionsUpdatedCount} instructions re-résolues avec nouveaux mots-clés`, 'INFO');
}
return updatedElements;
}
// ============================================
// NOUVELLE FONCTIONNALITÉ: Génération variables Google Sheets manquantes
// Génère les variables (MC+1_5, T+1_6, etc.) AVANT extraction pour avoir csvData complet
// ============================================
/**
* Scanner le XML pour trouver toutes les variables demandées
* @param {string} xmlTemplate - Template XML (peut être base64)
* @returns {Object} { MC+1: [indices], T+1: [indices], L+1: [indices] }
*/
function scanXMLForVariables(xmlTemplate) {
// Décoder le base64 si nécessaire
const xmlString = xmlTemplate.startsWith('<?xml')
? xmlTemplate
: Buffer.from(xmlTemplate, 'base64').toString('utf8');
const requestedVariables = {
'MC+1': new Set(),
'T+1': new Set(),
'L+1': new Set(),
'MC-1': new Set(),
'T-1': new Set(),
'L-1': new Set()
};
// Chercher toutes les variables {{MC+1_X}}, {{T+1_X}}, {{L+1_X}}, etc.
const regex = /\{\{(MC|T|L)([+-])1_(\d+)\}\}/gi;
let match;
while ((match = regex.exec(xmlString)) !== null) {
const varType = match[1].toUpperCase(); // MC, T, L
const sign = match[2]; // + ou -
const index = parseInt(match[3]); // 1, 2, 3, etc.
const key = `${varType}${sign}1`;
requestedVariables[key].add(index);
}
// Convertir Sets en arrays triés
Object.keys(requestedVariables).forEach(key => {
requestedVariables[key] = Array.from(requestedVariables[key]).sort((a, b) => a - b);
});
return requestedVariables;
}
/**
* Trouver les variables manquantes entre ce qui est demandé et ce qui existe
* @param {Object} requestedVariables - Variables demandées par le XML
* @param {Object} csvData - Données Google Sheets
* @returns {Array} Liste des variables manquantes { varName, index, type }
*/
function findMissingVariables(requestedVariables, csvData) {
const missingVariables = [];
// Helper pour obtenir le nombre d'éléments existants dans csvData
const getExistingCount = (key) => {
const mapping = {
'MC+1': 'mcPlus1',
'T+1': 'tPlus1',
'L+1': 'lPlus1',
'MC-1': 'mcMinus1',
'T-1': 'tMinus1',
'L-1': 'lMinus1'
};
const csvKey = mapping[key];
if (!csvData[csvKey]) return 0;
return csvData[csvKey].split(',').map(s => s.trim()).filter(s => s.length > 0).length;
};
// Pour chaque type de variable (MC+1, T+1, etc.)
Object.keys(requestedVariables).forEach(key => {
const requestedIndices = requestedVariables[key];
if (requestedIndices.length === 0) return;
const existingCount = getExistingCount(key);
const maxRequestedIndex = Math.max(...requestedIndices);
// Si on demande des indices qui n'existent pas
if (maxRequestedIndex > existingCount) {
for (let i = existingCount + 1; i <= maxRequestedIndex; i++) {
missingVariables.push({
varName: `${key}_${i}`,
index: i,
type: key,
fullKey: key
});
}
}
});
return missingVariables;
}
/**
* Mettre à jour csvData avec les mots-clés générés
* @param {Object} csvData - Données CSV originales
* @param {Object} generatedKeywords - Mots-clés générés { varName: value }
* @returns {Object} csvData mis à jour
*/
function updateCsvDataWithKeywords(csvData, generatedKeywords) {
const updatedCsvData = { ...csvData };
// Mapping entre types de variables et clés csvData
const mapping = {
'MC+1': 'mcPlus1',
'T+1': 'tPlus1',
'L+1': 'lPlus1',
'MC-1': 'mcMinus1',
'T-1': 'tMinus1',
'L-1': 'lMinus1'
};
// Organiser les mots-clés générés par type
const organized = {};
Object.keys(generatedKeywords).forEach(varName => {
if (varName.startsWith('_')) return; // Skip metadata
// Parser varName: "MC+1_5" → type="MC+1", index=5
const match = varName.match(/^(MC|T|L)([+-])1_(\d+)$/i);
if (!match) return;
const type = `${match[1].toUpperCase()}${match[2]}1`;
const index = parseInt(match[3]);
if (!organized[type]) {
organized[type] = {};
}
organized[type][index] = generatedKeywords[varName];
});
// Mettre à jour csvData pour chaque type
Object.keys(organized).forEach(type => {
const csvKey = mapping[type];
if (!csvKey) return;
// Récupérer les valeurs existantes
const existing = updatedCsvData[csvKey]
? updatedCsvData[csvKey].split(',').map(s => s.trim()).filter(s => s.length > 0)
: [];
// Créer un array complet avec les nouvelles valeurs
const maxIndex = Math.max(...Object.keys(organized[type]).map(i => parseInt(i)));
const complete = [];
for (let i = 1; i <= maxIndex; i++) {
if (organized[type][i]) {
// Nouvelle valeur générée
complete.push(organized[type][i]);
} else if (existing[i - 1]) {
// Valeur existante
complete.push(existing[i - 1]);
} else {
// Aucune valeur (ne devrait pas arriver)
complete.push('');
}
}
// Mettre à jour csvData
updatedCsvData[csvKey] = complete.join(', ');
});
return updatedCsvData;
}
/**
* Fonction principale: Générer les variables Google Sheets manquantes
* @param {string} xmlTemplate - Template XML
* @param {Object} csvData - Données Google Sheets
* @returns {Object} csvData mis à jour avec toutes les variables
*/
async function generateMissingSheetVariables(xmlTemplate, csvData) {
logSh('>>> GÉNÉRATION VARIABLES GOOGLE SHEETS MANQUANTES <<<', 'INFO');
// 1. Scanner le XML pour trouver toutes les variables demandées
const requestedVariables = scanXMLForVariables(xmlTemplate);
logSh('📊 Variables demandées dans le XML:', 'INFO');
Object.keys(requestedVariables).forEach(key => {
if (requestedVariables[key].length > 0) {
logSh(` ${key}: indices ${requestedVariables[key].join(', ')}`, 'INFO');
}
});
// 2. Comparer avec ce qui existe dans csvData
const missingVariables = findMissingVariables(requestedVariables, csvData);
if (missingVariables.length === 0) {
logSh('✅ Aucune variable Google Sheets manquante', 'INFO');
return csvData;
}
logSh(`⚠️ ${missingVariables.length} variables Google Sheets manquantes:`, 'INFO');
missingVariables.forEach((missing, index) => {
logSh(` ${index + 1}. ${missing.varName} (type: ${getVariableType(missing.varName)})`, 'INFO');
});
// 3. Transformer en format compatible avec callOpenAIForMissingKeywords
const pseudoElements = missingVariables.map(varInfo => ({
name: varInfo.varName,
currentContent: `[${varInfo.varName} non défini]`,
type: getVariableType(varInfo.varName),
context: {
mainKeyword: csvData.mc0,
elementType: getVariableType(varInfo.varName),
hierarchyLevel: 'sheet_variable'
}
}));
// 4. Préparer le contexte avec les mots-clés existants pour éviter les doublons
const existingKeywords = [];
// Extraire les mots-clés existants de csvData
if (csvData.mcPlus1) {
existingKeywords.push(...csvData.mcPlus1.split(',').map(s => s.trim()).filter(s => s));
}
if (csvData.tPlus1) {
existingKeywords.push(...csvData.tPlus1.split(',').map(s => s.trim()).filter(s => s));
}
if (csvData.lPlus1) {
existingKeywords.push(...csvData.lPlus1.split(',').map(s => s.trim()).filter(s => s));
}
logSh(`📌 Mots-clés existants à éviter: ${existingKeywords.join(', ')}`, 'DEBUG');
// Créer le contexte avec les mots-clés existants
const contextAnalysis = {
mainKeyword: csvData.mc0,
mainTitle: csvData.t0,
availableKeywords: existingKeywords, // ✅ IMPORTANT : éviter les doublons
availableContent: [],
theme: csvData.mc0
};
logSh('🤖 Appel LLM pour génération des variables Google Sheets...', 'INFO');
const generatedKeywords = await callOpenAIForMissingKeywords(
pseudoElements,
contextAnalysis,
csvData
);
// 5. Mettre à jour csvData
const updatedCsvData = updateCsvDataWithKeywords(csvData, generatedKeywords);
logSh(`✅ csvData mis à jour avec ${Object.keys(generatedKeywords).length - (generatedKeywords._subVariables ? 1 : 0)} nouvelles variables`, 'INFO');
// 6. Logger le résultat
logSh('\n📋 RÉCAPITULATIF VARIABLES APRÈS GÉNÉRATION:', 'INFO');
['mcPlus1', 'tPlus1', 'lPlus1'].forEach(key => {
if (updatedCsvData[key]) {
const values = updatedCsvData[key].split(',').map(s => s.trim());
logSh(` ${key}: ${values.length} valeurs`, 'INFO');
values.forEach((val, idx) => {
logSh(` [${idx + 1}] "${val}"`, 'INFO');
});
}
});
return updatedCsvData;
}
// Exports CommonJS
module.exports = {
generateMissingKeywords,
generateMissingSheetVariables // ✅ Export de la nouvelle fonction
};