6470 lines
224 KiB
JavaScript
6470 lines
224 KiB
JavaScript
/*
|
|
code.js — bundle concaténé
|
|
Généré: 2025-09-03T04:21:57.159Z
|
|
Source: lib
|
|
Fichiers: 16
|
|
Ordre: topo
|
|
*/
|
|
|
|
/*
|
|
┌────────────────────────────────────────────────────────────────────┐
|
|
│ File: lib/ErrorReporting.js │
|
|
└────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
// ========================================
|
|
// FICHIER: lib/error-reporting.js - CONVERTI POUR NODE.JS
|
|
// Description: Système de validation et rapport d'erreur
|
|
// ========================================
|
|
|
|
const { google } = require('googleapis');
|
|
const nodemailer = require('nodemailer');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const pino = require('pino');
|
|
const pretty = require('pino-pretty');
|
|
const { PassThrough } = require('stream');
|
|
const WebSocket = require('ws');
|
|
|
|
// Configuration
|
|
const SHEET_ID = process.env.GOOGLE_SHEETS_ID || '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c';
|
|
|
|
// WebSocket server for real-time logs
|
|
let wsServer;
|
|
const wsClients = new Set();
|
|
|
|
// Enhanced Pino logger configuration with real-time streaming and dated files
|
|
const now = new Date();
|
|
const timestamp = now.toISOString().slice(0, 10) + '_' +
|
|
now.toLocaleTimeString('fr-FR').replace(/:/g, '-');
|
|
const logFile = path.join(__dirname, '..', 'logs', `seo-generator-${timestamp}.log`);
|
|
|
|
const prettyStream = pretty({
|
|
colorize: true,
|
|
translateTime: 'HH:MM:ss.l',
|
|
ignore: 'pid,hostname',
|
|
});
|
|
|
|
const tee = new PassThrough();
|
|
tee.pipe(prettyStream).pipe(process.stdout);
|
|
|
|
// File destination with dated filename - FORCE DEBUG LEVEL
|
|
const fileDest = pino.destination({
|
|
dest: logFile,
|
|
mkdir: true,
|
|
sync: false,
|
|
minLength: 0 // Force immediate write even for small logs
|
|
});
|
|
tee.pipe(fileDest);
|
|
|
|
// Custom levels for Pino to include TRACE and PROMPT
|
|
const customLevels = {
|
|
trace: 5, // Below debug (10)
|
|
debug: 10,
|
|
info: 20,
|
|
prompt: 25, // New level for prompts (between info and warn)
|
|
warn: 30,
|
|
error: 40,
|
|
fatal: 50
|
|
};
|
|
|
|
// Pino logger instance with enhanced configuration and custom levels
|
|
const logger = pino(
|
|
{
|
|
level: 'debug', // FORCE DEBUG LEVEL for file logging
|
|
base: undefined,
|
|
timestamp: pino.stdTimeFunctions.isoTime,
|
|
customLevels: customLevels,
|
|
useOnlyCustomLevels: true
|
|
},
|
|
tee
|
|
);
|
|
|
|
// Initialize WebSocket server
|
|
function initWebSocketServer() {
|
|
if (!wsServer) {
|
|
wsServer = new WebSocket.Server({ port: process.env.LOG_WS_PORT || 8081 });
|
|
|
|
wsServer.on('connection', (ws) => {
|
|
wsClients.add(ws);
|
|
logger.info('Client connected to log WebSocket');
|
|
|
|
ws.on('close', () => {
|
|
wsClients.delete(ws);
|
|
logger.info('Client disconnected from log WebSocket');
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
logger.error('WebSocket error:', error.message);
|
|
wsClients.delete(ws);
|
|
});
|
|
});
|
|
|
|
logger.info(`Log WebSocket server started on port ${process.env.LOG_WS_PORT || 8081}`);
|
|
}
|
|
}
|
|
|
|
// Broadcast log to WebSocket clients
|
|
function broadcastLog(message, level) {
|
|
const logData = {
|
|
timestamp: new Date().toISOString(),
|
|
level: level.toUpperCase(),
|
|
message: message
|
|
};
|
|
|
|
wsClients.forEach(ws => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
ws.send(JSON.stringify(logData));
|
|
} catch (error) {
|
|
logger.error('Failed to send log to WebSocket client:', error.message);
|
|
wsClients.delete(ws);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 🔄 NODE.JS : Google Sheets API setup (remplace SpreadsheetApp)
|
|
let sheets;
|
|
let auth;
|
|
|
|
async function initGoogleSheets() {
|
|
if (!sheets) {
|
|
// Configuration auth Google Sheets API
|
|
// Pour la démo, on utilise une clé de service (à configurer)
|
|
auth = new google.auth.GoogleAuth({
|
|
keyFile: process.env.GOOGLE_CREDENTIALS_PATH, // Chemin vers fichier JSON credentials
|
|
scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
});
|
|
|
|
sheets = google.sheets({ version: 'v4', auth });
|
|
}
|
|
return sheets;
|
|
}
|
|
|
|
async function logSh(message, level = 'INFO') {
|
|
// Initialize WebSocket server if not already done
|
|
if (!wsServer) {
|
|
initWebSocketServer();
|
|
}
|
|
|
|
// Convert level to lowercase for Pino
|
|
const pinoLevel = level.toLowerCase();
|
|
|
|
// Enhanced trace metadata for hierarchical logging
|
|
const traceData = {};
|
|
if (message.includes('▶') || message.includes('✔') || message.includes('✖') || message.includes('•')) {
|
|
traceData.trace = true;
|
|
traceData.evt = message.includes('▶') ? 'span.start' :
|
|
message.includes('✔') ? 'span.end' :
|
|
message.includes('✖') ? 'span.error' : 'span.event';
|
|
}
|
|
|
|
// Log with Pino (handles console output with pretty formatting and file logging)
|
|
switch (pinoLevel) {
|
|
case 'error':
|
|
logger.error(traceData, message);
|
|
break;
|
|
case 'warning':
|
|
case 'warn':
|
|
logger.warn(traceData, message);
|
|
break;
|
|
case 'debug':
|
|
logger.debug(traceData, message);
|
|
break;
|
|
case 'trace':
|
|
logger.trace(traceData, message);
|
|
break;
|
|
case 'prompt':
|
|
logger.prompt(traceData, message);
|
|
break;
|
|
default:
|
|
logger.info(traceData, message);
|
|
}
|
|
|
|
// Broadcast to WebSocket clients for real-time viewing
|
|
broadcastLog(message, level);
|
|
|
|
// Force immediate flush to ensure real-time display and prevent log loss
|
|
logger.flush();
|
|
|
|
// Log to Google Sheets if enabled (async, non-blocking)
|
|
if (process.env.ENABLE_SHEETS_LOGGING === 'true') {
|
|
setImmediate(() => {
|
|
logToGoogleSheets(message, level).catch(err => {
|
|
// Silent fail for Google Sheets logging to avoid recursion
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fonction pour déterminer si on doit logger en console
|
|
function shouldLogToConsole(messageLevel, configLevel) {
|
|
const levels = { DEBUG: 0, INFO: 1, WARNING: 2, ERROR: 3 };
|
|
return levels[messageLevel] >= levels[configLevel];
|
|
}
|
|
|
|
// Log to file is now handled by Pino transport
|
|
// This function is kept for compatibility but does nothing
|
|
async function logToFile(message, level) {
|
|
// Pino handles file logging via transport configuration
|
|
// This function is deprecated and kept for compatibility only
|
|
}
|
|
|
|
// 🔄 NODE.JS : Log vers Google Sheets (version async)
|
|
async function logToGoogleSheets(message, level) {
|
|
try {
|
|
const sheetsApi = await initGoogleSheets();
|
|
|
|
const values = [[
|
|
new Date().toISOString(),
|
|
level,
|
|
message,
|
|
'Node.js workflow'
|
|
]];
|
|
|
|
await sheetsApi.spreadsheets.values.append({
|
|
spreadsheetId: SHEET_ID,
|
|
range: 'Logs!A:D',
|
|
valueInputOption: 'RAW',
|
|
insertDataOption: 'INSERT_ROWS',
|
|
resource: { values }
|
|
});
|
|
|
|
} catch (error) {
|
|
logSh('Échec log Google Sheets: ' + error.message, 'WARNING'); // Using logSh instead of console.warn
|
|
}
|
|
}
|
|
|
|
// 🔄 NODE.JS : Version simplifiée cleanLogSheet
|
|
async function cleanLogSheet() {
|
|
try {
|
|
logSh('🧹 Nettoyage logs...', 'INFO'); // Using logSh instead of console.log
|
|
|
|
// 1. Nettoyer fichiers logs locaux (garder 7 derniers jours)
|
|
await cleanLocalLogs();
|
|
|
|
// 2. Nettoyer Google Sheets si activé
|
|
if (process.env.ENABLE_SHEETS_LOGGING === 'true') {
|
|
await cleanGoogleSheetsLogs();
|
|
}
|
|
|
|
logSh('✅ Logs nettoyés', 'INFO'); // Using logSh instead of console.log
|
|
|
|
} catch (error) {
|
|
logSh('Erreur nettoyage logs: ' + error.message, 'ERROR'); // Using logSh instead of console.error
|
|
}
|
|
}
|
|
|
|
async function cleanLocalLogs() {
|
|
try {
|
|
// Note: With Pino, log files are managed differently
|
|
// This function is kept for compatibility with Google Sheets logs cleanup
|
|
// Pino log rotation should be handled by external tools like logrotate
|
|
|
|
// For now, we keep the basic cleanup for any remaining old log files
|
|
const logsDir = path.join(__dirname, '../logs');
|
|
|
|
try {
|
|
const files = await fs.readdir(logsDir);
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - 7); // Garder 7 jours
|
|
|
|
for (const file of files) {
|
|
if (file.endsWith('.log')) {
|
|
const filePath = path.join(logsDir, file);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.mtime < cutoffDate) {
|
|
await fs.unlink(filePath);
|
|
logSh(`🗑️ Supprimé log ancien: ${file}`, 'INFO');
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Directory might not exist, that's fine
|
|
}
|
|
} catch (error) {
|
|
// Silent fail
|
|
}
|
|
}
|
|
|
|
async function cleanGoogleSheetsLogs() {
|
|
try {
|
|
const sheetsApi = await initGoogleSheets();
|
|
|
|
// Clear + remettre headers
|
|
await sheetsApi.spreadsheets.values.clear({
|
|
spreadsheetId: SHEET_ID,
|
|
range: 'Logs!A:D'
|
|
});
|
|
|
|
await sheetsApi.spreadsheets.values.update({
|
|
spreadsheetId: SHEET_ID,
|
|
range: 'Logs!A1:D1',
|
|
valueInputOption: 'RAW',
|
|
resource: {
|
|
values: [['Timestamp', 'Level', 'Message', 'Source']]
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
logSh('Échec nettoyage Google Sheets: ' + error.message, 'WARNING'); // Using logSh instead of console.warn
|
|
}
|
|
}
|
|
|
|
// ============= VALIDATION PRINCIPALE - IDENTIQUE =============
|
|
|
|
function validateWorkflowIntegrity(elements, generatedContent, finalXML, csvData) {
|
|
logSh('🔍 >>> VALIDATION INTÉGRITÉ WORKFLOW <<<', 'INFO'); // Using logSh instead of console.log
|
|
|
|
const errors = [];
|
|
const warnings = [];
|
|
const stats = {
|
|
elementsExtracted: elements.length,
|
|
contentGenerated: Object.keys(generatedContent).length,
|
|
tagsReplaced: 0,
|
|
tagsRemaining: 0
|
|
};
|
|
|
|
// TEST 1: Détection tags dupliqués
|
|
const duplicateCheck = detectDuplicateTags(elements);
|
|
if (duplicateCheck.hasDuplicates) {
|
|
errors.push({
|
|
type: 'DUPLICATE_TAGS',
|
|
severity: 'HIGH',
|
|
message: `Tags dupliqués détectés: ${duplicateCheck.duplicates.join(', ')}`,
|
|
impact: 'Certains contenus ne seront pas remplacés dans le XML final',
|
|
suggestion: 'Vérifier le template XML pour corriger la structure'
|
|
});
|
|
}
|
|
|
|
// TEST 2: Cohérence éléments extraits vs générés
|
|
const missingGeneration = elements.filter(el => !generatedContent[el.originalTag]);
|
|
if (missingGeneration.length > 0) {
|
|
errors.push({
|
|
type: 'MISSING_GENERATION',
|
|
severity: 'HIGH',
|
|
message: `${missingGeneration.length} éléments extraits mais non générés`,
|
|
details: missingGeneration.map(el => el.originalTag),
|
|
impact: 'Contenu incomplet dans le XML final'
|
|
});
|
|
}
|
|
|
|
// TEST 3: Tags non remplacés dans XML final
|
|
const remainingTags = (finalXML.match(/\|[^|]*\|/g) || []);
|
|
stats.tagsRemaining = remainingTags.length;
|
|
|
|
if (remainingTags.length > 0) {
|
|
errors.push({
|
|
type: 'UNREPLACED_TAGS',
|
|
severity: 'HIGH',
|
|
message: `${remainingTags.length} tags non remplacés dans le XML final`,
|
|
details: remainingTags.slice(0, 5),
|
|
impact: 'XML final contient des placeholders non remplacés'
|
|
});
|
|
}
|
|
|
|
// TEST 4: Variables CSV manquantes
|
|
const missingVars = detectMissingCSVVariables(csvData);
|
|
if (missingVars.length > 0) {
|
|
warnings.push({
|
|
type: 'MISSING_CSV_VARIABLES',
|
|
severity: 'MEDIUM',
|
|
message: `Variables CSV manquantes: ${missingVars.join(', ')}`,
|
|
impact: 'Système de génération de mots-clés automatique activé'
|
|
});
|
|
}
|
|
|
|
// TEST 5: Qualité génération IA
|
|
const generationQuality = assessGenerationQuality(generatedContent);
|
|
if (generationQuality.errorRate > 0.1) {
|
|
warnings.push({
|
|
type: 'GENERATION_QUALITY',
|
|
severity: 'MEDIUM',
|
|
message: `${(generationQuality.errorRate * 100).toFixed(1)}% d'erreurs de génération IA`,
|
|
impact: 'Qualité du contenu potentiellement dégradée'
|
|
});
|
|
}
|
|
|
|
// CALCUL STATS FINALES
|
|
stats.tagsReplaced = elements.length - remainingTags.length;
|
|
stats.successRate = stats.elementsExtracted > 0 ?
|
|
((stats.tagsReplaced / elements.length) * 100).toFixed(1) : '100';
|
|
|
|
const report = {
|
|
timestamp: new Date().toISOString(),
|
|
csvData: { mc0: csvData.mc0, t0: csvData.t0 },
|
|
stats: stats,
|
|
errors: errors,
|
|
warnings: warnings,
|
|
status: errors.length === 0 ? 'SUCCESS' : 'ERROR'
|
|
};
|
|
|
|
const logLevel = report.status === 'SUCCESS' ? 'INFO' : 'ERROR';
|
|
logSh(`✅ Validation terminée: ${report.status} (${errors.length} erreurs, ${warnings.length} warnings)`, 'INFO'); // Using logSh instead of console.log
|
|
|
|
// ENVOYER RAPPORT SI ERREURS (async en arrière-plan)
|
|
if (errors.length > 0 || warnings.length > 2) {
|
|
sendErrorReport(report).catch(err => {
|
|
logSh('Erreur envoi rapport: ' + err.message, 'ERROR'); // Using logSh instead of console.error
|
|
});
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
// ============= HELPERS - IDENTIQUES =============
|
|
|
|
function detectDuplicateTags(elements) {
|
|
const tagCounts = {};
|
|
const duplicates = [];
|
|
|
|
elements.forEach(element => {
|
|
const tag = element.originalTag;
|
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
|
|
if (tagCounts[tag] === 2) {
|
|
duplicates.push(tag);
|
|
logSh(`❌ DUPLICATE détecté: ${tag}`, 'ERROR'); // Using logSh instead of console.error
|
|
}
|
|
});
|
|
|
|
return {
|
|
hasDuplicates: duplicates.length > 0,
|
|
duplicates: duplicates,
|
|
counts: tagCounts
|
|
};
|
|
}
|
|
|
|
function detectMissingCSVVariables(csvData) {
|
|
const missing = [];
|
|
|
|
if (!csvData.mcPlus1 || csvData.mcPlus1.split(',').length < 4) {
|
|
missing.push('MC+1 (insuffisant)');
|
|
}
|
|
if (!csvData.tPlus1 || csvData.tPlus1.split(',').length < 4) {
|
|
missing.push('T+1 (insuffisant)');
|
|
}
|
|
if (!csvData.lPlus1 || csvData.lPlus1.split(',').length < 4) {
|
|
missing.push('L+1 (insuffisant)');
|
|
}
|
|
|
|
return missing;
|
|
}
|
|
|
|
function assessGenerationQuality(generatedContent) {
|
|
let errorCount = 0;
|
|
let totalCount = Object.keys(generatedContent).length;
|
|
|
|
Object.values(generatedContent).forEach(content => {
|
|
if (content && (
|
|
content.includes('[ERREUR') ||
|
|
content.includes('ERROR') ||
|
|
content.length < 10
|
|
)) {
|
|
errorCount++;
|
|
}
|
|
});
|
|
|
|
return {
|
|
errorRate: totalCount > 0 ? errorCount / totalCount : 0,
|
|
totalGenerated: totalCount,
|
|
errorsFound: errorCount
|
|
};
|
|
}
|
|
|
|
// 🔄 NODE.JS : Email avec nodemailer (remplace MailApp)
|
|
async function sendErrorReport(report) {
|
|
try {
|
|
logSh('📧 Envoi rapport d\'erreur par email...', 'INFO'); // Using logSh instead of console.log
|
|
|
|
// Configuration nodemailer (Gmail par exemple)
|
|
const transporter = nodemailer.createTransport({
|
|
service: 'gmail',
|
|
auth: {
|
|
user: process.env.EMAIL_USER, // 'your-email@gmail.com'
|
|
pass: process.env.EMAIL_APP_PASSWORD // App password Google
|
|
}
|
|
});
|
|
|
|
const subject = `Erreur Workflow SEO Node.js - ${report.status} - ${report.csvData.mc0}`;
|
|
const htmlBody = createHTMLReport(report);
|
|
|
|
const mailOptions = {
|
|
from: process.env.EMAIL_USER,
|
|
to: 'alexistrouve.pro@gmail.com',
|
|
subject: subject,
|
|
html: htmlBody,
|
|
attachments: [{
|
|
filename: `error-report-${Date.now()}.json`,
|
|
content: JSON.stringify(report, null, 2),
|
|
contentType: 'application/json'
|
|
}]
|
|
};
|
|
|
|
await transporter.sendMail(mailOptions);
|
|
logSh('✅ Rapport d\'erreur envoyé par email', 'INFO'); // Using logSh instead of console.log
|
|
|
|
} catch (error) {
|
|
logSh(`❌ Échec envoi email: ${error.message}`, 'ERROR'); // Using logSh instead of console.error
|
|
}
|
|
}
|
|
|
|
// ============= HTML REPORT - IDENTIQUE =============
|
|
|
|
function createHTMLReport(report) {
|
|
const statusColor = report.status === 'SUCCESS' ? '#28a745' : '#dc3545';
|
|
|
|
let html = `
|
|
<div style="font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto;">
|
|
<h1 style="color: ${statusColor};">Rapport Workflow SEO Automatisé (Node.js)</h1>
|
|
|
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
|
<h2>Résumé Exécutif</h2>
|
|
<p><strong>Statut:</strong> <span style="color: ${statusColor};">${report.status}</span></p>
|
|
<p><strong>Article:</strong> ${report.csvData.t0}</p>
|
|
<p><strong>Mot-clé:</strong> ${report.csvData.mc0}</p>
|
|
<p><strong>Taux de réussite:</strong> ${report.stats.successRate}%</p>
|
|
<p><strong>Timestamp:</strong> ${report.timestamp}</p>
|
|
<p><strong>Plateforme:</strong> Node.js Server</p>
|
|
</div>`;
|
|
|
|
if (report.errors.length > 0) {
|
|
html += `<div style="background: #f8d7da; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
|
<h2>Erreurs Critiques (${report.errors.length})</h2>`;
|
|
|
|
report.errors.forEach((error, i) => {
|
|
html += `
|
|
<div style="margin: 10px 0; padding: 10px; border-left: 3px solid #dc3545;">
|
|
<h4>${i + 1}. ${error.type}</h4>
|
|
<p><strong>Message:</strong> ${error.message}</p>
|
|
<p><strong>Impact:</strong> ${error.impact}</p>
|
|
${error.suggestion ? `<p><strong>Solution:</strong> ${error.suggestion}</p>` : ''}
|
|
</div>`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
}
|
|
|
|
if (report.warnings.length > 0) {
|
|
html += `<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
|
<h2>Avertissements (${report.warnings.length})</h2>`;
|
|
|
|
report.warnings.forEach((warning, i) => {
|
|
html += `
|
|
<div style="margin: 10px 0; padding: 10px; border-left: 3px solid #ffc107;">
|
|
<h4>${i + 1}. ${warning.type}</h4>
|
|
<p>${warning.message}</p>
|
|
</div>`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
}
|
|
|
|
html += `
|
|
<div style="background: #e9ecef; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
|
<h2>Statistiques Détaillées</h2>
|
|
<ul>
|
|
<li>Éléments extraits: ${report.stats.elementsExtracted}</li>
|
|
<li>Contenus générés: ${report.stats.contentGenerated}</li>
|
|
<li>Tags remplacés: ${report.stats.tagsReplaced}</li>
|
|
<li>Tags restants: ${report.stats.tagsRemaining}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div style="background: #d1ecf1; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
|
<h2>Informations Système</h2>
|
|
<ul>
|
|
<li>Plateforme: Node.js</li>
|
|
<li>Version: ${process.version}</li>
|
|
<li>Mémoire: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB</li>
|
|
<li>Uptime: ${Math.round(process.uptime())}s</li>
|
|
</ul>
|
|
</div>
|
|
</div>`;
|
|
|
|
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 `<?xml version="1.0" encoding="UTF-8"?>
|
|
<article>
|
|
<header>
|
|
<h1>|Titre_Principal{{T0}}{Rédige un titre H1 accrocheur de maximum 10 mots pour {{MC0}}. Style {{personality.style}}}|</h1>
|
|
<intro>|Introduction{{MC0}}{Rédige une introduction engageante de 2-3 phrases sur {{MC0}}. Ton {{personality.style}}, utilise {{personality.vocabulairePref}}}|</intro>
|
|
</header>
|
|
|
|
<main>
|
|
<section class="primary">
|
|
<h2>|Titre_H2_1{{MC+1_1}}{Crée un titre H2 informatif sur {{MC+1_1}}. Style {{personality.style}}}|</h2>
|
|
<p>|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}}}|</p>
|
|
</section>
|
|
|
|
<section class="secondary">
|
|
<h2>|Titre_H2_2{{MC+1_2}}{Titre H2 pour {{MC+1_2}}. Mets en valeur les points forts. Ton {{personality.style}}}|</h2>
|
|
<p>|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}}}|</p>
|
|
</section>
|
|
|
|
<section class="benefits">
|
|
<h2>|Titre_H2_3{{MC+1_3}}{Titre H2 sur les bénéfices de {{MC+1_3}}. Accrocheur et informatif}|</h2>
|
|
<p>|Paragraphe_3{{MC+1_3}}{Explique en 4-5 phrases les avantages de {{MC+1_3}} pour {{MC0}}. Ton {{personality.style}}}|</p>
|
|
</section>
|
|
</main>
|
|
|
|
<aside class="faq">
|
|
<h2>|FAQ_Titre{Titre de section FAQ accrocheur sur {{MC0}}}|</h2>
|
|
|
|
<div class="faq-item">
|
|
<h3>|Faq_q_1{{MC+1_1}}{Question fréquente sur {{MC+1_1}} et {{MC0}}}|</h3>
|
|
<p>|Faq_a_1{{MC+1_1}}{Réponse claire et précise. 2-3 phrases. Ton {{personality.style}}}|</p>
|
|
</div>
|
|
|
|
<div class="faq-item">
|
|
<h3>|Faq_q_2{{MC+1_2}}{Question pratique sur {{MC+1_2}} en lien avec {{MC0}}}|</h3>
|
|
<p>|Faq_a_2{{MC+1_2}}{Réponse détaillée et utile. 2-3 phrases explicatives. Ton {{personality.style}}}|</p>
|
|
</div>
|
|
|
|
<div class="faq-item">
|
|
<h3>|Faq_q_3{{MC+1_3}}{Question sur {{MC+1_3}} que se posent les clients}|</h3>
|
|
<p>|Faq_a_3{{MC+1_3}}{Réponse complète qui rassure et informe. 2-3 phrases. Ton {{personality.style}}}|</p>
|
|
</div>
|
|
</aside>
|
|
|
|
<footer>
|
|
<p>|Conclusion{{MC0}}{Conclusion engageante de 2 phrases sur {{MC0}}. Appel à l'action subtil. Ton {{personality.style}}}|</p>
|
|
</footer>
|
|
</article>`;
|
|
}
|
|
|
|
/**
|
|
* 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<string>} - 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');
|
|
logSh(`===== FIN PROMPT ${llmProvider.toUpperCase()} (${personality?.nom || 'AUCUNE'}) =====\n`, 'PROMPT');
|
|
|
|
// 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);
|
|
|
|
const duration = Date.now() - startTime;
|
|
logSh(`✅ ${llmProvider.toUpperCase()} (${personality?.nom || 'sans personnalité'}) réponse en ${duration}ms: "${content.substring(0, 150)}${content.length > 150 ? '...' : ''}"`, '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 <strong>, </strong>, {{...}}, {...}
|
|
let cleanName = element.name
|
|
.replace(/<\/?strong>/g, '') // ← ENLEVER <strong>
|
|
.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}] `;
|
|
|
|
// INSTRUCTIONS SPÉCIFIQUES PAR TYPE
|
|
if (missing.type.includes('titre_h1')) {
|
|
prompt += `→ Titre H1 principal (8-10 mots) pour ${contextAnalysis.mainKeyword}\n`;
|
|
} else if (missing.type.includes('titre_h2')) {
|
|
prompt += `→ Titre H2 section (6-8 mots) lié à ${contextAnalysis.mainKeyword}\n`;
|
|
} else if (missing.type.includes('titre_h3')) {
|
|
prompt += `→ Sous-titre H3 (4-6 mots) spécialisé ${contextAnalysis.mainKeyword}\n`;
|
|
} else if (missing.type.includes('texte') || missing.type.includes('txt')) {
|
|
prompt += `→ Thème/sujet pour paragraphe 150 mots sur ${contextAnalysis.mainKeyword}\n`;
|
|
} else if (missing.type.includes('faq_question')) {
|
|
prompt += `→ Question client directe sur ${contextAnalysis.mainKeyword} (8-12 mots)\n`;
|
|
} else if (missing.type.includes('faq_reponse')) {
|
|
prompt += `→ Thème réponse experte ${contextAnalysis.mainKeyword} (2-4 mots)\n`;
|
|
} else {
|
|
prompt += `→ Expression/mot-clé pertinent ${contextAnalysis.mainKeyword}\n`;
|
|
}
|
|
});
|
|
|
|
prompt += `\nCONSIGNES:
|
|
- Reste dans le thème ${contextAnalysis.mainKeyword}
|
|
- Varie les angles et expressions
|
|
- Évite répétitions avec mots-clés existants
|
|
- Précis et pertinents
|
|
|
|
FORMAT:
|
|
[${missingElements[0].name}]
|
|
Expression/mot-clé généré 1
|
|
|
|
[${missingElements[1] ? missingElements[1].name : 'exemple'}]
|
|
Expression/mot-clé généré 2
|
|
|
|
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/SelectiveEnhancement.js │
|
|
└────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
// ========================================
|
|
// FICHIER: SelectiveEnhancement.js - Node.js Version
|
|
// Description: Enhancement par batch pour éviter timeouts
|
|
// ========================================
|
|
|
|
const { callLLM } = require('./LLMManager');
|
|
const { logSh } = require('./ErrorReporting');
|
|
const { tracer } = require('./trace.js');
|
|
const { selectMultiplePersonalitiesWithAI, getPersonalities } = require('./BrainConfig');
|
|
|
|
/**
|
|
* NOUVELLE APPROCHE - Multi-Personnalités Batch Enhancement
|
|
* 4 personnalités différentes utilisées dans le pipeline pour maximum d'anti-détection
|
|
*/
|
|
async function generateWithBatchEnhancement(hierarchy, csvData) {
|
|
const totalElements = Object.keys(hierarchy).length;
|
|
|
|
// NOUVEAU: Sélection de 4 personnalités complémentaires
|
|
const personalities = await tracer.run('SelectiveEnhancement.selectMultiplePersonalities()', async () => {
|
|
const allPersonalities = await getPersonalities();
|
|
const selectedPersonalities = await selectMultiplePersonalitiesWithAI(csvData.mc0, csvData.t0, allPersonalities);
|
|
await tracer.event(`4 personnalités sélectionnées: ${selectedPersonalities.map(p => p.nom).join(', ')}`);
|
|
return selectedPersonalities;
|
|
}, { mc0: csvData.mc0, t0: csvData.t0 });
|
|
|
|
await tracer.annotate({
|
|
totalElements,
|
|
personalities: personalities.map(p => `${p.nom}(${p.style})`).join(', '),
|
|
mc0: csvData.mc0
|
|
});
|
|
|
|
// ÉTAPE 1 : Génération base avec IA configurée + Personnalité 1
|
|
const baseContents = await tracer.run('SelectiveEnhancement.generateAllContentBase()', async () => {
|
|
const csvDataWithPersonality1 = { ...csvData, personality: personalities[0] };
|
|
const aiProvider1 = personalities[0].aiEtape1Base;
|
|
const result = await generateAllContentBase(hierarchy, csvDataWithPersonality1, aiProvider1);
|
|
await tracer.event(`${Object.keys(result).length} éléments générés avec ${personalities[0].nom} via ${aiProvider1.toUpperCase()}`);
|
|
return result;
|
|
}, { hierarchyElements: Object.keys(hierarchy).length, personality1: personalities[0].nom, llmProvider: personalities[0].aiEtape1Base, mc0: csvData.mc0 });
|
|
|
|
// ÉTAPE 2 : Enhancement technique avec IA configurée + Personnalité 2
|
|
const technicalEnhanced = await tracer.run('SelectiveEnhancement.enhanceAllTechnicalTerms()', async () => {
|
|
const csvDataWithPersonality2 = { ...csvData, personality: personalities[1] };
|
|
const aiProvider2 = personalities[1].aiEtape2Technique;
|
|
const result = await enhanceAllTechnicalTerms(baseContents, csvDataWithPersonality2, aiProvider2);
|
|
const enhancedCount = Object.keys(result).filter(k => result[k] !== baseContents[k]).length;
|
|
await tracer.event(`${enhancedCount}/${Object.keys(result).length} éléments techniques améliorés avec ${personalities[1].nom} via ${aiProvider2.toUpperCase()}`);
|
|
return result;
|
|
}, { baseElements: Object.keys(baseContents).length, personality2: personalities[1].nom, llmProvider: personalities[1].aiEtape2Technique, mc0: csvData.mc0 });
|
|
|
|
// ÉTAPE 3 : Enhancement transitions avec IA configurée + Personnalité 3
|
|
const transitionsEnhanced = await tracer.run('SelectiveEnhancement.enhanceAllTransitions()', async () => {
|
|
const csvDataWithPersonality3 = { ...csvData, personality: personalities[2] };
|
|
const aiProvider3 = personalities[2].aiEtape3Transitions;
|
|
const result = await enhanceAllTransitions(technicalEnhanced, csvDataWithPersonality3, aiProvider3);
|
|
const enhancedCount = Object.keys(result).filter(k => result[k] !== technicalEnhanced[k]).length;
|
|
await tracer.event(`${enhancedCount}/${Object.keys(result).length} transitions fluidifiées avec ${personalities[2].nom} via ${aiProvider3.toUpperCase()}`);
|
|
return result;
|
|
}, { technicalElements: Object.keys(technicalEnhanced).length, personality3: personalities[2].nom, llmProvider: personalities[2].aiEtape3Transitions });
|
|
|
|
// ÉTAPE 4 : Enhancement style avec IA configurée + Personnalité 4
|
|
const finalContents = await tracer.run('SelectiveEnhancement.enhanceAllPersonalityStyle()', async () => {
|
|
const csvDataWithPersonality4 = { ...csvData, personality: personalities[3] };
|
|
const aiProvider4 = personalities[3].aiEtape4Style;
|
|
const result = await enhanceAllPersonalityStyle(transitionsEnhanced, csvDataWithPersonality4, aiProvider4);
|
|
const enhancedCount = Object.keys(result).filter(k => result[k] !== transitionsEnhanced[k]).length;
|
|
const avgWords = Math.round(Object.values(result).reduce((acc, content) => acc + content.split(' ').length, 0) / Object.keys(result).length);
|
|
await tracer.event(`${enhancedCount}/${Object.keys(result).length} éléments stylisés avec ${personalities[3].nom} via ${aiProvider4.toUpperCase()}`, { avgWordsPerElement: avgWords });
|
|
return result;
|
|
}, { transitionElements: Object.keys(transitionsEnhanced).length, personality4: personalities[3].nom, llmProvider: personalities[3].aiEtape4Style });
|
|
|
|
// Log final du DNA Mixing réussi avec IA configurables
|
|
const aiChain = personalities.map((p, i) => `${p.aiEtape1Base || p.aiEtape2Technique || p.aiEtape3Transitions || p.aiEtape4Style}`.toUpperCase()).join(' → ');
|
|
logSh(`✅ DNA MIXING MULTI-PERSONNALITÉS TERMINÉ:`, 'INFO');
|
|
logSh(` 🎭 4 personnalités utilisées: ${personalities.map(p => p.nom).join(' → ')}`, 'INFO');
|
|
logSh(` 🤖 IA configurées: ${personalities[0].aiEtape1Base.toUpperCase()} → ${personalities[1].aiEtape2Technique.toUpperCase()} → ${personalities[2].aiEtape3Transitions.toUpperCase()} → ${personalities[3].aiEtape4Style.toUpperCase()}`, 'INFO');
|
|
logSh(` 📝 ${Object.keys(finalContents).length} éléments avec style hybride généré`, 'INFO');
|
|
|
|
return finalContents;
|
|
}
|
|
|
|
/**
|
|
* ÉTAPE 1 - Génération base TOUS éléments avec IA configurable
|
|
*/
|
|
async function generateAllContentBase(hierarchy, csvData, aiProvider) {
|
|
logSh('🔍 === DEBUG GÉNÉRATION BASE ===', 'DEBUG');
|
|
|
|
// Debug: logger la hiérarchie complète
|
|
logSh(`🔍 Hiérarchie reçue: ${Object.keys(hierarchy).length} sections`, 'DEBUG');
|
|
Object.keys(hierarchy).forEach((path, i) => {
|
|
const section = hierarchy[path];
|
|
logSh(`🔍 Section ${i+1} [${path}]:`, 'DEBUG');
|
|
logSh(`🔍 - title: ${section.title ? section.title.originalElement?.originalTag : 'AUCUN'}`, 'DEBUG');
|
|
logSh(`🔍 - text: ${section.text ? section.text.originalElement?.originalTag : 'AUCUN'}`, 'DEBUG');
|
|
logSh(`🔍 - questions: ${section.questions?.length || 0}`, 'DEBUG');
|
|
});
|
|
|
|
const allElements = collectAllElements(hierarchy);
|
|
logSh(`🔍 Éléments collectés: ${allElements.length}`, 'DEBUG');
|
|
|
|
// Debug: logger tous les éléments collectés
|
|
allElements.forEach((element, i) => {
|
|
logSh(`🔍 Élément ${i+1}: tag="${element.tag}", type="${element.type}"`, 'DEBUG');
|
|
});
|
|
|
|
// NOUVELLE LOGIQUE : SÉPARER PAIRES FAQ ET AUTRES ÉLÉMENTS
|
|
const results = {};
|
|
|
|
logSh(`🔍 === GÉNÉRATION INTELLIGENTE DE ${allElements.length} ÉLÉMENTS ===`, 'DEBUG');
|
|
logSh(`🔍 Ordre respecté: ${allElements.map(el => el.tag.replace(/\|/g, '')).join(' → ')}`, 'DEBUG');
|
|
|
|
// 1. IDENTIFIER les paires FAQ
|
|
const { faqPairs, otherElements } = separateFAQPairsAndOthers(allElements);
|
|
|
|
logSh(`🔍 ${faqPairs.length} paires FAQ trouvées, ${otherElements.length} autres éléments`, 'INFO');
|
|
|
|
// 2. GÉNÉRER les autres éléments EN BATCH ORDONNÉ (titres d'abord, puis textes avec contexte)
|
|
const groupedElements = groupElementsByType(otherElements);
|
|
|
|
// ORDRE DE GÉNÉRATION : TITRES → TEXTES → INTRO → AUTRES
|
|
const orderedTypes = ['titre', 'texte', 'intro'];
|
|
|
|
for (const type of orderedTypes) {
|
|
const elements = groupedElements[type];
|
|
if (!elements || elements.length === 0) continue;
|
|
|
|
// DÉCOUPER EN CHUNKS DE MAX 4 ÉLÉMENTS POUR ÉVITER TIMEOUTS
|
|
const chunks = chunkArray(elements, 4);
|
|
logSh(`🚀 BATCH ${type.toUpperCase()}: ${elements.length} éléments en ${chunks.length} chunks`, 'INFO');
|
|
|
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
const chunk = chunks[chunkIndex];
|
|
logSh(` Chunk ${chunkIndex + 1}/${chunks.length}: ${chunk.length} éléments`, 'DEBUG');
|
|
|
|
try {
|
|
// Passer les résultats déjà générés pour contexte (titres → textes)
|
|
const batchPrompt = createBatchBasePrompt(chunk, type, csvData, results);
|
|
|
|
const batchResponse = await callLLM(aiProvider, batchPrompt, {
|
|
temperature: 0.7,
|
|
maxTokens: 2000 * chunk.length
|
|
}, csvData.personality);
|
|
|
|
const batchResults = parseBatchResponse(batchResponse, chunk);
|
|
Object.assign(results, batchResults);
|
|
|
|
logSh(`✅ Chunk ${chunkIndex + 1}: ${Object.keys(batchResults).length}/${chunk.length} éléments générés`, 'INFO');
|
|
|
|
} catch (error) {
|
|
logSh(`❌ FATAL: Chunk ${chunkIndex + 1} de ${type} échoué: ${error.message}`, 'ERROR');
|
|
throw new Error(`FATAL: Génération chunk ${chunkIndex + 1} de ${type} échouée - arrêt du workflow: ${error.message}`);
|
|
}
|
|
|
|
// Délai entre chunks pour éviter rate limiting
|
|
if (chunkIndex < chunks.length - 1) {
|
|
await sleep(1500);
|
|
}
|
|
}
|
|
|
|
logSh(`✅ BATCH ${type.toUpperCase()} COMPLET: ${elements.length} éléments générés en ${chunks.length} chunks`, 'INFO');
|
|
}
|
|
|
|
// TRAITER les types restants (autres que titre/texte/intro)
|
|
for (const [type, elements] of Object.entries(groupedElements)) {
|
|
if (orderedTypes.includes(type) || elements.length === 0) continue;
|
|
|
|
// DÉCOUPER EN CHUNKS DE MAX 4 ÉLÉMENTS POUR ÉVITER TIMEOUTS
|
|
const chunks = chunkArray(elements, 4);
|
|
logSh(`🚀 BATCH ${type.toUpperCase()}: ${elements.length} éléments en ${chunks.length} chunks`, 'INFO');
|
|
|
|
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 batchPrompt = createBatchBasePrompt(chunk, type, csvData, results);
|
|
|
|
const batchResponse = await callLLM(aiProvider, batchPrompt, {
|
|
temperature: 0.7,
|
|
maxTokens: 2000 * chunk.length
|
|
}, csvData.personality);
|
|
|
|
const batchResults = parseBatchResponse(batchResponse, chunk);
|
|
Object.assign(results, batchResults);
|
|
|
|
logSh(`✅ Chunk ${chunkIndex + 1}: ${Object.keys(batchResults).length}/${chunk.length} éléments générés`, 'INFO');
|
|
|
|
} catch (error) {
|
|
logSh(`❌ FATAL: Chunk ${chunkIndex + 1} de ${type} échoué: ${error.message}`, 'ERROR');
|
|
throw new Error(`FATAL: Génération chunk ${chunkIndex + 1} de ${type} échouée - arrêt du workflow: ${error.message}`);
|
|
}
|
|
|
|
// Délai entre chunks
|
|
if (chunkIndex < chunks.length - 1) {
|
|
await sleep(1500);
|
|
}
|
|
}
|
|
|
|
logSh(`✅ BATCH ${type.toUpperCase()} COMPLET: ${elements.length} éléments générés en ${chunks.length} chunks`, 'INFO');
|
|
}
|
|
|
|
// 3. GÉNÉRER les paires FAQ ensemble (RESTAURÉ depuis .gs)
|
|
if (faqPairs.length > 0) {
|
|
logSh(`🔍 === GÉNÉRATION PAIRES FAQ (${faqPairs.length} paires) ===`, 'INFO');
|
|
const faqResults = await generateFAQPairsRestored(faqPairs, csvData, aiProvider);
|
|
Object.assign(results, faqResults);
|
|
}
|
|
|
|
logSh(`🔍 === RÉSULTATS FINAUX GÉNÉRATION BASE ===`, 'DEBUG');
|
|
logSh(`🔍 Total généré: ${Object.keys(results).length} éléments`, 'DEBUG');
|
|
Object.keys(results).forEach(tag => {
|
|
logSh(`🔍 [${tag}]: "${results[tag]}"`, 'DEBUG');
|
|
});
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* ÉTAPE 2 - Enhancement technique BATCH OPTIMISÉ avec IA configurable
|
|
* OPTIMISATION : 1 appel extraction + 1 appel enhancement au lieu de 20+
|
|
*/
|
|
async function enhanceAllTechnicalTerms(baseContents, csvData, aiProvider) {
|
|
logSh('🔧 === DÉBUT ENHANCEMENT TECHNIQUE ===', 'INFO');
|
|
logSh('Enhancement technique BATCH TOTAL...', 'DEBUG');
|
|
|
|
const allElements = Object.keys(baseContents);
|
|
if (allElements.length === 0) {
|
|
logSh('⚠️ Aucun élément à analyser techniquement', 'WARNING');
|
|
return baseContents;
|
|
}
|
|
|
|
const analysisStart = Date.now();
|
|
logSh(`📊 Analyse démarrée: ${allElements.length} éléments à examiner`, 'INFO');
|
|
|
|
try {
|
|
// ÉTAPE 1 : Extraction batch TOUS les termes techniques (1 seul appel)
|
|
logSh(`🔍 Analyse technique batch: ${allElements.length} éléments`, 'INFO');
|
|
const technicalAnalysis = await extractAllTechnicalTermsBatch(baseContents, csvData, aiProvider);
|
|
const analysisEnd = Date.now();
|
|
|
|
// ÉTAPE 2 : Enhancement batch TOUS les éléments qui en ont besoin (1 seul appel)
|
|
const elementsNeedingEnhancement = technicalAnalysis.filter(item => item.needsEnhancement);
|
|
|
|
logSh(`📋 Analyse terminée (${analysisEnd - analysisStart}ms):`, 'INFO');
|
|
logSh(` • ${elementsNeedingEnhancement.length}/${allElements.length} éléments nécessitent enhancement`, 'INFO');
|
|
|
|
if (elementsNeedingEnhancement.length === 0) {
|
|
logSh('✅ Aucun élément ne nécessite enhancement technique - contenu déjà optimal', 'INFO');
|
|
return baseContents;
|
|
}
|
|
|
|
// Log détaillé des éléments à améliorer
|
|
elementsNeedingEnhancement.forEach((item, i) => {
|
|
logSh(` ${i+1}. [${item.tag}]: ${item.technicalTerms.join(', ')}`, 'DEBUG');
|
|
});
|
|
|
|
const enhancementStart = Date.now();
|
|
logSh(`🔧 Enhancement technique: ${elementsNeedingEnhancement.length}/${allElements.length} éléments`, 'INFO');
|
|
const enhancedContents = await enhanceAllElementsTechnicalBatch(elementsNeedingEnhancement, csvData, aiProvider);
|
|
const enhancementEnd = Date.now();
|
|
|
|
// ÉTAPE 3 : Merger résultats
|
|
const results = { ...baseContents };
|
|
let actuallyEnhanced = 0;
|
|
Object.keys(enhancedContents).forEach(tag => {
|
|
if (enhancedContents[tag] !== baseContents[tag]) {
|
|
results[tag] = enhancedContents[tag];
|
|
actuallyEnhanced++;
|
|
}
|
|
});
|
|
|
|
logSh(`⚡ Enhancement terminé (${enhancementEnd - enhancementStart}ms):`, 'INFO');
|
|
logSh(` • ${actuallyEnhanced} éléments réellement améliorés`, 'INFO');
|
|
logSh(` • Termes intégrés: dibond, impression UV, fraisage, etc.`, 'DEBUG');
|
|
logSh(`✅ Enhancement technique terminé avec succès`, 'INFO');
|
|
return results;
|
|
|
|
} catch (error) {
|
|
const analysisTotal = Date.now() - analysisStart;
|
|
logSh(`❌ FATAL: Enhancement technique échoué après ${analysisTotal}ms`, 'ERROR');
|
|
logSh(`❌ Message: ${error.message}`, 'ERROR');
|
|
throw new Error(`FATAL: Enhancement technique impossible - arrêt du workflow: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* NOUVELLE FONCTION : Extraction batch TOUS les termes techniques
|
|
*/
|
|
async function extractAllTechnicalTermsBatch(baseContents, csvData, aiProvider) {
|
|
const contentEntries = Object.keys(baseContents);
|
|
|
|
const batchAnalysisPrompt = `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: "${baseContents[tag]}"`).join('\n\n')}
|
|
|
|
CONSIGNES:
|
|
- Identifie UNIQUEMENT les vrais termes techniques métier/industrie
|
|
- Évite mots génériques (qualité, service, pratique, personnalisé, etc.)
|
|
- Focus: matériaux, procédés, normes, dimensions, technologies
|
|
- Si aucun terme technique → "AUCUN"
|
|
|
|
EXEMPLES VALIDES: dibond, impression UV, fraisage CNC, épaisseur 3mm, aluminium brossé
|
|
EXEMPLES INVALIDES: durable, pratique, personnalisé, moderne, esthétique
|
|
|
|
FORMAT RÉPONSE EXACT:
|
|
[1] dibond, impression UV, 3mm OU AUCUN
|
|
[2] aluminium, fraisage CNC OU AUCUN
|
|
[3] AUCUN
|
|
etc... (${contentEntries.length} lignes total)`;
|
|
|
|
try {
|
|
const analysisResponse = await callLLM(aiProvider, batchAnalysisPrompt, {
|
|
temperature: 0.3,
|
|
maxTokens: 2000
|
|
}, csvData.personality);
|
|
|
|
return parseAllTechnicalTermsResponse(analysisResponse, baseContents, contentEntries);
|
|
|
|
} catch (error) {
|
|
logSh(`❌ FATAL: Extraction termes techniques batch échouée: ${error.message}`, 'ERROR');
|
|
throw new Error(`FATAL: Analyse termes techniques impossible - arrêt du workflow: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* NOUVELLE FONCTION : Enhancement batch TOUS les éléments
|
|
*/
|
|
async function enhanceAllElementsTechnicalBatch(elementsNeedingEnhancement, csvData, aiProvider) {
|
|
if (elementsNeedingEnhancement.length === 0) return {};
|
|
|
|
const batchEnhancementPrompt = `MISSION: Améliore UNIQUEMENT la précision technique de ces ${elementsNeedingEnhancement.length} contenus.
|
|
|
|
PERSONNALITÉ: ${csvData.personality?.nom} (${csvData.personality?.style})
|
|
CONTEXTE: ${csvData.mc0} - Secteur: Signalétique/impression
|
|
VOCABULAIRE PRÉFÉRÉ: ${csvData.personality?.vocabulairePref}
|
|
|
|
CONTENUS + TERMES À AMÉLIORER:
|
|
|
|
${elementsNeedingEnhancement.map((item, i) => `[${i + 1}] TAG: ${item.tag}
|
|
CONTENU ACTUEL: "${item.content}"
|
|
TERMES TECHNIQUES À INTÉGRER: ${item.technicalTerms.join(', ')}`).join('\n\n')}
|
|
|
|
CONSIGNES STRICTES:
|
|
- Améliore UNIQUEMENT la précision technique, garde le style ${csvData.personality?.nom}
|
|
- GARDE la même longueur, structure et ton
|
|
- Intègre naturellement les termes techniques listés
|
|
- NE CHANGE PAS le fond du message ni le style personnel
|
|
- Utilise un vocabulaire expert mais accessible
|
|
- ÉVITE les répétitions excessives
|
|
- RESPECTE le niveau technique: ${csvData.personality?.niveauTechnique}
|
|
- Termes techniques secteur: dibond, aluminium, impression UV, fraisage, épaisseur, PMMA
|
|
|
|
FORMAT RÉPONSE:
|
|
[1] Contenu avec amélioration technique selon ${csvData.personality?.nom}
|
|
[2] Contenu avec amélioration technique selon ${csvData.personality?.nom}
|
|
etc... (${elementsNeedingEnhancement.length} éléments total)`;
|
|
|
|
try {
|
|
const enhanced = await callLLM(aiProvider, batchEnhancementPrompt, {
|
|
temperature: 0.4,
|
|
maxTokens: 5000 // Plus large pour batch total
|
|
}, csvData.personality);
|
|
|
|
return parseTechnicalEnhancementBatchResponse(enhanced, elementsNeedingEnhancement);
|
|
|
|
} catch (error) {
|
|
logSh(`❌ FATAL: Enhancement technique batch échoué: ${error.message}`, 'ERROR');
|
|
throw new Error(`FATAL: Enhancement technique batch impossible - arrêt du workflow: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ÉTAPE 3 - Enhancement transitions BATCH avec IA configurable
|
|
*/
|
|
async function enhanceAllTransitions(baseContents, csvData, aiProvider) {
|
|
logSh('🔗 === DÉBUT ENHANCEMENT TRANSITIONS ===', 'INFO');
|
|
logSh('Enhancement transitions batch...', 'DEBUG');
|
|
|
|
const transitionStart = Date.now();
|
|
const allElements = Object.keys(baseContents);
|
|
logSh(`📊 Analyse transitions: ${allElements.length} éléments à examiner`, 'INFO');
|
|
|
|
// Sélectionner éléments longs qui bénéficient d'amélioration transitions
|
|
const transitionElements = [];
|
|
let analyzedCount = 0;
|
|
Object.keys(baseContents).forEach(tag => {
|
|
const content = baseContents[tag];
|
|
analyzedCount++;
|
|
if (content.length > 150) {
|
|
const needsTransitions = analyzeTransitionNeed(content);
|
|
logSh(` [${tag}]: ${content.length}c, transitions=${needsTransitions ? '✅' : '❌'}`, 'DEBUG');
|
|
if (needsTransitions) {
|
|
transitionElements.push({
|
|
tag: tag,
|
|
content: content
|
|
});
|
|
}
|
|
} else {
|
|
logSh(` [${tag}]: ${content.length}c - trop court, ignoré`, 'DEBUG');
|
|
}
|
|
});
|
|
|
|
logSh(`📋 Analyse transitions terminée:`, 'INFO');
|
|
logSh(` • ${analyzedCount} éléments analysés`, 'INFO');
|
|
logSh(` • ${transitionElements.length} nécessitent amélioration`, 'INFO');
|
|
|
|
if (transitionElements.length === 0) {
|
|
logSh('✅ Pas d\'éléments nécessitant enhancement transitions - fluidité déjà optimale', 'INFO');
|
|
return baseContents;
|
|
}
|
|
|
|
logSh(`${transitionElements.length} éléments à améliorer (transitions)`, 'INFO');
|
|
|
|
const chunks = chunkArray(transitionElements, 6); // Plus petit pour Gemini
|
|
const results = { ...baseContents };
|
|
|
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
const chunk = chunks[chunkIndex];
|
|
|
|
try {
|
|
logSh(`Chunk transitions ${chunkIndex + 1}/${chunks.length} (${chunk.length} éléments)`, 'DEBUG');
|
|
|
|
const batchTransitionsPrompt = `MISSION: Améliore UNIQUEMENT les transitions et fluidité de ces contenus.
|
|
|
|
PERSONNALITÉ: ${csvData.personality?.nom} (${csvData.personality?.style})
|
|
CONNECTEURS PRÉFÉRÉS: ${csvData.personality?.connecteursPref}
|
|
|
|
CONTENUS:
|
|
|
|
${chunk.map((item, i) => `[${i + 1}] TAG: ${item.tag}
|
|
"${item.content}"`).join('\n\n')}
|
|
|
|
OBJECTIFS:
|
|
- Connecteurs plus naturels et variés issus de: ${csvData.personality?.connecteursPref}
|
|
- Transitions fluides entre idées
|
|
- ÉVITE répétitions excessives ("franchement", "du coup", "vraiment", "par ailleurs")
|
|
- Style cohérent ${csvData.personality?.style}
|
|
|
|
CONTRAINTES STRICTES:
|
|
- NE CHANGE PAS le fond du message
|
|
- GARDE la même structure et longueur approximative
|
|
- Améliore SEULEMENT la fluidité des transitions
|
|
- RESPECTE le style ${csvData.personality?.nom}
|
|
- RÉPONDS DIRECTEMENT PAR LE CONTENU AMÉLIORÉ, sans préfixe ni tag XML
|
|
|
|
FORMAT DE RÉPONSE:
|
|
[1] Contenu avec transitions améliorées selon ${csvData.personality?.nom}
|
|
[2] Contenu avec transitions améliorées selon ${csvData.personality?.nom}
|
|
etc...`;
|
|
|
|
const improved = await callLLM(aiProvider, batchTransitionsPrompt, {
|
|
temperature: 0.6,
|
|
maxTokens: 2500
|
|
}, csvData.personality);
|
|
|
|
const parsedImprovements = parseTransitionsBatchResponse(improved, chunk);
|
|
|
|
Object.keys(parsedImprovements).forEach(tag => {
|
|
results[tag] = parsedImprovements[tag];
|
|
});
|
|
|
|
} catch (error) {
|
|
logSh(`❌ Erreur chunk transitions ${chunkIndex + 1}: ${error.message}`, 'ERROR');
|
|
}
|
|
|
|
if (chunkIndex < chunks.length - 1) {
|
|
await sleep(1500);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* ÉTAPE 4 - Enhancement style personnalité BATCH avec IA configurable
|
|
*/
|
|
async function enhanceAllPersonalityStyle(baseContents, csvData, aiProvider) {
|
|
const personality = csvData.personality;
|
|
if (!personality) {
|
|
logSh('Pas de personnalité, skip enhancement style', 'DEBUG');
|
|
return baseContents;
|
|
}
|
|
|
|
logSh(`Enhancement style ${personality.nom} batch...`, 'DEBUG');
|
|
|
|
// Tous les éléments bénéficient de l'adaptation personnalité
|
|
const styleElements = Object.keys(baseContents).map(tag => ({
|
|
tag: tag,
|
|
content: baseContents[tag]
|
|
}));
|
|
|
|
const chunks = chunkArray(styleElements, 8);
|
|
const results = { ...baseContents };
|
|
|
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
const chunk = chunks[chunkIndex];
|
|
|
|
try {
|
|
logSh(`Chunk style ${chunkIndex + 1}/${chunks.length} (${chunk.length} éléments)`, 'DEBUG');
|
|
|
|
const batchStylePrompt = `MISSION: Adapte UNIQUEMENT le style de ces contenus selon ${personality.nom}.
|
|
|
|
PERSONNALITÉ: ${personality.nom}
|
|
DESCRIPTION: ${personality.description}
|
|
STYLE CIBLE: ${personality.style}
|
|
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}
|
|
"${item.content}"`).join('\n\n')}
|
|
|
|
CONSIGNES STRICTES:
|
|
- GARDE le même contenu informatif et technique
|
|
- Adapte SEULEMENT le ton, les expressions et le vocabulaire selon ${personality.nom}
|
|
- RESPECTE la longueur approximative (même nombre de mots ±20%)
|
|
- ÉVITE les répétitions excessives ("franchement", "du coup", "vraiment")
|
|
- VARIE les expressions et connecteurs selon: ${personality.connecteursPref}
|
|
- Style ${personality.nom} reconnaissable mais NATUREL
|
|
- RÉPONDS DIRECTEMENT PAR LE CONTENU STYLISÉ, sans préfixe ni tag XML
|
|
- PAS de messages d'excuse ou d'incapacité
|
|
|
|
FORMAT DE RÉPONSE:
|
|
[1] Contenu stylisé selon ${personality.nom} (${personality.style})
|
|
[2] Contenu stylisé selon ${personality.nom} (${personality.style})
|
|
etc...`;
|
|
|
|
const styled = await callLLM(aiProvider, batchStylePrompt, {
|
|
temperature: 0.8,
|
|
maxTokens: 3000
|
|
}, personality);
|
|
|
|
const parsedStyles = parseStyleBatchResponse(styled, chunk);
|
|
|
|
Object.keys(parsedStyles).forEach(tag => {
|
|
results[tag] = parsedStyles[tag];
|
|
});
|
|
|
|
} catch (error) {
|
|
logSh(`❌ Erreur chunk style ${chunkIndex + 1}: ${error.message}`, 'ERROR');
|
|
}
|
|
|
|
if (chunkIndex < chunks.length - 1) {
|
|
await sleep(1500);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ============= HELPER FUNCTIONS =============
|
|
|
|
/**
|
|
* Sleep function replacement for Utilities.sleep
|
|
*/
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* RESTAURÉ DEPUIS .GS : Génération des paires FAQ cohérentes
|
|
*/
|
|
async function generateFAQPairsRestored(faqPairs, csvData, aiProvider) {
|
|
logSh(`🔍 === GÉNÉRATION PAIRES FAQ (logique .gs restaurée) ===`, 'INFO');
|
|
|
|
if (faqPairs.length === 0) return {};
|
|
|
|
const batchPrompt = createBatchFAQPairsPrompt(faqPairs, csvData);
|
|
logSh(`🔍 Prompt FAQ paires (${batchPrompt.length} chars): "${batchPrompt.substring(0, 300)}..."`, 'DEBUG');
|
|
|
|
try {
|
|
const batchResponse = await callLLM(aiProvider, batchPrompt, {
|
|
temperature: 0.8,
|
|
maxTokens: 3000 // Plus large pour les paires
|
|
}, csvData.personality);
|
|
|
|
logSh(`🔍 Réponse FAQ paires reçue: ${batchResponse.length} caractères`, 'DEBUG');
|
|
logSh(`🔍 Début réponse: "${batchResponse.substring(0, 200)}..."`, 'DEBUG');
|
|
|
|
return parseFAQPairsResponse(batchResponse, faqPairs);
|
|
|
|
} catch (error) {
|
|
logSh(`❌ FATAL: Erreur génération paires FAQ: ${error.message}`, 'ERROR');
|
|
throw new Error(`FATAL: Génération paires FAQ échouée - arrêt du workflow: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* RESTAURÉ DEPUIS .GS : Prompt pour paires FAQ cohérentes
|
|
*/
|
|
function createBatchFAQPairsPrompt(faqPairs, csvData) {
|
|
const personality = csvData.personality;
|
|
|
|
let prompt = `PERSONNALITÉ: ${personality.nom} | ${personality.description}
|
|
STYLE: ${personality.style}
|
|
VOCABULAIRE: ${personality.vocabulairePref}
|
|
CONNECTEURS: ${personality.connecteursPref}
|
|
NIVEAU TECHNIQUE: ${personality.niveauTechnique}
|
|
|
|
GÉNÈRE ${faqPairs.length} PAIRES FAQ COHÉRENTES pour ${csvData.mc0}:
|
|
|
|
RÈGLES STRICTES:
|
|
- QUESTIONS: Neutres, directes, langage client naturel (8-15 mots)
|
|
- RÉPONSES: Style ${personality.style}, vocabulaire ${personality.vocabulairePref} (50-80 mots)
|
|
- Sujets à couvrir: prix, livraison, personnalisation, installation, durabilité
|
|
- ÉVITE répétitions excessives et expressions trop familières
|
|
- Style ${personality.nom} reconnaissable mais PROFESSIONNEL
|
|
- PAS de messages d'excuse ("je n'ai pas l'information")
|
|
- RÉPONDS DIRECTEMENT par questions et réponses, sans préfixe
|
|
|
|
PAIRES À GÉNÉRER:
|
|
`;
|
|
|
|
faqPairs.forEach((pair, index) => {
|
|
const questionTag = pair.question.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '');
|
|
const answerTag = pair.answer.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '');
|
|
|
|
prompt += `${index + 1}. [${questionTag}] + [${answerTag}]
|
|
Question client sur ${csvData.mc0} → Réponse ${personality.style}
|
|
`;
|
|
});
|
|
|
|
prompt += `
|
|
|
|
FORMAT DE RÉPONSE:
|
|
PAIRE 1:
|
|
[${faqPairs[0].question.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '')}]
|
|
Question client directe et naturelle sur ${csvData.mc0} ?
|
|
|
|
[${faqPairs[0].answer.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '')}]
|
|
Réponse utile et rassurante selon le style ${personality.style} de ${personality.nom}.
|
|
`;
|
|
|
|
if (faqPairs.length > 1) {
|
|
prompt += `PAIRE 2:
|
|
etc...
|
|
`;
|
|
}
|
|
|
|
return prompt;
|
|
}
|
|
|
|
/**
|
|
* RESTAURÉ DEPUIS .GS : Parser réponse paires FAQ
|
|
*/
|
|
function parseFAQPairsResponse(response, faqPairs) {
|
|
const results = {};
|
|
|
|
logSh(`🔍 Parsing FAQ paires: "${response.substring(0, 300)}..."`, 'DEBUG');
|
|
|
|
// Parser avec regex [TAG] contenu
|
|
const regex = /\[([^\]]+)\]\s*([^[]*?)(?=\[|$)/gs;
|
|
let match;
|
|
const parsedItems = {};
|
|
|
|
while ((match = regex.exec(response)) !== null) {
|
|
const tag = match[1].trim();
|
|
let content = match[2].trim().replace(/\n\s*\n/g, '\n').replace(/^\n+|\n+$/g, '');
|
|
|
|
// NOUVEAU: Appliquer le nettoyage XML pour FAQ aussi
|
|
content = cleanXMLTagsFromContent(content);
|
|
|
|
if (content && content.length > 0) {
|
|
parsedItems[tag] = content;
|
|
logSh(`🔍 Parsé [${tag}]: "${content.substring(0, 100)}..."`, 'DEBUG');
|
|
}
|
|
}
|
|
|
|
// Mapper aux vrais tags FAQ avec |
|
|
let pairesCompletes = 0;
|
|
faqPairs.forEach(pair => {
|
|
const questionCleanTag = pair.question.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '');
|
|
const answerCleanTag = pair.answer.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '');
|
|
|
|
const questionContent = parsedItems[questionCleanTag];
|
|
const answerContent = parsedItems[answerCleanTag];
|
|
|
|
if (questionContent && answerContent) {
|
|
results[pair.question.tag] = questionContent;
|
|
results[pair.answer.tag] = answerContent;
|
|
pairesCompletes++;
|
|
logSh(`✅ Paire FAQ ${pair.number} complète: Q="${questionContent}" R="${answerContent.substring(0, 50)}..."`, 'INFO');
|
|
} else {
|
|
logSh(`⚠️ Paire FAQ ${pair.number} incomplète: Q=${!!questionContent} R=${!!answerContent}`, 'WARNING');
|
|
|
|
if (questionContent) results[pair.question.tag] = questionContent;
|
|
if (answerContent) results[pair.answer.tag] = answerContent;
|
|
}
|
|
});
|
|
|
|
logSh(`📊 FAQ parsing: ${pairesCompletes}/${faqPairs.length} paires complètes`, 'INFO');
|
|
|
|
// FATAL si aucune paire complète (comme dans le .gs)
|
|
if (pairesCompletes === 0 && faqPairs.length > 0) {
|
|
logSh(`❌ FATAL: Aucune paire FAQ générée correctement`, 'ERROR');
|
|
throw new Error(`FATAL: Génération FAQ incomplète (0/${faqPairs.length} paires complètes) - arrêt du workflow`);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* RESTAURÉ DEPUIS .GS : Nettoyer instructions FAQ
|
|
*/
|
|
function cleanFAQInstructions(instructions, csvData) {
|
|
if (!instructions) return '';
|
|
|
|
let cleanInstructions = instructions;
|
|
|
|
// Remplacer variables
|
|
cleanInstructions = cleanInstructions.replace(/\{\{T0\}\}/g, csvData.t0 || '');
|
|
cleanInstructions = cleanInstructions.replace(/\{\{MC0\}\}/g, csvData.mc0 || '');
|
|
cleanInstructions = cleanInstructions.replace(/\{\{T-1\}\}/g, csvData.tMinus1 || '');
|
|
cleanInstructions = cleanInstructions.replace(/\{\{L-1\}\}/g, csvData.lMinus1 || '');
|
|
|
|
// Variables multiples MC+1_X, T+1_X, L+1_X
|
|
if (csvData.mcPlus1) {
|
|
const mcPlus1 = csvData.mcPlus1.split(',').map(s => s.trim());
|
|
for (let i = 1; i <= 6; i++) {
|
|
const mcValue = mcPlus1[i-1] || `[MC+1_${i} non défini]`;
|
|
cleanInstructions = cleanInstructions.replace(new RegExp(`\\{\\{MC\\+1_${i}\\}\\}`, 'g'), mcValue);
|
|
}
|
|
}
|
|
|
|
if (csvData.tPlus1) {
|
|
const tPlus1 = csvData.tPlus1.split(',').map(s => s.trim());
|
|
for (let i = 1; i <= 6; i++) {
|
|
const tValue = tPlus1[i-1] || `[T+1_${i} non défini]`;
|
|
cleanInstructions = cleanInstructions.replace(new RegExp(`\\{\\{T\\+1_${i}\\}\\}`, 'g'), tValue);
|
|
}
|
|
}
|
|
|
|
// Nettoyer HTML
|
|
cleanInstructions = cleanInstructions.replace(/<\/?[^>]+>/g, '');
|
|
cleanInstructions = cleanInstructions.replace(/\s+/g, ' ').trim();
|
|
|
|
return cleanInstructions;
|
|
}
|
|
|
|
/**
|
|
* Collecter tous les éléments dans l'ordre XML original
|
|
* CORRECTION: Suit l'ordre séquentiel XML au lieu de grouper par section
|
|
*/
|
|
function collectAllElements(hierarchy) {
|
|
const allElements = [];
|
|
const tagToElementMap = {};
|
|
|
|
// 1. Créer un mapping de tous les éléments disponibles
|
|
Object.keys(hierarchy).forEach(path => {
|
|
const section = hierarchy[path];
|
|
|
|
if (section.title) {
|
|
tagToElementMap[section.title.originalElement.originalTag] = {
|
|
tag: section.title.originalElement.originalTag,
|
|
element: section.title.originalElement,
|
|
type: 'titre'
|
|
};
|
|
}
|
|
|
|
if (section.text) {
|
|
tagToElementMap[section.text.originalElement.originalTag] = {
|
|
tag: section.text.originalElement.originalTag,
|
|
element: section.text.originalElement,
|
|
type: 'texte'
|
|
};
|
|
}
|
|
|
|
section.questions.forEach(q => {
|
|
tagToElementMap[q.originalElement.originalTag] = {
|
|
tag: q.originalElement.originalTag,
|
|
element: q.originalElement,
|
|
type: q.originalElement.type
|
|
};
|
|
});
|
|
});
|
|
|
|
// 2. Récupérer l'ordre XML original depuis le template global
|
|
logSh(`🔍 Global XML Template disponible: ${!!global.currentXmlTemplate}`, 'DEBUG');
|
|
if (global.currentXmlTemplate && global.currentXmlTemplate.length > 0) {
|
|
logSh(`🔍 Template XML: ${global.currentXmlTemplate.substring(0, 200)}...`, 'DEBUG');
|
|
const regex = /\|([^|]+)\|/g;
|
|
let match;
|
|
|
|
// Parcourir le XML dans l'ordre d'apparition
|
|
while ((match = regex.exec(global.currentXmlTemplate)) !== null) {
|
|
const fullMatch = match[1];
|
|
|
|
// Extraire le nom du tag (sans variables)
|
|
const nameMatch = fullMatch.match(/^([^{]+)/);
|
|
const tagName = nameMatch ? nameMatch[1].trim() : fullMatch.split('{')[0];
|
|
const pureTag = `|${tagName}|`;
|
|
|
|
// Si cet élément existe dans notre mapping, l'ajouter dans l'ordre
|
|
if (tagToElementMap[pureTag]) {
|
|
allElements.push(tagToElementMap[pureTag]);
|
|
logSh(`🔍 Ajouté dans l'ordre: ${pureTag}`, 'DEBUG');
|
|
delete tagToElementMap[pureTag]; // Éviter les doublons
|
|
} else {
|
|
logSh(`🔍 Tag XML non trouvé dans mapping: ${pureTag}`, 'DEBUG');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Ajouter les éléments restants (sécurité)
|
|
const remainingElements = Object.values(tagToElementMap);
|
|
if (remainingElements.length > 0) {
|
|
logSh(`🔍 Éléments restants ajoutés: ${remainingElements.map(el => el.tag).join(', ')}`, 'DEBUG');
|
|
remainingElements.forEach(element => {
|
|
allElements.push(element);
|
|
});
|
|
}
|
|
|
|
logSh(`🔍 ORDRE FINAL: ${allElements.map(el => el.tag.replace(/\|/g, '')).join(' → ')}`, 'INFO');
|
|
|
|
return allElements;
|
|
}
|
|
|
|
/**
|
|
* RESTAURÉ DEPUIS .GS : Séparer les paires FAQ des autres éléments
|
|
*/
|
|
function separateFAQPairsAndOthers(allElements) {
|
|
const faqPairs = [];
|
|
const otherElements = [];
|
|
const faqQuestions = {};
|
|
const faqAnswers = {};
|
|
|
|
// 1. Collecter toutes les questions et réponses FAQ
|
|
allElements.forEach(element => {
|
|
if (element.type === 'faq_question') {
|
|
// Extraire le numéro : |Faq_q_1| → 1
|
|
const numberMatch = element.tag.match(/(\d+)/);
|
|
const faqNumber = numberMatch ? numberMatch[1] : '1';
|
|
faqQuestions[faqNumber] = element;
|
|
logSh(`🔍 Question FAQ ${faqNumber} trouvée: ${element.tag}`, 'DEBUG');
|
|
} else if (element.type === 'faq_reponse') {
|
|
// Extraire le numéro : |Faq_a_1| → 1
|
|
const numberMatch = element.tag.match(/(\d+)/);
|
|
const faqNumber = numberMatch ? numberMatch[1] : '1';
|
|
faqAnswers[faqNumber] = element;
|
|
logSh(`🔍 Réponse FAQ ${faqNumber} trouvée: ${element.tag}`, 'DEBUG');
|
|
} else {
|
|
// Élément normal (titre, texte, intro, etc.)
|
|
otherElements.push(element);
|
|
}
|
|
});
|
|
|
|
// 2. Créer les paires FAQ cohérentes
|
|
Object.keys(faqQuestions).forEach(number => {
|
|
const question = faqQuestions[number];
|
|
const answer = faqAnswers[number];
|
|
|
|
if (question && answer) {
|
|
faqPairs.push({
|
|
number: number,
|
|
question: question,
|
|
answer: answer
|
|
});
|
|
logSh(`✅ Paire FAQ ${number} créée: ${question.tag} + ${answer.tag}`, 'INFO');
|
|
} else if (question) {
|
|
logSh(`⚠️ Question FAQ ${number} sans réponse correspondante`, 'WARNING');
|
|
otherElements.push(question); // Traiter comme élément individuel
|
|
} else if (answer) {
|
|
logSh(`⚠️ Réponse FAQ ${number} sans question correspondante`, 'WARNING');
|
|
otherElements.push(answer); // Traiter comme élément individuel
|
|
}
|
|
});
|
|
|
|
logSh(`🔍 Séparation terminée: ${faqPairs.length} paires FAQ, ${otherElements.length} autres éléments`, 'INFO');
|
|
|
|
return { faqPairs, otherElements };
|
|
}
|
|
|
|
/**
|
|
* Grouper éléments par type
|
|
*/
|
|
function groupElementsByType(elements) {
|
|
const groups = {};
|
|
|
|
elements.forEach(element => {
|
|
const type = element.type;
|
|
if (!groups[type]) {
|
|
groups[type] = [];
|
|
}
|
|
groups[type].push(element);
|
|
});
|
|
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* Diviser array en chunks
|
|
*/
|
|
function chunkArray(array, size) {
|
|
const chunks = [];
|
|
for (let i = 0; i < array.length; i += size) {
|
|
chunks.push(array.slice(i, i + size));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
/**
|
|
* Trouver le titre associé à un élément texte
|
|
*/
|
|
function findAssociatedTitle(textElement, existingResults) {
|
|
const textName = textElement.element.name || textElement.tag;
|
|
|
|
// STRATÉGIE 1: Correspondance directe (Txt_H2_1 → Titre_H2_1)
|
|
const directMatch = textName.replace(/Txt_/, 'Titre_').replace(/Text_/, 'Titre_');
|
|
const directTitle = existingResults[`|${directMatch}|`] || existingResults[directMatch];
|
|
if (directTitle) return directTitle;
|
|
|
|
// STRATÉGIE 2: Même niveau hiérarchique (H2, H3)
|
|
const levelMatch = textName.match(/(H\d)_(\d+)/);
|
|
if (levelMatch) {
|
|
const [, level, number] = levelMatch;
|
|
const titleTag = `Titre_${level}_${number}`;
|
|
const levelTitle = existingResults[`|${titleTag}|`] || existingResults[titleTag];
|
|
if (levelTitle) return levelTitle;
|
|
}
|
|
|
|
// STRATÉGIE 3: Proximité dans l'ordre (texte suivant un titre)
|
|
const allTitles = Object.entries(existingResults)
|
|
.filter(([tag]) => tag.includes('Titre'))
|
|
.sort(([a], [b]) => a.localeCompare(b));
|
|
|
|
if (allTitles.length > 0) {
|
|
// Retourner le premier titre disponible comme contexte général
|
|
return allTitles[0][1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Créer prompt batch de base
|
|
*/
|
|
function createBatchBasePrompt(elements, type, csvData, existingResults = {}) {
|
|
const personality = csvData.personality;
|
|
|
|
let prompt = `RÉDACTEUR: ${personality.nom} | Style: ${personality.style}
|
|
SUJET: ${csvData.mc0}
|
|
|
|
${type === 'titre' ? 'GÉNÈRE DES TITRES COURTS ET IMPACTANTS' : `GÉNÈRE ${elements.length} ${type.toUpperCase()}S PROFESSIONNELS`}:
|
|
`;
|
|
|
|
// AJOUTER CONTEXTE DES TITRES POUR LES TEXTES
|
|
if (type === 'texte' && Object.keys(existingResults).length > 0) {
|
|
const generatedTitles = Object.entries(existingResults)
|
|
.filter(([tag]) => tag.includes('Titre'))
|
|
.map(([tag, title]) => `• ${tag.replace(/\|/g, '')}: "${title}"`)
|
|
.slice(0, 5); // Limiter à 5 titres pour éviter surcharge
|
|
|
|
if (generatedTitles.length > 0) {
|
|
prompt += `
|
|
CONTEXTE - TITRES GÉNÉRÉS:
|
|
${generatedTitles.join('\n')}
|
|
|
|
`;
|
|
}
|
|
}
|
|
|
|
elements.forEach((elementInfo, index) => {
|
|
const cleanTag = elementInfo.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '');
|
|
|
|
prompt += `${index + 1}. [${cleanTag}] `;
|
|
|
|
// INSTRUCTIONS SPÉCIFIQUES ET COURTES PAR TYPE
|
|
if (type === 'titre') {
|
|
if (elementInfo.element.type === 'titre_h1') {
|
|
prompt += `CRÉER UN TITRE H1 PRINCIPAL (8-12 mots) sur "${csvData.t0}" - NE PAS écrire "Titre_H1_1"\n`;
|
|
} else if (elementInfo.element.type === 'titre_h2') {
|
|
prompt += `CRÉER UN TITRE H2 SECTION (6-10 mots) sur "${csvData.mc0}" - NE PAS écrire "Titre_H2_X"\n`;
|
|
} else if (elementInfo.element.type === 'titre_h3') {
|
|
prompt += `CRÉER UN TITRE H3 SOUS-SECTION (4-8 mots) - NE PAS écrire "Titre_H3_X"\n`;
|
|
} else {
|
|
prompt += `CRÉER UN TITRE ACCROCHEUR (4-10 mots) sur "${csvData.mc0}" - NE PAS écrire "Titre_"\n`;
|
|
}
|
|
} else if (type === 'texte') {
|
|
const wordCount = elementInfo.element.name && elementInfo.element.name.includes('H2') ? '150' : '100';
|
|
prompt += `Paragraphe ${wordCount} mots, style ${personality.style}\n`;
|
|
|
|
// ASSOCIER LE TITRE CORRESPONDANT AUTOMATIQUEMENT
|
|
const associatedTitle = findAssociatedTitle(elementInfo, existingResults);
|
|
if (associatedTitle) {
|
|
prompt += ` Développe le titre: "${associatedTitle}"\n`;
|
|
}
|
|
|
|
if (elementInfo.element.resolvedContent) {
|
|
prompt += ` Thème: "${elementInfo.element.resolvedContent}"\n`;
|
|
}
|
|
} else if (type === 'intro') {
|
|
prompt += `Introduction 80-100 mots, ton accueillant\n`;
|
|
} else {
|
|
prompt += `Contenu pertinent pour ${csvData.mc0}\n`;
|
|
}
|
|
});
|
|
|
|
prompt += `\nSTYLE ${personality.nom.toUpperCase()} - ${personality.style}:
|
|
- Vocabulaire: ${personality.vocabulairePref}
|
|
- Connecteurs: ${personality.connecteursPref}
|
|
- Phrases: ${personality.longueurPhrases}
|
|
- Niveau technique: ${personality.niveauTechnique}
|
|
|
|
CONSIGNES STRICTES:
|
|
- RESPECTE le style ${personality.style} de ${personality.nom} mais RESTE PROFESSIONNEL
|
|
- INTERDICTION ABSOLUE: "du coup", "bon", "alors", "franchement", "nickel", "tip-top", "costaud" en excès
|
|
- VARIE les connecteurs: ${personality.connecteursPref}
|
|
- POUR LES TITRES: SEULEMENT le titre réel, JAMAIS de référence "Titre_H1_1" ou "Titre_H2_7"
|
|
- EXEMPLE TITRE: "Plaques personnalisées résistantes aux intempéries" PAS "Titre_H2_1"
|
|
- RÉPONDS DIRECTEMENT par le contenu demandé, SANS introduction ni nom de tag
|
|
- PAS de message d'excuse du type "je n'ai pas l'information"
|
|
- CONTENU cohérent et professionnel, évite la sur-familiarité
|
|
|
|
FORMAT DE RÉPONSE ${type === 'titre' ? '(TITRES UNIQUEMENT)' : ''}:
|
|
[${elements[0].tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '')}]
|
|
${type === 'titre' ? 'Titre réel et attractif (PAS "Titre_H1_1")' : 'Contenu rédigé selon le style ' + personality.nom}
|
|
|
|
[${elements[1] ? elements[1].tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '') : 'element2'}]
|
|
${type === 'titre' ? 'Titre réel et attractif (PAS "Titre_H2_1")' : 'Contenu rédigé selon le style ' + personality.nom}
|
|
|
|
etc...`;
|
|
|
|
return prompt;
|
|
}
|
|
|
|
/**
|
|
* Parser réponse batch générique avec nettoyage des tags XML
|
|
*/
|
|
function parseBatchResponse(response, elements) {
|
|
const results = {};
|
|
|
|
// Parser avec regex [TAG] contenu
|
|
const regex = /\[([^\]]+)\]\s*\n([^[]*?)(?=\n\[|$)/gs;
|
|
let match;
|
|
const parsedItems = {};
|
|
|
|
while ((match = regex.exec(response)) !== null) {
|
|
const tag = match[1].trim();
|
|
let content = match[2].trim();
|
|
|
|
// NOUVEAU: Nettoyer les tags XML qui peuvent apparaître dans le contenu
|
|
content = cleanXMLTagsFromContent(content);
|
|
|
|
parsedItems[tag] = content;
|
|
}
|
|
|
|
// Mapper aux vrais tags avec |
|
|
elements.forEach(element => {
|
|
const cleanTag = element.tag.replace(/\|/g, '').replace(/[{}]/g, '').replace(/<\/?strong>/g, '');
|
|
|
|
if (parsedItems[cleanTag] && parsedItems[cleanTag].length > 10) {
|
|
results[element.tag] = parsedItems[cleanTag];
|
|
logSh(`✅ Parsé [${cleanTag}]: "${parsedItems[cleanTag].substring(0, 100)}..."`, 'DEBUG');
|
|
} else {
|
|
// Fallback si parsing échoue ou contenu trop court
|
|
results[element.tag] = `Contenu professionnel pour ${element.element.name}`;
|
|
logSh(`⚠️ Fallback [${cleanTag}]: parsing échoué ou contenu invalide`, 'WARNING');
|
|
}
|
|
});
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* NOUVELLE FONCTION: Nettoyer les tags XML du contenu généré
|
|
*/
|
|
function cleanXMLTagsFromContent(content) {
|
|
if (!content) return content;
|
|
|
|
// Supprimer les tags XML avec **
|
|
content = content.replace(/\*\*[^*]+\*\*/g, '');
|
|
|
|
// Supprimer les préfixes de titres indésirables
|
|
content = content.replace(/^(Bon,?\s*)?(alors,?\s*)?(voici\s+le\s+topo\s+pour\s+)?Titre_[HU]\d+_\d+[.,\s]*/gi, '');
|
|
content = content.replace(/^(Bon,?\s*)?(alors,?\s*)?pour\s+Titre_[HU]\d+_\d+[.,\s]*/gi, '');
|
|
content = content.replace(/^(Bon,?\s*)?(donc,?\s*)?Titre_[HU]\d+_\d+[.,\s]*/gi, '');
|
|
|
|
// Supprimer les messages d'excuse
|
|
content = content.replace(/Oh là là,?\s*je\s*(suis\s*)?(\w+\s*)?désolée?\s*,?\s*mais\s*je\s*n'ai\s*pas\s*l'information.*?(?=\.|$)/gi, '');
|
|
content = content.replace(/Bon,?\s*passons\s*au\s*suivant.*?(?=\.|$)/gi, '');
|
|
content = content.replace(/je\s*ne\s*sais\s*pas\s*quoi\s*vous\s*dire.*?(?=\.|$)/gi, '');
|
|
content = content.replace(/encore\s*un\s*point\s*où\s*je\s*n'ai\s*pas\s*l'information.*?(?=\.|$)/gi, '');
|
|
|
|
// Réduire les répétitions excessives d'expressions familières
|
|
content = content.replace(/(du coup[,\s]+){3,}/gi, 'du coup ');
|
|
content = content.replace(/(bon[,\s]+){3,}/gi, 'bon ');
|
|
content = content.replace(/(franchement[,\s]+){3,}/gi, 'franchement ');
|
|
content = content.replace(/(alors[,\s]+){3,}/gi, 'alors ');
|
|
content = content.replace(/(nickel[,\s]+){2,}/gi, 'nickel ');
|
|
content = content.replace(/(tip-top[,\s]+){2,}/gi, 'tip-top ');
|
|
content = content.replace(/(costaud[,\s]+){2,}/gi, 'costaud ');
|
|
|
|
// Nettoyer espaces multiples et retours ligne
|
|
content = content.replace(/\s{2,}/g, ' ');
|
|
content = content.replace(/\n{2,}/g, '\n');
|
|
content = content.trim();
|
|
|
|
return content;
|
|
}
|
|
|
|
// ============= PARSING FUNCTIONS =============
|
|
|
|
/**
|
|
* Parser réponse extraction termes
|
|
*/
|
|
function parseAllTechnicalTermsResponse(response, baseContents, contentEntries) {
|
|
const results = [];
|
|
const regex = /\[(\d+)\]\s*([^[]*?)(?=\[\d+\]|$)/gs;
|
|
let match;
|
|
const parsedItems = {};
|
|
|
|
// Parser la réponse
|
|
while ((match = regex.exec(response)) !== null) {
|
|
const index = parseInt(match[1]) - 1; // Convertir en 0-indexé
|
|
const termsText = match[2].trim();
|
|
parsedItems[index] = termsText;
|
|
}
|
|
|
|
// Mapper aux éléments
|
|
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: tag,
|
|
content: baseContents[tag],
|
|
technicalTerms: technicalTerms,
|
|
needsEnhancement: hasTerms && technicalTerms.length > 0
|
|
});
|
|
|
|
logSh(`🔍 [${tag}]: ${hasTerms ? technicalTerms.join(', ') : 'pas de termes techniques'}`, 'DEBUG');
|
|
});
|
|
|
|
const enhancementCount = results.filter(r => r.needsEnhancement).length;
|
|
logSh(`📊 Analyse terminée: ${enhancementCount}/${contentEntries.length} éléments ont besoin d'enhancement`, 'INFO');
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Parser réponse enhancement technique
|
|
*/
|
|
function parseTechnicalEnhancementBatchResponse(response, elementsNeedingEnhancement) {
|
|
const results = {};
|
|
const regex = /\[(\d+)\]\s*([^[]*?)(?=\[\d+\]|$)/gs;
|
|
let match;
|
|
let index = 0;
|
|
|
|
while ((match = regex.exec(response)) && index < elementsNeedingEnhancement.length) {
|
|
let content = match[2].trim();
|
|
const element = elementsNeedingEnhancement[index];
|
|
|
|
// NOUVEAU: Appliquer le nettoyage XML
|
|
content = cleanXMLTagsFromContent(content);
|
|
|
|
if (content && content.length > 10) {
|
|
results[element.tag] = content;
|
|
logSh(`✅ Enhanced [${element.tag}]: "${content.substring(0, 100)}..."`, 'DEBUG');
|
|
} else {
|
|
// Fallback si contenu invalide après nettoyage
|
|
results[element.tag] = element.content;
|
|
logSh(`⚠️ Fallback [${element.tag}]: contenu invalide après nettoyage`, 'WARNING');
|
|
}
|
|
|
|
index++;
|
|
}
|
|
|
|
// Vérifier si on a bien tout parsé
|
|
if (Object.keys(results).length < elementsNeedingEnhancement.length) {
|
|
logSh(`⚠️ Parsing partiel: ${Object.keys(results).length}/${elementsNeedingEnhancement.length}`, 'WARNING');
|
|
|
|
// Compléter avec contenu original pour les manquants
|
|
elementsNeedingEnhancement.forEach(element => {
|
|
if (!results[element.tag]) {
|
|
results[element.tag] = element.content;
|
|
}
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Parser réponse batch transitions/style (format identique)
|
|
*/
|
|
function parseTechnicalBatchResponse(response, chunk) {
|
|
const results = {};
|
|
const regex = /\[(\d+)\]\s*([^[]*?)(?=\[\d+\]|$)/gs;
|
|
let match;
|
|
let index = 0;
|
|
|
|
while ((match = regex.exec(response)) && index < chunk.length) {
|
|
const content = match[2].trim();
|
|
results[chunk[index].tag] = content;
|
|
index++;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function parseTransitionsBatchResponse(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 content = match[2].trim();
|
|
|
|
// Appliquer le nettoyage XML
|
|
content = cleanXMLTagsFromContent(content);
|
|
|
|
if (content && content.length > 10) {
|
|
results[chunk[index].tag] = content;
|
|
} else {
|
|
// Fallback si contenu invalide
|
|
results[chunk[index].tag] = chunk[index].content; // Garder contenu original
|
|
}
|
|
index++;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function parseStyleBatchResponse(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 content = match[2].trim();
|
|
|
|
// Appliquer le nettoyage XML
|
|
content = cleanXMLTagsFromContent(content);
|
|
|
|
if (content && content.length > 10) {
|
|
results[chunk[index].tag] = content;
|
|
} else {
|
|
// Fallback si contenu invalide
|
|
results[chunk[index].tag] = chunk[index].content; // Garder contenu original
|
|
}
|
|
index++;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ============= ANALYSIS FUNCTIONS =============
|
|
|
|
/**
|
|
* Analyser besoin d'amélioration transitions
|
|
*/
|
|
function analyzeTransitionNeed(content) {
|
|
const sentences = content.split(/[.!?]+/).filter(s => s.trim().length > 10);
|
|
|
|
// Critères multiples d'analyse
|
|
const metrics = {
|
|
repetitiveConnectors: analyzeRepetitiveConnectors(content),
|
|
abruptTransitions: analyzeAbruptTransitions(sentences),
|
|
sentenceVariety: analyzeSentenceVariety(sentences),
|
|
formalityLevel: analyzeFormalityLevel(content),
|
|
overallLength: content.length
|
|
};
|
|
|
|
// Score de besoin (0-1)
|
|
let needScore = 0;
|
|
needScore += metrics.repetitiveConnectors * 0.3;
|
|
needScore += metrics.abruptTransitions * 0.4;
|
|
needScore += (1 - metrics.sentenceVariety) * 0.2;
|
|
needScore += metrics.formalityLevel * 0.1;
|
|
|
|
// Seuil ajustable selon longueur
|
|
const threshold = metrics.overallLength > 300 ? 0.4 : 0.6;
|
|
|
|
logSh(`🔍 Analyse transitions: score=${needScore.toFixed(2)}, seuil=${threshold}`, 'DEBUG');
|
|
|
|
return needScore > threshold;
|
|
}
|
|
|
|
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 previous = sentences[i-1].trim();
|
|
|
|
const hasConnector = hasTransitionWord(current);
|
|
const topicContinuity = calculateTopicContinuity(previous, current);
|
|
|
|
// Transition abrupte = pas de connecteur + faible continuité thématique
|
|
if (!hasConnector && topicContinuity < 0.3) {
|
|
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;
|
|
|
|
// Calculer variance des longueurs
|
|
const variance = lengths.reduce((acc, len) => acc + Math.pow(len - avgLength, 2), 0) / lengths.length;
|
|
const stdDev = Math.sqrt(variance);
|
|
|
|
// Score de variété (0-1) - plus la variance est élevée, plus c'est varié
|
|
return Math.min(1, stdDev / avgLength);
|
|
}
|
|
|
|
function analyzeFormalityLevel(content) {
|
|
const formalIndicators = [
|
|
'il convient de', 'par conséquent', 'néanmoins', 'toutefois',
|
|
'de surcroît', 'en définitive', 'il s\'avère que', 'force est de constater'
|
|
];
|
|
|
|
let formalCount = 0;
|
|
formalIndicators.forEach(indicator => {
|
|
if (content.toLowerCase().includes(indicator)) formalCount++;
|
|
});
|
|
|
|
const sentences = content.split(/[.!?]+/).length;
|
|
return sentences > 0 ? formalCount / sentences : 0;
|
|
}
|
|
|
|
function calculateTopicContinuity(sentence1, sentence2) {
|
|
const stopWords = ['les', 'des', 'une', 'sont', 'avec', 'pour', 'dans', 'cette', 'vous', 'peut', 'tout'];
|
|
|
|
const words1 = extractSignificantWords(sentence1, stopWords);
|
|
const words2 = extractSignificantWords(sentence2, stopWords);
|
|
|
|
if (words1.length === 0 || words2.length === 0) return 0;
|
|
|
|
const commonWords = words1.filter(word => words2.includes(word));
|
|
const semanticSimilarity = commonWords.length / Math.min(words1.length, words2.length);
|
|
|
|
const technicalWords = ['plaque', 'dibond', 'aluminium', 'impression', 'signalétique'];
|
|
const commonTechnical = commonWords.filter(word => technicalWords.includes(word));
|
|
const technicalBonus = commonTechnical.length * 0.2;
|
|
|
|
return Math.min(1, semanticSimilarity + technicalBonus);
|
|
}
|
|
|
|
function extractSignificantWords(sentence, stopWords) {
|
|
return sentence.toLowerCase()
|
|
.match(/\b[a-zàâäéèêëïîôùûüÿç]{4,}\b/g) // Mots 4+ lettres avec accents
|
|
?.filter(word => !stopWords.includes(word)) || [];
|
|
}
|
|
|
|
function hasTransitionWord(sentence) {
|
|
const connectors = ['par ailleurs', 'en effet', 'de plus', 'cependant', 'ainsi', 'donc', 'ensuite', 'puis', 'également', 'aussi', 'toutefois', 'néanmoins', 'alors', 'enfin'];
|
|
return connectors.some(connector => sentence.toLowerCase().includes(connector));
|
|
}
|
|
|
|
/**
|
|
* Instructions de style dynamiques
|
|
*/
|
|
function getPersonalityStyleInstructions(personality) {
|
|
// CORRECTION: Utilisation des VRAIS champs Google Sheets au lieu du hardcodé
|
|
if (!personality) return "Style professionnel standard";
|
|
|
|
const instructions = `STYLE ${personality.nom.toUpperCase()} (${personality.style}):
|
|
- Description: ${personality.description}
|
|
- Vocabulaire préféré: ${personality.vocabulairePref || 'professionnel, qualité'}
|
|
- Connecteurs préférés: ${personality.connecteursPref || 'par ailleurs, en effet'}
|
|
- Mots-clés secteurs: ${personality.motsClesSecteurs || 'technique, qualité'}
|
|
- Longueur phrases: ${personality.longueurPhrases || 'Moyennes (15-25 mots)'}
|
|
- Niveau technique: ${personality.niveauTechnique || 'Accessible'}
|
|
- Style CTA: ${personality.ctaStyle || 'Professionnel'}
|
|
- Défauts simulés: ${personality.defautsSimules || 'Aucun'}
|
|
- Erreurs typiques à éviter: ${personality.erreursTypiques || 'Répétitions, généralités'}`;
|
|
|
|
return instructions;
|
|
}
|
|
|
|
/**
|
|
* Créer prompt pour élément (fonction de base nécessaire)
|
|
*/
|
|
function createPromptForElement(element, csvData) {
|
|
const personality = csvData.personality;
|
|
const styleContext = `Rédige dans le style ${personality.style} de ${personality.nom} (${personality.description}).`;
|
|
|
|
switch (element.type) {
|
|
case 'titre_h1':
|
|
return `${styleContext}
|
|
MISSION: Crée un titre H1 accrocheur pour: ${csvData.mc0}
|
|
Référence: ${csvData.t0}
|
|
CONSIGNES: 10 mots maximum, direct et impactant, optimisé SEO.
|
|
RÉPONDS UNIQUEMENT PAR LE TITRE, sans introduction.`;
|
|
|
|
case 'titre_h2':
|
|
return `${styleContext}
|
|
MISSION: Crée un titre H2 optimisé SEO pour: ${csvData.mc0}
|
|
CONSIGNES: Intègre naturellement le mot-clé, 8 mots maximum.
|
|
RÉPONDS UNIQUEMENT PAR LE TITRE, sans introduction.`;
|
|
|
|
case 'intro':
|
|
if (element.instructions) {
|
|
return `${styleContext}
|
|
MISSION: ${element.instructions}
|
|
Données contextuelles:
|
|
- MC0: ${csvData.mc0}
|
|
- T-1: ${csvData.tMinus1}
|
|
- L-1: ${csvData.lMinus1}
|
|
RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`;
|
|
}
|
|
return `${styleContext}
|
|
MISSION: Rédige une introduction de 100 mots pour ${csvData.mc0}.
|
|
RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`;
|
|
|
|
case 'texte':
|
|
if (element.instructions) {
|
|
return `${styleContext}
|
|
MISSION: ${element.instructions}
|
|
RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`;
|
|
}
|
|
return `${styleContext}
|
|
MISSION: Rédige un paragraphe de 150 mots sur ${csvData.mc0}.
|
|
RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`;
|
|
|
|
case 'faq_question':
|
|
if (element.instructions) {
|
|
return `${styleContext}
|
|
MISSION: ${element.instructions}
|
|
CONTEXTE: ${csvData.mc0} - ${csvData.t0}
|
|
STYLE: Question ${csvData.personality?.style} de ${csvData.personality?.nom}
|
|
CONSIGNES:
|
|
- Vraie question que se poserait un client intéressé par ${csvData.mc0}
|
|
- Commence par "Comment", "Quel", "Pourquoi", "Où", "Quand" ou "Est-ce que"
|
|
- Maximum 15 mots, pratique et concrète
|
|
- Vocabulaire: ${csvData.personality?.vocabulairePref || 'accessible'}
|
|
RÉPONDS UNIQUEMENT PAR LA QUESTION, sans guillemets ni introduction.`;
|
|
}
|
|
return `${styleContext}
|
|
MISSION: Génère une vraie question FAQ client sur ${csvData.mc0}.
|
|
CONSIGNES:
|
|
- Question pratique et concrète qu'un client se poserait
|
|
- Commence par "Comment", "Quel", "Pourquoi", "Combien", "Où" ou "Est-ce que"
|
|
- Maximum 15 mots, style ${csvData.personality?.style}
|
|
- Vocabulaire: ${csvData.personality?.vocabulairePref || 'accessible'}
|
|
RÉPONDS UNIQUEMENT PAR LA QUESTION, sans guillemets ni introduction.`;
|
|
|
|
case 'faq_reponse':
|
|
if (element.instructions) {
|
|
return `${styleContext}
|
|
MISSION: ${element.instructions}
|
|
CONTEXTE: ${csvData.mc0} - ${csvData.t0}
|
|
STYLE: Réponse ${csvData.personality?.style} de ${csvData.personality?.nom}
|
|
CONSIGNES:
|
|
- Réponse utile et rassurante
|
|
- 50-80 mots, ton ${csvData.personality?.style}
|
|
- Vocabulaire: ${csvData.personality?.vocabulairePref}
|
|
- Connecteurs: ${csvData.personality?.connecteursPref}
|
|
RÉPONDS UNIQUEMENT PAR LA RÉPONSE, sans introduction.`;
|
|
}
|
|
return `${styleContext}
|
|
MISSION: Réponds à une question client sur ${csvData.mc0}.
|
|
CONSIGNES:
|
|
- Réponse utile, claire et rassurante
|
|
- 50-80 mots, ton ${csvData.personality?.style} de ${csvData.personality?.nom}
|
|
- Vocabulaire: ${csvData.personality?.vocabulairePref || 'professionnel'}
|
|
- Connecteurs: ${csvData.personality?.connecteursPref || 'par ailleurs'}
|
|
RÉPONDS UNIQUEMENT PAR LA RÉPONSE, sans introduction.`;
|
|
|
|
default:
|
|
return `${styleContext}
|
|
MISSION: Génère du contenu pertinent pour ${csvData.mc0}.
|
|
RÉPONDS UNIQUEMENT PAR LE CONTENU, sans présentation.`;
|
|
}
|
|
}
|
|
|
|
// ============= EXPORTS =============
|
|
|
|
module.exports = {
|
|
generateWithBatchEnhancement,
|
|
generateAllContentBase,
|
|
enhanceAllTechnicalTerms,
|
|
enhanceAllTransitions,
|
|
enhanceAllPersonalityStyle,
|
|
collectAllElements,
|
|
groupElementsByType,
|
|
chunkArray,
|
|
createBatchBasePrompt,
|
|
parseBatchResponse,
|
|
cleanXMLTagsFromContent,
|
|
analyzeTransitionNeed,
|
|
getPersonalityStyleInstructions,
|
|
createPromptForElement,
|
|
sleep,
|
|
separateFAQPairsAndOthers,
|
|
generateFAQPairsRestored,
|
|
createBatchFAQPairsPrompt,
|
|
parseFAQPairsResponse,
|
|
cleanFAQInstructions
|
|
};
|
|
|
|
/*
|
|
┌────────────────────────────────────────────────────────────────────┐
|
|
│ File: lib/ContentGeneration.js │
|
|
└────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
// ========================================
|
|
// FICHIER: lib/content-generation.js - CONVERTI POUR NODE.JS
|
|
// Description: Génération de contenu avec batch enhancement
|
|
// ========================================
|
|
|
|
// 🔄 NODE.JS IMPORTS
|
|
const { logSh } = require('./ErrorReporting');
|
|
const { generateWithBatchEnhancement } = require('./SelectiveEnhancement');
|
|
|
|
// ============= GÉNÉRATION PRINCIPALE - ADAPTÉE =============
|
|
|
|
async function generateWithContext(hierarchy, csvData) {
|
|
logSh('=== GÉNÉRATION AVEC BATCH ENHANCEMENT ===', 'INFO');
|
|
|
|
// *** UTILISE LE SELECTIVE ENHANCEMENT ***
|
|
return await generateWithBatchEnhancement(hierarchy, csvData);
|
|
}
|
|
|
|
// ============= EXPORTS =============
|
|
|
|
module.exports = {
|
|
generateWithContext
|
|
};
|
|
|
|
/*
|
|
┌────────────────────────────────────────────────────────────────────┐
|
|
│ 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 <strong> du template XML
|
|
* @param {string} xmlString - Le contenu XML à nettoyer
|
|
* @returns {string} - XML nettoyé
|
|
*/
|
|
function cleanStrongTags(xmlString) {
|
|
logSh('Nettoyage balises <strong> du template...', 'DEBUG');
|
|
|
|
// Enlever toutes les balises <strong> et </strong>
|
|
let cleaned = xmlString.replace(/<\/?strong>/g, '');
|
|
|
|
// Log du nettoyage
|
|
const strongCount = (xmlString.match(/<\/?strong>/g) || []).length;
|
|
if (strongCount > 0) {
|
|
logSh(`${strongCount} balises <strong> 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'
|
|
};
|
|
|
|
// ============= 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<object>} - Résultat du workflow
|
|
*/
|
|
async function triggerAutonomousWorkflow(rowNumber) {
|
|
try {
|
|
logSh('🚀 TRIGGER AUTONOME DÉCLENCHÉ (Digital Ocean)', 'INFO');
|
|
|
|
// Anti-bouncing simulé
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
return await runAutonomousWorkflowFromTrigger(rowNumber);
|
|
|
|
} catch (error) {
|
|
logSh(`❌ Erreur trigger autonome DO: ${error.toString()}`, 'ERROR');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ORCHESTRATEUR: Prépare les données et délègue à Main.js
|
|
*/
|
|
async function runAutonomousWorkflowFromTrigger(rowNumber) {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
logSh(`🎬 ORCHESTRATION AUTONOME - LIGNE ${rowNumber}`, 'INFO');
|
|
|
|
// 1. LIRE DONNÉES CSV + XML FILENAME
|
|
const csvData = await readCSVDataWithXMLFileName(rowNumber);
|
|
logSh(`✅ CSV: ${csvData.mc0}, XML: ${csvData.xmlFileName}`, 'INFO');
|
|
|
|
// 2. RÉCUPÉRER XML DEPUIS DIGITAL OCEAN
|
|
const xmlTemplate = await fetchXMLFromDigitalOceanSimple(csvData.xmlFileName);
|
|
logSh(`✅ XML récupéré: ${xmlTemplate.length} caractères`, 'INFO');
|
|
|
|
// 3. 🎯 DÉLÉGUER LE WORKFLOW À MAIN.JS
|
|
const workflowData = {
|
|
rowNumber: rowNumber,
|
|
xmlTemplate: Buffer.from(xmlTemplate).toString('base64'), // Encoder comme Make.com
|
|
csvData: csvData,
|
|
source: 'digital_ocean_autonomous'
|
|
};
|
|
|
|
const result = await handleFullWorkflow(workflowData);
|
|
|
|
const duration = Date.now() - startTime;
|
|
logSh(`🏆 ORCHESTRATION TERMINÉE en ${Math.round(duration/1000)}s`, 'INFO');
|
|
|
|
// 4. MARQUER LIGNE COMME TRAITÉE
|
|
await markRowAsProcessed(rowNumber, result);
|
|
|
|
return result;
|
|
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
logSh(`❌ ERREUR ORCHESTRATION: ${error.toString()}`, 'ERROR');
|
|
await markRowAsError(rowNumber, error.toString());
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============= INTERFACE DIGITAL OCEAN =============
|
|
|
|
async function fetchXMLFromDigitalOceanSimple(fileName) {
|
|
const filePath = `wp-content/XML/${fileName}`;
|
|
const fileUrl = `${DO_CONFIG.endpoint}/${filePath}`;
|
|
|
|
try {
|
|
const response = await axios.get(fileUrl); // Sans auth
|
|
return response.data;
|
|
} catch (error) {
|
|
throw new Error(`Fichier non accessible: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupérer XML depuis Digital Ocean Spaces avec authentification
|
|
*/
|
|
async function fetchXMLFromDigitalOcean(fileName) {
|
|
if (!fileName) {
|
|
throw new Error('Nom de fichier XML requis');
|
|
}
|
|
|
|
const filePath = `wp-content/XML/${fileName}`;
|
|
logSh(`🌊 Récupération XML: ${fileName} , ${filePath}`, 'DEBUG');
|
|
|
|
const fileUrl = `${DO_CONFIG.endpoint}/${filePath}`;
|
|
logSh(`🔗 URL complète: ${fileUrl}`, 'DEBUG');
|
|
|
|
const signature = generateAWSSignature(filePath);
|
|
|
|
try {
|
|
const response = await axios.get(fileUrl, {
|
|
headers: signature.headers
|
|
});
|
|
|
|
logSh(`📡 Response code: ${response.status}`, 'DEBUG');
|
|
logSh(`📄 Response: ${response.data.toString()}`, 'DEBUG');
|
|
|
|
if (response.status === 200) {
|
|
return response.data;
|
|
} else {
|
|
throw new Error(`HTTP ${response.status}: ${response.data}`);
|
|
}
|
|
|
|
} catch (error) {
|
|
logSh(`❌ Erreur DO complète: ${error.toString()}`, 'ERROR');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lire données CSV avec nom fichier XML (colonne J)
|
|
*/
|
|
async function readCSVDataWithXMLFileName(rowNumber) {
|
|
try {
|
|
// Configuration Google Sheets - avec fallback sur fichier JSON
|
|
let serviceAccountAuth;
|
|
|
|
if (SHEET_CONFIG.serviceAccountEmail && SHEET_CONFIG.privateKey) {
|
|
// Utiliser variables d'environnement
|
|
serviceAccountAuth = new JWT({
|
|
email: SHEET_CONFIG.serviceAccountEmail,
|
|
key: SHEET_CONFIG.privateKey,
|
|
scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
});
|
|
} else {
|
|
// Utiliser fichier JSON
|
|
serviceAccountAuth = new JWT({
|
|
keyFile: SHEET_CONFIG.keyFile,
|
|
scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
});
|
|
}
|
|
|
|
const doc = new GoogleSpreadsheet(SHEET_CONFIG.sheetId, serviceAccountAuth);
|
|
await doc.loadInfo();
|
|
|
|
const sheet = doc.sheetsByTitle['instructions'];
|
|
if (!sheet) {
|
|
throw new Error('Sheet "instructions" non trouvée');
|
|
}
|
|
|
|
await sheet.loadCells(`A${rowNumber}:I${rowNumber}`);
|
|
|
|
const slug = sheet.getCell(rowNumber - 1, 0).value;
|
|
const t0 = sheet.getCell(rowNumber - 1, 1).value;
|
|
const mc0 = sheet.getCell(rowNumber - 1, 2).value;
|
|
const tMinus1 = sheet.getCell(rowNumber - 1, 3).value;
|
|
const lMinus1 = sheet.getCell(rowNumber - 1, 4).value;
|
|
const mcPlus1 = sheet.getCell(rowNumber - 1, 5).value;
|
|
const tPlus1 = sheet.getCell(rowNumber - 1, 6).value;
|
|
const lPlus1 = sheet.getCell(rowNumber - 1, 7).value;
|
|
const xmlFileName = sheet.getCell(rowNumber - 1, 8).value;
|
|
|
|
if (!xmlFileName || xmlFileName.toString().trim() === '') {
|
|
throw new Error(`Nom fichier XML manquant colonne I, ligne ${rowNumber}`);
|
|
}
|
|
|
|
let cleanFileName = xmlFileName.toString().trim();
|
|
if (!cleanFileName.endsWith('.xml')) {
|
|
cleanFileName += '.xml';
|
|
}
|
|
|
|
// Récupérer personnalité (délègue au système existant BrainConfig.js)
|
|
const personalities = await getPersonalities(); // Pas de paramètre, lit depuis JSON
|
|
const selectedPersonality = await selectPersonalityWithAI(mc0, t0, personalities);
|
|
|
|
return {
|
|
rowNumber: rowNumber,
|
|
slug: slug,
|
|
t0: t0,
|
|
mc0: mc0,
|
|
tMinus1: tMinus1,
|
|
lMinus1: lMinus1,
|
|
mcPlus1: mcPlus1,
|
|
tPlus1: tPlus1,
|
|
lPlus1: lPlus1,
|
|
xmlFileName: cleanFileName,
|
|
personality: selectedPersonality
|
|
};
|
|
|
|
} catch (error) {
|
|
logSh(`❌ Erreur lecture CSV: ${error.toString()}`, 'ERROR');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============= STATUTS ET VALIDATION =============
|
|
|
|
/**
|
|
* Vérifier si le workflow doit être déclenché
|
|
* En Node.js, cette logique sera adaptée selon votre stratégie (webhook, polling, etc.)
|
|
*/
|
|
function shouldTriggerWorkflow(rowNumber, xmlFileName) {
|
|
if (!rowNumber || rowNumber <= 1) {
|
|
return false;
|
|
}
|
|
|
|
if (!xmlFileName || xmlFileName.toString().trim() === '') {
|
|
logSh('⚠️ Pas de fichier XML (colonne J), workflow ignoré', 'WARNING');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function markRowAsProcessed(rowNumber, result) {
|
|
try {
|
|
// Configuration Google Sheets - avec fallback sur fichier JSON
|
|
let serviceAccountAuth;
|
|
|
|
if (SHEET_CONFIG.serviceAccountEmail && SHEET_CONFIG.privateKey) {
|
|
serviceAccountAuth = new JWT({
|
|
email: SHEET_CONFIG.serviceAccountEmail,
|
|
key: SHEET_CONFIG.privateKey,
|
|
scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
});
|
|
} else {
|
|
serviceAccountAuth = new JWT({
|
|
keyFile: SHEET_CONFIG.keyFile,
|
|
scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
});
|
|
}
|
|
|
|
const doc = new GoogleSpreadsheet(SHEET_CONFIG.sheetId, serviceAccountAuth);
|
|
await doc.loadInfo();
|
|
|
|
const sheet = doc.sheetsByTitle['instructions'];
|
|
|
|
// Vérifier et ajouter headers si nécessaire
|
|
await sheet.loadCells('K1:N1');
|
|
if (!sheet.getCell(0, 10).value) {
|
|
sheet.getCell(0, 10).value = 'Status';
|
|
sheet.getCell(0, 11).value = 'Processed_At';
|
|
sheet.getCell(0, 12).value = 'Article_ID';
|
|
sheet.getCell(0, 13).value = 'Source';
|
|
await sheet.saveUpdatedCells();
|
|
}
|
|
|
|
// Marquer la ligne
|
|
await sheet.loadCells(`K${rowNumber}:N${rowNumber}`);
|
|
sheet.getCell(rowNumber - 1, 10).value = '✅ DO_SUCCESS';
|
|
sheet.getCell(rowNumber - 1, 11).value = new Date().toISOString();
|
|
sheet.getCell(rowNumber - 1, 12).value = result.articleStorage?.articleId || '';
|
|
sheet.getCell(rowNumber - 1, 13).value = 'Digital Ocean';
|
|
|
|
await sheet.saveUpdatedCells();
|
|
|
|
logSh(`✅ Ligne ${rowNumber} marquée comme traitée`, 'INFO');
|
|
|
|
} catch (error) {
|
|
logSh(`⚠️ Erreur marquage statut: ${error.toString()}`, 'WARNING');
|
|
}
|
|
}
|
|
|
|
async function markRowAsError(rowNumber, errorMessage) {
|
|
try {
|
|
// Configuration Google Sheets - avec fallback sur fichier JSON
|
|
let serviceAccountAuth;
|
|
|
|
if (SHEET_CONFIG.serviceAccountEmail && SHEET_CONFIG.privateKey) {
|
|
serviceAccountAuth = new JWT({
|
|
email: SHEET_CONFIG.serviceAccountEmail,
|
|
key: SHEET_CONFIG.privateKey,
|
|
scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
});
|
|
} else {
|
|
serviceAccountAuth = new JWT({
|
|
keyFile: SHEET_CONFIG.keyFile,
|
|
scopes: ['https://www.googleapis.com/auth/spreadsheets']
|
|
});
|
|
}
|
|
|
|
const doc = new GoogleSpreadsheet(SHEET_CONFIG.sheetId, serviceAccountAuth);
|
|
await doc.loadInfo();
|
|
|
|
const sheet = doc.sheetsByTitle['instructions'];
|
|
|
|
await sheet.loadCells(`K${rowNumber}:N${rowNumber}`);
|
|
sheet.getCell(rowNumber - 1, 10).value = '❌ DO_ERROR';
|
|
sheet.getCell(rowNumber - 1, 11).value = new Date().toISOString();
|
|
sheet.getCell(rowNumber - 1, 12).value = errorMessage.substring(0, 100);
|
|
sheet.getCell(rowNumber - 1, 13).value = 'DO Error';
|
|
|
|
await sheet.saveUpdatedCells();
|
|
|
|
} catch (error) {
|
|
logSh(`⚠️ Erreur marquage erreur: ${error.toString()}`, 'WARNING');
|
|
}
|
|
}
|
|
|
|
// ============= SIGNATURE AWS V4 =============
|
|
|
|
function generateAWSSignature(filePath) {
|
|
const now = new Date();
|
|
const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
const timeStamp = now.toISOString().replace(/[-:]/g, '').slice(0, -5) + 'Z';
|
|
|
|
const headers = {
|
|
'Host': DO_CONFIG.endpoint.replace('https://', ''),
|
|
'X-Amz-Date': timeStamp,
|
|
'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD'
|
|
};
|
|
|
|
const credentialScope = `${dateStamp}/${DO_CONFIG.region}/s3/aws4_request`;
|
|
|
|
const canonicalHeaders = Object.keys(headers)
|
|
.sort()
|
|
.map(key => `${key.toLowerCase()}:${headers[key]}`)
|
|
.join('\n');
|
|
|
|
const signedHeaders = Object.keys(headers)
|
|
.map(key => key.toLowerCase())
|
|
.sort()
|
|
.join(';');
|
|
|
|
const canonicalRequest = [
|
|
'GET',
|
|
`/${filePath}`,
|
|
'',
|
|
canonicalHeaders + '\n',
|
|
signedHeaders,
|
|
'UNSIGNED-PAYLOAD'
|
|
].join('\n');
|
|
|
|
const stringToSign = [
|
|
'AWS4-HMAC-SHA256',
|
|
timeStamp,
|
|
credentialScope,
|
|
crypto.createHash('sha256').update(canonicalRequest).digest('hex')
|
|
].join('\n');
|
|
|
|
// Calculs HMAC étape par étape
|
|
const kDate = crypto.createHmac('sha256', 'AWS4' + DO_CONFIG.secretAccessKey).update(dateStamp).digest();
|
|
const kRegion = crypto.createHmac('sha256', kDate).update(DO_CONFIG.region).digest();
|
|
const kService = crypto.createHmac('sha256', kRegion).update('s3').digest();
|
|
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
|
|
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
|
|
headers['Authorization'] = `AWS4-HMAC-SHA256 Credential=${DO_CONFIG.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
|
|
return { headers: headers };
|
|
}
|
|
|
|
// ============= SETUP ET TEST =============
|
|
|
|
/**
|
|
* Configuration du trigger autonome - Remplacé par webhook ou polling en Node.js
|
|
*/
|
|
function setupAutonomousTrigger() {
|
|
logSh('⚙️ Configuration trigger autonome Digital Ocean...', 'INFO');
|
|
|
|
// En Node.js, vous pourriez utiliser:
|
|
// - Express.js avec webhooks
|
|
// - Cron jobs avec node-cron
|
|
// - Polling de la Google Sheet
|
|
// - WebSocket connections
|
|
|
|
logSh('✅ Configuration prête pour webhooks/polling Node.js', 'INFO');
|
|
logSh('🎯 Mode: Webhook/API → Digital Ocean → Main.js', 'INFO');
|
|
}
|
|
|
|
async function testDigitalOceanConnection() {
|
|
logSh('🧪 Test connexion Digital Ocean...', 'INFO');
|
|
|
|
try {
|
|
const testFiles = ['template1.xml', 'plaque-rue.xml', 'test.xml'];
|
|
|
|
for (const fileName of testFiles) {
|
|
try {
|
|
const content = await fetchXMLFromDigitalOceanSimple(fileName);
|
|
logSh(`✅ Fichier '${fileName}' accessible (${content.length} chars)`, 'INFO');
|
|
return true;
|
|
} catch (error) {
|
|
logSh(`⚠️ '${fileName}' non accessible: ${error.toString()}`, 'DEBUG');
|
|
}
|
|
}
|
|
|
|
logSh('❌ Aucun fichier test accessible dans DO', 'ERROR');
|
|
return false;
|
|
|
|
} catch (error) {
|
|
logSh(`❌ Test DO échoué: ${error.toString()}`, 'ERROR');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ============= EXPORTS =============
|
|
|
|
module.exports = {
|
|
triggerAutonomousWorkflow,
|
|
runAutonomousWorkflowFromTrigger,
|
|
fetchXMLFromDigitalOcean,
|
|
fetchXMLFromDigitalOceanSimple,
|
|
readCSVDataWithXMLFileName,
|
|
markRowAsProcessed,
|
|
markRowAsError,
|
|
testDigitalOceanConnection,
|
|
setupAutonomousTrigger,
|
|
DO_CONFIG
|
|
};
|
|
|
|
/*
|
|
┌────────────────────────────────────────────────────────────────────┐
|
|
│ File: lib/Main.js │
|
|
└────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
// ========================================
|
|
// FICHIER: lib/main.js - CONVERTI POUR NODE.JS
|
|
// RESPONSABILITÉ: COEUR DU WORKFLOW DE GÉNÉRATION
|
|
// ========================================
|
|
|
|
// 🔧 CONFIGURATION ENVIRONNEMENT
|
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
|
|
|
|
// 🔄 IMPORTS NODE.JS (remplace les dépendances Apps Script)
|
|
const { getBrainConfig } = require('./BrainConfig');
|
|
const { extractElements, buildSmartHierarchy } = require('./ElementExtraction');
|
|
const { generateMissingKeywords } = require('./MissingKeywords');
|
|
const { generateWithContext } = require('./ContentGeneration');
|
|
const { injectGeneratedContent, cleanStrongTags } = require('./ContentAssembly');
|
|
const { validateWorkflowIntegrity, logSh } = require('./ErrorReporting');
|
|
const { saveGeneratedArticleOrganic } = require('./ArticleStorage');
|
|
const { tracer } = require('./trace.js');
|
|
const { fetchXMLFromDigitalOcean } = require('./DigitalOceanWorkflow');
|
|
const { spawn } = require('child_process');
|
|
const path = require('path');
|
|
|
|
// Variable pour éviter de relancer Edge plusieurs fois
|
|
let logViewerLaunched = false;
|
|
|
|
/**
|
|
* Lancer le log viewer dans Edge
|
|
*/
|
|
function launchLogViewer() {
|
|
if (logViewerLaunched) return;
|
|
|
|
try {
|
|
const logViewerPath = path.join(__dirname, '..', 'logs-viewer.html');
|
|
const fileUrl = `file:///${logViewerPath.replace(/\\/g, '/')}`;
|
|
|
|
// Lancer Edge avec l'URL du fichier
|
|
const edgeProcess = spawn('cmd', ['/c', 'start', 'msedge', fileUrl], {
|
|
detached: true,
|
|
stdio: 'ignore'
|
|
});
|
|
|
|
edgeProcess.unref();
|
|
logViewerLaunched = true;
|
|
|
|
logSh('🌐 Log viewer ouvert dans Edge', 'INFO');
|
|
} catch (error) {
|
|
logSh(`⚠️ Impossible d'ouvrir le log viewer: ${error.message}`, 'WARNING');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* COEUR DU WORKFLOW - Compatible Make.com ET Digital Ocean ET Node.js
|
|
* @param {object} data - Données du workflow
|
|
* @param {string} data.xmlTemplate - XML template (base64 encodé)
|
|
* @param {object} data.csvData - Données CSV ou rowNumber
|
|
* @param {string} data.source - 'make_com' | 'digital_ocean_autonomous' | 'node_server'
|
|
*/
|
|
async function handleFullWorkflow(data) {
|
|
// Lancer le log viewer au début du workflow
|
|
launchLogViewer();
|
|
|
|
return await tracer.run('Main.handleFullWorkflow()', async () => {
|
|
await tracer.annotate({ source: data.source || 'node_server', mc0: data.csvData?.mc0 || data.rowNumber });
|
|
|
|
// 1. PRÉPARER LES DONNÉES CSV
|
|
const csvData = await tracer.run('Main.prepareCSVData()', async () => {
|
|
const result = await prepareCSVData(data);
|
|
await tracer.event(`CSV préparé: ${result.mc0}`, { csvKeys: Object.keys(result) });
|
|
return result;
|
|
}, { rowNumber: data.rowNumber, source: data.source });
|
|
|
|
// 2. DÉCODER LE XML TEMPLATE
|
|
const xmlString = await tracer.run('Main.decodeXMLTemplate()', async () => {
|
|
const result = decodeXMLTemplate(data.xmlTemplate);
|
|
await tracer.event(`XML décodé: ${result.length} caractères`);
|
|
return result;
|
|
}, { templateLength: data.xmlTemplate?.length });
|
|
|
|
// 3. PREPROCESSING XML
|
|
const processedXML = await tracer.run('Main.preprocessXML()', async () => {
|
|
const result = preprocessXML(xmlString);
|
|
await tracer.event('XML préprocessé');
|
|
global.currentXmlTemplate = result;
|
|
return result;
|
|
}, { originalLength: xmlString?.length });
|
|
|
|
// 4. EXTRAIRE ÉLÉMENTS
|
|
const elements = await tracer.run('ElementExtraction.extractElements()', async () => {
|
|
const result = await extractElements(processedXML, csvData);
|
|
await tracer.event(`${result.length} éléments extraits`);
|
|
return result;
|
|
}, { xmlLength: processedXML?.length, mc0: csvData.mc0 });
|
|
|
|
// 5. GÉNÉRER MOTS-CLÉS MANQUANTS
|
|
const finalElements = await tracer.run('MissingKeywords.generateMissingKeywords()', async () => {
|
|
const updatedElements = await generateMissingKeywords(elements, csvData);
|
|
const result = Object.keys(updatedElements).length > 0 ? updatedElements : elements;
|
|
await tracer.event('Mots-clés manquants traités');
|
|
return result;
|
|
}, { elementsCount: elements.length, mc0: csvData.mc0 });
|
|
|
|
// 6. CONSTRUIRE HIÉRARCHIE INTELLIGENTE
|
|
const hierarchy = await tracer.run('ElementExtraction.buildSmartHierarchy()', async () => {
|
|
const result = await buildSmartHierarchy(finalElements);
|
|
await tracer.event(`Hiérarchie construite: ${Object.keys(result).length} sections`);
|
|
return result;
|
|
}, { finalElementsCount: finalElements.length });
|
|
|
|
// 7. 🎯 GÉNÉRATION AVEC SELECTIVE ENHANCEMENT (Phase 2)
|
|
const generatedContent = await tracer.run('ContentGeneration.generateWithContext()', async () => {
|
|
const result = await generateWithContext(hierarchy, csvData);
|
|
await tracer.event(`Contenu généré: ${Object.keys(result).length} éléments`);
|
|
return result;
|
|
}, { elementsCount: Object.keys(hierarchy).length, personality: csvData.personality?.nom });
|
|
|
|
// 8. ASSEMBLER XML FINAL
|
|
const finalXML = await tracer.run('ContentAssembly.injectGeneratedContent()', async () => {
|
|
const result = injectGeneratedContent(processedXML, generatedContent, finalElements);
|
|
await tracer.event('XML final assemblé');
|
|
return result;
|
|
}, { contentPieces: Object.keys(generatedContent).length, elementsCount: finalElements.length });
|
|
|
|
// 9. VALIDATION INTÉGRITÉ
|
|
const validationReport = await tracer.run('ErrorReporting.validateWorkflowIntegrity()', async () => {
|
|
const result = validateWorkflowIntegrity(finalElements, generatedContent, finalXML, csvData);
|
|
await tracer.event(`Validation: ${result.status}`);
|
|
return result;
|
|
}, { finalXMLLength: finalXML?.length, contentKeys: Object.keys(generatedContent).length });
|
|
|
|
// 10. SAUVEGARDE ARTICLE
|
|
const articleStorage = await tracer.run('Main.saveArticle()', async () => {
|
|
const result = await saveArticle(finalXML, generatedContent, finalElements, csvData, data.source);
|
|
if (result) {
|
|
await tracer.event(`Article sauvé: ID ${result.articleId}`);
|
|
}
|
|
return result;
|
|
}, { source: data.source, mc0: csvData.mc0, elementsCount: finalElements.length });
|
|
|
|
// 11. RÉPONSE FINALE
|
|
const response = await tracer.run('Main.buildWorkflowResponse()', async () => {
|
|
const result = await buildWorkflowResponse(finalXML, generatedContent, finalElements, csvData, validationReport, articleStorage, data.source);
|
|
await tracer.event(`Response keys: ${Object.keys(result).join(', ')}`);
|
|
return result;
|
|
}, { validationStatus: validationReport?.status, articleId: articleStorage?.articleId });
|
|
|
|
return response;
|
|
}, { source: data.source || 'node_server', rowNumber: data.rowNumber, hasXMLTemplate: !!data.xmlTemplate });
|
|
}
|
|
|
|
// ============= PRÉPARATION DONNÉES =============
|
|
|
|
/**
|
|
* Préparer les données CSV selon la source - ASYNC pour Node.js
|
|
* RÉCUPÈRE: Google Sheets (données CSV) + Digital Ocean (XML template)
|
|
*/
|
|
async function prepareCSVData(data) {
|
|
if (data.csvData && data.csvData.mc0) {
|
|
// Données déjà préparées (Digital Ocean ou direct)
|
|
return data.csvData;
|
|
} else if (data.rowNumber) {
|
|
// 1. RÉCUPÉRER DONNÉES CSV depuis Google Sheet (OBLIGATOIRE)
|
|
await logSh(`🧠 Récupération données CSV ligne ${data.rowNumber}...`, 'INFO');
|
|
const config = await getBrainConfig(data.rowNumber);
|
|
if (!config.success) {
|
|
await logSh('❌ ÉCHEC: Impossible de récupérer les données Google Sheets', 'ERROR');
|
|
throw new Error('FATAL: Google Sheets inaccessible - arrêt du workflow');
|
|
}
|
|
|
|
// 2. VÉRIFIER XML FILENAME depuis Google Sheet (colonne I)
|
|
const xmlFileName = config.data.xmlFileName;
|
|
if (!xmlFileName || xmlFileName.trim() === '') {
|
|
await logSh('❌ ÉCHEC: Nom fichier XML manquant (colonne I Google Sheets)', 'ERROR');
|
|
throw new Error('FATAL: XML filename manquant - arrêt du workflow');
|
|
}
|
|
|
|
await logSh(`📋 CSV récupéré: ${config.data.mc0}`, 'INFO');
|
|
await logSh(`📄 XML filename: ${xmlFileName}`, 'INFO');
|
|
|
|
// 3. RÉCUPÉRER XML CONTENT depuis Digital Ocean avec AUTH (OBLIGATOIRE)
|
|
await logSh(`🌊 Récupération XML template depuis Digital Ocean (avec signature AWS)...`, 'INFO');
|
|
let xmlContent;
|
|
try {
|
|
xmlContent = await fetchXMLFromDigitalOcean(xmlFileName);
|
|
await logSh(`✅ XML récupéré: ${xmlContent.length} caractères`, 'INFO');
|
|
} catch (digitalOceanError) {
|
|
await logSh(`❌ ÉCHEC: Digital Ocean inaccessible - ${digitalOceanError.message}`, 'ERROR');
|
|
throw new Error(`FATAL: Digital Ocean échec - arrêt du workflow: ${digitalOceanError.message}`);
|
|
}
|
|
|
|
// 4. ENCODER XML pour le workflow (comme Make.com)
|
|
// Si on a récupéré un fichier XML, l'utiliser. Sinon utiliser le template par défaut déjà dans config.data.xmlTemplate
|
|
if (xmlContent) {
|
|
data.xmlTemplate = Buffer.from(xmlContent).toString('base64');
|
|
await logSh('🔄 XML depuis Digital Ocean encodé base64 pour le workflow', 'DEBUG');
|
|
} else if (config.data.xmlTemplate) {
|
|
data.xmlTemplate = Buffer.from(config.data.xmlTemplate).toString('base64');
|
|
await logSh('🔄 XML template par défaut encodé base64 pour le workflow', 'DEBUG');
|
|
}
|
|
|
|
return config.data;
|
|
} else {
|
|
throw new Error('FATAL: Données CSV invalides - rowNumber requis');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Décoder le XML template - NODE.JS VERSION
|
|
*/
|
|
function decodeXMLTemplate(xmlTemplate) {
|
|
if (!xmlTemplate) {
|
|
throw new Error('Template XML manquant');
|
|
}
|
|
|
|
// Si le template commence déjà par <?xml, c'est du texte plain
|
|
if (xmlTemplate.startsWith('<?xml') || xmlTemplate.startsWith('<')) {
|
|
return xmlTemplate;
|
|
}
|
|
|
|
try {
|
|
// 🔄 NODE.JS : Tenter base64 uniquement si ce n'est pas déjà du XML
|
|
const decoded = Buffer.from(xmlTemplate, 'base64').toString('utf8');
|
|
return decoded;
|
|
} catch (error) {
|
|
// Si échec, considérer comme texte plain
|
|
logSh('🔍 XML pas encodé base64, utilisation directe', 'DEBUG'); // Using logSh instead of console.log
|
|
return xmlTemplate;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Preprocessing XML (nettoyage) - IDENTIQUE
|
|
*/
|
|
function preprocessXML(xmlString) {
|
|
let processed = xmlString;
|
|
|
|
// Nettoyer balises <strong>
|
|
processed = cleanStrongTags(processed);
|
|
|
|
// Autres nettoyages futurs...
|
|
|
|
return processed;
|
|
}
|
|
|
|
// ============= SAUVEGARDE =============
|
|
|
|
/**
|
|
* Sauvegarder l'article avec métadonnées source - ASYNC pour Node.js
|
|
*/
|
|
async function saveArticle(finalXML, generatedContent, finalElements, csvData, source) {
|
|
await logSh('💾 Sauvegarde article...', 'INFO');
|
|
|
|
const articleData = {
|
|
xmlContent: finalXML,
|
|
generatedTexts: generatedContent,
|
|
elementsGenerated: finalElements.length,
|
|
originalElements: finalElements
|
|
};
|
|
|
|
const storageConfig = {
|
|
antiDetectionLevel: 'Selective_Enhancement',
|
|
llmUsed: 'claude+openai+gemini+mistral',
|
|
workflowVersion: '2.0-NodeJS', // 🔄 Mise à jour version
|
|
source: source || 'node_server', // 🔄 Source par défaut
|
|
enhancementTechniques: [
|
|
'technical_terms_gpt4',
|
|
'transitions_gemini',
|
|
'personality_style_mistral'
|
|
]
|
|
};
|
|
|
|
try {
|
|
const articleStorage = await saveGeneratedArticleOrganic(articleData, csvData, storageConfig);
|
|
await logSh(`✅ Article sauvé: ID ${articleStorage.articleId}`, 'INFO');
|
|
return articleStorage;
|
|
} catch (storageError) {
|
|
await logSh(`⚠️ Erreur sauvegarde: ${storageError.toString()}`, 'WARNING');
|
|
return null; // Non-bloquant
|
|
}
|
|
}
|
|
|
|
// ============= RÉPONSE =============
|
|
|
|
/**
|
|
* Construire la réponse finale du workflow - ASYNC pour logSh
|
|
*/
|
|
async function buildWorkflowResponse(finalXML, generatedContent, finalElements, csvData, validationReport, articleStorage, source) {
|
|
const response = {
|
|
success: true,
|
|
source: source,
|
|
xmlContent: finalXML,
|
|
generatedTexts: generatedContent,
|
|
elementsGenerated: finalElements.length,
|
|
personality: csvData.personality?.nom || 'Unknown',
|
|
csvData: {
|
|
mc0: csvData.mc0,
|
|
t0: csvData.t0,
|
|
personality: csvData.personality?.nom
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
validationReport: validationReport,
|
|
articleStorage: articleStorage,
|
|
|
|
// NOUVELLES MÉTADONNÉES PHASE 2
|
|
antiDetectionLevel: 'Selective_Enhancement',
|
|
llmsUsed: ['claude', 'openai', 'gemini', 'mistral'],
|
|
enhancementApplied: true,
|
|
workflowVersion: '2.0-NodeJS', // 🔄 Version mise à jour
|
|
|
|
// STATS PERFORMANCE
|
|
stats: {
|
|
xmlLength: finalXML.length,
|
|
contentPieces: Object.keys(generatedContent).length,
|
|
wordCount: calculateTotalWordCount(generatedContent),
|
|
validationStatus: validationReport.status
|
|
}
|
|
};
|
|
|
|
await logSh(`🔍 Response.stats: ${JSON.stringify(response.stats)}`, 'DEBUG');
|
|
|
|
return response;
|
|
}
|
|
|
|
// ============= HELPERS =============
|
|
|
|
/**
|
|
* Calculer nombre total de mots - IDENTIQUE
|
|
*/
|
|
function calculateTotalWordCount(generatedContent) {
|
|
let totalWords = 0;
|
|
Object.values(generatedContent).forEach(content => {
|
|
if (content && typeof content === 'string') {
|
|
totalWords += content.trim().split(/\s+/).length;
|
|
}
|
|
});
|
|
return totalWords;
|
|
}
|
|
|
|
// ============= POINTS D'ENTRÉE SUPPLÉMENTAIRES =============
|
|
|
|
/**
|
|
* Test du workflow principal - ASYNC pour Node.js
|
|
*/
|
|
async function testMainWorkflow() {
|
|
try {
|
|
const testData = {
|
|
csvData: {
|
|
mc0: 'plaque test nodejs',
|
|
t0: 'Test workflow principal Node.js',
|
|
personality: { nom: 'Marc', style: 'professionnel' },
|
|
tMinus1: 'parent test',
|
|
mcPlus1: 'mot1,mot2,mot3,mot4',
|
|
tPlus1: 'Titre1,Titre2,Titre3,Titre4'
|
|
},
|
|
xmlTemplate: Buffer.from('<?xml version="1.0"?><test>|Test_Element{{T0}}|</test>').toString('base64'),
|
|
source: 'test_main_nodejs'
|
|
};
|
|
|
|
const result = await handleFullWorkflow(testData);
|
|
return result;
|
|
|
|
} catch (error) {
|
|
throw error;
|
|
} finally {
|
|
tracer.printSummary();
|
|
}
|
|
}
|
|
|
|
// 🔄 NODE.JS EXPORTS
|
|
module.exports = {
|
|
handleFullWorkflow,
|
|
testMainWorkflow,
|
|
prepareCSVData,
|
|
decodeXMLTemplate,
|
|
preprocessXML,
|
|
saveArticle,
|
|
buildWorkflowResponse,
|
|
calculateTotalWordCount,
|
|
launchLogViewer
|
|
};
|
|
|
|
/*
|
|
┌────────────────────────────────────────────────────────────────────┐
|
|
│ File: lib/test-manual.js │
|
|
└────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
// ========================================
|
|
// FICHIER: test-manual.js - ENTRY POINT MANUEL
|
|
// Description: Test workflow ligne 2 Google Sheets
|
|
// Usage: node test-manual.js
|
|
// ========================================
|
|
|
|
require('./polyfills/fetch.cjs');
|
|
require('dotenv').config();
|
|
|
|
const { handleFullWorkflow } = require('./Main');
|
|
const { logSh } = require('./ErrorReporting');
|
|
|
|
/**
|
|
* TEST MANUEL LIGNE 2
|
|
*/
|
|
async function testWorkflowLigne2() {
|
|
logSh('🚀 === DÉMARRAGE TEST MANUEL LIGNE 2 ===', 'INFO'); // Using logSh instead of console.log
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// DONNÉES DE TEST POUR LIGNE 2
|
|
const testData = {
|
|
rowNumber: 2, // Ligne 2 Google Sheets
|
|
source: 'test_manual_nodejs'
|
|
};
|
|
|
|
logSh('📊 Configuration test:', 'INFO'); // Using logSh instead of console.log
|
|
logSh(` • Ligne: ${testData.rowNumber}`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(` • Source: ${testData.source}`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(` • Timestamp: ${new Date().toISOString()}`, 'INFO'); // Using logSh instead of console.log
|
|
|
|
// LANCER LE WORKFLOW
|
|
logSh('\n🎯 Lancement workflow principal...', 'INFO'); // Using logSh instead of console.log
|
|
const result = await handleFullWorkflow(testData);
|
|
|
|
// AFFICHER RÉSULTATS
|
|
const duration = Date.now() - startTime;
|
|
logSh('\n🏆 === WORKFLOW TERMINÉ AVEC SUCCÈS ===', 'INFO'); // Using logSh instead of console.log
|
|
logSh(`⏱️ Durée: ${Math.round(duration/1000)}s`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(`📊 Status: ${result.success ? '✅ SUCCESS' : '❌ ERROR'}`, 'INFO'); // Using logSh instead of console.log
|
|
|
|
if (result.success) {
|
|
logSh(`📝 Éléments générés: ${result.elementsGenerated}`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(`👤 Personnalité: ${result.personality}`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(`🎯 MC0: ${result.csvData?.mc0 || 'N/A'}`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(`📄 XML length: ${result.stats?.xmlLength || 'N/A'} chars`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(`🔤 Mots total: ${result.stats?.wordCount || 'N/A'}`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(`🧠 LLMs utilisés: ${result.llmsUsed?.join(', ') || 'N/A'}`, 'INFO'); // Using logSh instead of console.log
|
|
|
|
if (result.articleStorage) {
|
|
logSh(`💾 Article sauvé: ID ${result.articleStorage.articleId}`, 'INFO'); // Using logSh instead of console.log
|
|
}
|
|
}
|
|
|
|
logSh('\n📋 Résultat complet:', 'DEBUG'); // Using logSh instead of console.log
|
|
logSh(JSON.stringify(result, null, 2), 'DEBUG'); // Using logSh instead of console.log
|
|
|
|
return result;
|
|
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
logSh('\n❌ === ERREUR WORKFLOW ===', 'ERROR'); // Using logSh instead of console.error
|
|
logSh(`❌ Message: ${error.message}`, 'ERROR'); // Using logSh instead of console.error
|
|
logSh(`❌ Durée avant échec: ${Math.round(duration/1000)}s`, 'ERROR'); // Using logSh instead of console.error
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
logSh(`❌ Stack: ${error.stack}`, 'ERROR'); // Using logSh instead of console.error
|
|
}
|
|
|
|
// Afficher conseils de debug
|
|
logSh('\n🔧 CONSEILS DE DEBUG:', 'INFO'); // Using logSh instead of console.log
|
|
logSh('1. Vérifiez vos variables d\'environnement (.env)', 'INFO'); // Using logSh instead of console.log
|
|
logSh('2. Vérifiez la connexion Google Sheets', 'INFO'); // Using logSh instead of console.log
|
|
logSh('3. Vérifiez les API keys LLM', 'INFO'); // Using logSh instead of console.log
|
|
logSh('4. Regardez les logs détaillés dans ./logs/', 'INFO'); // Using logSh instead of console.log
|
|
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* VÉRIFICATIONS PRÉALABLES
|
|
*/
|
|
function checkEnvironment() {
|
|
logSh('🔍 Vérification environnement...', 'INFO'); // Using logSh instead of console.log
|
|
|
|
const required = [
|
|
'GOOGLE_SHEETS_ID',
|
|
'OPENAI_API_KEY'
|
|
];
|
|
|
|
const missing = required.filter(key => !process.env[key]);
|
|
|
|
if (missing.length > 0) {
|
|
logSh('❌ Variables d\'environnement manquantes:', 'ERROR'); // Using logSh instead of console.error
|
|
missing.forEach(key => logSh(` • ${key}`, 'ERROR')); // Using logSh instead of console.error
|
|
logSh('\n💡 Créez un fichier .env avec ces variables', 'ERROR'); // Using logSh instead of console.error
|
|
process.exit(1);
|
|
}
|
|
|
|
logSh('✅ Variables d\'environnement OK', 'INFO'); // Using logSh instead of console.log
|
|
|
|
// Info sur les variables configurées
|
|
logSh('📋 Configuration détectée:', 'INFO'); // Using logSh instead of console.log
|
|
logSh(` • Google Sheets ID: ${process.env.GOOGLE_SHEETS_ID}`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(` • OpenAI: ${process.env.OPENAI_API_KEY ? '✅ Configuré' : '❌ Manquant'}`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(` • Claude: ${process.env.CLAUDE_API_KEY ? '✅ Configuré' : '⚠️ Optionnel'}`, 'INFO'); // Using logSh instead of console.log
|
|
logSh(` • Gemini: ${process.env.GEMINI_API_KEY ? '✅ Configuré' : '⚠️ Optionnel'}`, 'INFO'); // Using logSh instead of console.log
|
|
}
|
|
|
|
/**
|
|
* POINT D'ENTRÉE PRINCIPAL
|
|
*/
|
|
async function main() {
|
|
try {
|
|
// Vérifications préalables
|
|
checkEnvironment();
|
|
|
|
// Test workflow
|
|
await testWorkflowLigne2();
|
|
|
|
logSh('\n🎉 Test manuel terminé avec succès !', 'INFO'); // Using logSh instead of console.log
|
|
process.exit(0);
|
|
|
|
} catch (error) {
|
|
logSh('\n💥 Erreur fatale: ' + error.message, 'ERROR'); // Using logSh instead of console.error
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Lancer si exécuté directement
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = { testWorkflowLigne2 };
|
|
|
|
/*
|
|
┌────────────────────────────────────────────────────────────────────┐
|
|
│ File: lib/ManualTrigger.js │
|
|
└────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
const { logSh } = require('./ErrorReporting'); // Using unified logSh from ErrorReporting
|
|
|
|
/**
|
|
* 🚀 TRIGGER MANUEL - Lit ligne 2 et lance le workflow
|
|
* Exécute cette fonction depuis l'éditeur Apps Script
|
|
*/
|
|
function runWorkflowLigne(numeroLigne = 2) {
|
|
cleanLogSheet(); // Nettoie les logs pour ce test
|
|
|
|
try {
|
|
logSh('🎬 >>> DÉMARRAGE WORKFLOW MANUEL <<<', 'INFO');
|
|
|
|
// 1. LIRE AUTOMATIQUEMENT LA LIGNE INDIQUÉ
|
|
const csvData = readCSVDataFromRow(numeroLigne);
|
|
logSh(`✅ Données lues - MC0: ${csvData.mc0}`, 'INFO');
|
|
logSh(`✅ Titre: ${csvData.t0}`, 'INFO');
|
|
logSh(`✅ Personnalité: ${csvData.personality.nom}`, 'INFO');
|
|
|
|
// 2. XML TEMPLATE SIMPLE POUR TEST (ou lit depuis Digital Ocean si configuré)
|
|
const xmlTemplate = getXMLTemplateForTest(csvData);
|
|
logSh(`✅ XML template: ${xmlTemplate.length} caractères`, 'INFO');
|
|
|
|
// 3. 🎯 LANCER LE WORKFLOW PRINCIPAL
|
|
const workflowData = {
|
|
csvData: csvData,
|
|
xmlTemplate: Utilities.base64Encode(xmlTemplate),
|
|
source: 'manuel_ligne2'
|
|
};
|
|
|
|
const result = handleFullWorkflow(workflowData);
|
|
|
|
logSh('🏆 === WORKFLOW MANUEL TERMINÉ ===', 'INFO');
|
|
|
|
// ← EXTRAIRE LES VRAIES DONNÉES
|
|
let actualData;
|
|
if (result && result.getContentText) {
|
|
// C'est un ContentService, extraire le JSON
|
|
actualData = JSON.parse(result.getContentText());
|
|
} else {
|
|
actualData = result;
|
|
}
|
|
|
|
logSh(`Type result: ${typeof result}`, 'DEBUG');
|
|
logSh(`Result keys: ${Object.keys(result || {})}`, 'DEBUG');
|
|
logSh(`ActualData keys: ${Object.keys(actualData || {})}`, 'DEBUG');
|
|
logSh(`ActualData: ${JSON.stringify(actualData)}`, 'DEBUG');
|
|
|
|
if (actualData && actualData.stats) {
|
|
logSh(`📊 Éléments générés: ${actualData.stats.contentPieces}`, 'INFO');
|
|
logSh(`📝 Nombre de mots: ${actualData.stats.wordCount}`, 'INFO');
|
|
} else {
|
|
logSh('⚠️ Format résultat inattendu', 'WARNING');
|
|
logSh('ActualData: ' + JSON.stringify(actualData, null, 2), 'DEBUG'); // Using logSh instead of console.log
|
|
}
|
|
|
|
return actualData;
|
|
|
|
} catch (error) {
|
|
logSh(`❌ ERREUR WORKFLOW MANUEL: ${error.toString()}`, 'ERROR');
|
|
logSh(`Stack: ${error.stack}`, 'ERROR');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* HELPER - Lire CSV depuis une ligne spécifique
|
|
*/
|
|
function readCSVDataFromRow(rowNumber) {
|
|
const sheetId = '1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c';
|
|
const spreadsheet = SpreadsheetApp.openById(sheetId);
|
|
const articlesSheet = spreadsheet.getSheetByName('instructions');
|
|
|
|
// Lire la ligne complète (colonnes A à H)
|
|
const range = articlesSheet.getRange(rowNumber, 1, 1, 9);
|
|
const [slug, t0, mc0, tMinus1, lMinus1, mcPlus1, tPlus1, lPlus1, xmlFileName] = range.getValues()[0];
|
|
|
|
logSh(`📖 Lecture ligne ${rowNumber}: ${slug}`, 'DEBUG');
|
|
|
|
// Récupérer personnalités et sélectionner automatiquement
|
|
const personalitiesSheet = spreadsheet.getSheetByName('Personnalites');
|
|
const personalities = getPersonalities(personalitiesSheet);
|
|
const selectedPersonality = selectPersonalityWithAI(mc0, t0, personalities);
|
|
|
|
return {
|
|
rowNumber: rowNumber,
|
|
slug: slug || 'test-slug',
|
|
t0: t0 || 'Titre par défaut',
|
|
mc0: mc0 || 'mot-clé test',
|
|
tMinus1: tMinus1 || 'parent',
|
|
lMinus1: lMinus1 || '/parent',
|
|
mcPlus1: mcPlus1 || 'mot1,mot2,mot3,mot4',
|
|
tPlus1: tPlus1 || 'Titre1,Titre2,Titre3,Titre4',
|
|
lPlus1: lPlus1 || '/lien1,/lien2,/lien3,/lien4',
|
|
personality: selectedPersonality,
|
|
xmlFileName: xmlFileName ? xmlFileName.toString().trim() : null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* HELPER - XML Template simple pour test (ou depuis Digital Ocean)
|
|
*/
|
|
function getXMLTemplateForTest(csvData) {
|
|
logSh("csvData.xmlFileName: " + csvData.xmlFileName, 'DEBUG'); // Using logSh instead of console.log
|
|
|
|
if (csvData.xmlFileName) {
|
|
logSh("Tentative Digital Ocean...", 'INFO'); // Using logSh instead of console.log
|
|
try {
|
|
return fetchXMLFromDigitalOceanSimple(csvData.xmlFileName);
|
|
} catch (error) {
|
|
// ← ENLÈVE LE CATCH SILENCIEUX
|
|
logSh("Erreur DO: " + error.toString(), 'WARNING'); // Using logSh instead of console.log
|
|
logSh(`❌ ERREUR DO DÉTAILLÉE: ${error.toString()}`, 'ERROR');
|
|
|
|
// Continue sans Digital Ocean
|
|
}
|
|
}
|
|
|
|
logSh("❌ FATAL: Aucun template XML disponible", 'ERROR');
|
|
throw new Error("FATAL: Template XML indisponible (Digital Ocean inaccessible + pas de fallback) - arrêt du workflow");
|
|
}
|
|
|
|
/*
|
|
┌────────────────────────────────────────────────────────────────────┐
|
|
│ File: lib/trace-wrap.js │
|
|
└────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
// lib/trace-wrap.js
|
|
const { tracer } = require('./trace.js');
|
|
|
|
const traced = (name, fn, attrs) => (...args) =>
|
|
tracer.run(name, () => fn(...args), attrs);
|
|
|
|
module.exports = {
|
|
traced
|
|
};
|
|
|
|
/*
|
|
┌────────────────────────────────────────────────────────────────────┐
|
|
│ File: lib/Utils.js │
|
|
└────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
// ========================================
|
|
// FICHIER: utils.js - Conversion Node.js
|
|
// Description: Utilitaires génériques pour le workflow
|
|
// ========================================
|
|
|
|
// Import du système de logging (assumant que logSh est disponible globalement)
|
|
// const { logSh } = require('./logging'); // À décommenter si logSh est dans un module séparé
|
|
|
|
/**
|
|
* Créer une réponse de succès standardisée
|
|
* @param {Object} data - Données à retourner
|
|
* @returns {Object} Réponse formatée pour Express/HTTP
|
|
*/
|
|
function createSuccessResponse(data) {
|
|
return {
|
|
success: true,
|
|
data: data,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Créer une réponse d'erreur standardisée
|
|
* @param {string|Error} error - Message d'erreur ou objet Error
|
|
* @returns {Object} Réponse d'erreur formatée
|
|
*/
|
|
function createErrorResponse(error) {
|
|
const errorMessage = error instanceof Error ? error.message : error.toString();
|
|
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
timestamp: new Date().toISOString(),
|
|
stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Middleware Express pour envoyer des réponses standardisées
|
|
* Usage: res.success(data) ou res.error(error)
|
|
*/
|
|
function responseMiddleware(req, res, next) {
|
|
// Méthode pour réponse de succès
|
|
res.success = (data, statusCode = 200) => {
|
|
res.status(statusCode).json(createSuccessResponse(data));
|
|
};
|
|
|
|
// Méthode pour réponse d'erreur
|
|
res.error = (error, statusCode = 500) => {
|
|
res.status(statusCode).json(createErrorResponse(error));
|
|
};
|
|
|
|
next();
|
|
}
|
|
|
|
/**
|
|
* HELPER : Nettoyer les instructions FAQ
|
|
* Remplace les variables et nettoie le HTML
|
|
* @param {string} instructions - Instructions à nettoyer
|
|
* @param {Object} csvData - Données CSV pour remplacement variables
|
|
* @returns {string} Instructions nettoyées
|
|
*/
|
|
function cleanFAQInstructions(instructions, csvData) {
|
|
if (!instructions || !csvData) {
|
|
return instructions || '';
|
|
}
|
|
|
|
let clean = instructions.toString();
|
|
|
|
try {
|
|
// Remplacer variables simples
|
|
clean = clean.replace(/\{\{MC0\}\}/g, csvData.mc0 || '');
|
|
clean = clean.replace(/\{\{T0\}\}/g, csvData.t0 || '');
|
|
|
|
// Variables multiples si nécessaire
|
|
if (csvData.mcPlus1) {
|
|
const mcPlus1 = csvData.mcPlus1.split(',').map(s => s.trim());
|
|
|
|
for (let i = 1; i <= 6; i++) {
|
|
const mcValue = mcPlus1[i-1] || `[MC+1_${i} non défini]`;
|
|
clean = clean.replace(new RegExp(`\\{\\{MC\\+1_${i}\\}\\}`, 'g'), mcValue);
|
|
}
|
|
}
|
|
|
|
// Variables T+1 et L+1 si disponibles
|
|
if (csvData.tPlus1) {
|
|
const tPlus1 = csvData.tPlus1.split(',').map(s => s.trim());
|
|
for (let i = 1; i <= 6; i++) {
|
|
const tValue = tPlus1[i-1] || `[T+1_${i} non défini]`;
|
|
clean = clean.replace(new RegExp(`\\{\\{T\\+1_${i}\\}\\}`, 'g'), tValue);
|
|
}
|
|
}
|
|
|
|
if (csvData.lPlus1) {
|
|
const lPlus1 = csvData.lPlus1.split(',').map(s => s.trim());
|
|
for (let i = 1; i <= 6; i++) {
|
|
const lValue = lPlus1[i-1] || `[L+1_${i} non défini]`;
|
|
clean = clean.replace(new RegExp(`\\{\\{L\\+1_${i}\\}\\}`, 'g'), lValue);
|
|
}
|
|
}
|
|
|
|
// Nettoyer HTML
|
|
clean = clean.replace(/<\/?[^>]+>/g, '');
|
|
|
|
// Nettoyer espaces en trop
|
|
clean = clean.replace(/\s+/g, ' ').trim();
|
|
|
|
} catch (error) {
|
|
if (typeof logSh === 'function') {
|
|
logSh(`⚠️ Erreur nettoyage instructions FAQ: ${error.toString()}`, 'WARNING');
|
|
}
|
|
// Retourner au moins la version partiellement nettoyée
|
|
}
|
|
|
|
return clean;
|
|
}
|
|
|
|
/**
|
|
* Utilitaire pour attendre un délai (remplace Utilities.sleep de Google Apps Script)
|
|
* @param {number} ms - Millisecondes à attendre
|
|
* @returns {Promise} Promise qui se résout après le délai
|
|
*/
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Utilitaire pour encoder en base64
|
|
* @param {string} text - Texte à encoder
|
|
* @returns {string} Texte encodé en base64
|
|
*/
|
|
function base64Encode(text) {
|
|
return Buffer.from(text, 'utf8').toString('base64');
|
|
}
|
|
|
|
/**
|
|
* Utilitaire pour décoder du base64
|
|
* @param {string} base64Text - Texte base64 à décoder
|
|
* @returns {string} Texte décodé
|
|
*/
|
|
function base64Decode(base64Text) {
|
|
return Buffer.from(base64Text, 'base64').toString('utf8');
|
|
}
|
|
|
|
/**
|
|
* Valider et nettoyer un slug/filename
|
|
* @param {string} slug - Slug à nettoyer
|
|
* @returns {string} Slug nettoyé
|
|
*/
|
|
function cleanSlug(slug) {
|
|
if (!slug) return '';
|
|
|
|
return slug
|
|
.toString()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\-_]/g, '-') // Remplacer caractères spéciaux par -
|
|
.replace(/-+/g, '-') // Éviter doubles tirets
|
|
.replace(/^-+|-+$/g, ''); // Enlever tirets début/fin
|
|
}
|
|
|
|
/**
|
|
* Compter les mots dans un texte
|
|
* @param {string} text - Texte à analyser
|
|
* @returns {number} Nombre de mots
|
|
*/
|
|
function countWords(text) {
|
|
if (!text || typeof text !== 'string') return 0;
|
|
|
|
return text
|
|
.trim()
|
|
.replace(/\s+/g, ' ') // Normaliser espaces
|
|
.split(' ')
|
|
.filter(word => word.length > 0)
|
|
.length;
|
|
}
|
|
|
|
/**
|
|
* Formater une durée en millisecondes en format lisible
|
|
* @param {number} ms - Durée en millisecondes
|
|
* @returns {string} Durée formatée (ex: "2.3s" ou "450ms")
|
|
*/
|
|
function formatDuration(ms) {
|
|
if (ms < 1000) {
|
|
return `${ms}ms`;
|
|
} else if (ms < 60000) {
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
} else {
|
|
const minutes = Math.floor(ms / 60000);
|
|
const seconds = ((ms % 60000) / 1000).toFixed(1);
|
|
return `${minutes}m ${seconds}s`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Utilitaire pour retry automatique d'une fonction
|
|
* @param {Function} fn - Fonction à exécuter avec retry
|
|
* @param {number} maxRetries - Nombre maximum de tentatives
|
|
* @param {number} delay - Délai entre tentatives (ms)
|
|
* @returns {Promise} Résultat de la fonction ou erreur finale
|
|
*/
|
|
async function withRetry(fn, maxRetries = 3, delay = 1000) {
|
|
let lastError;
|
|
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
lastError = error;
|
|
|
|
if (typeof logSh === 'function') {
|
|
logSh(`⚠️ Tentative ${attempt}/${maxRetries} échouée: ${error.toString()}`, 'WARNING');
|
|
}
|
|
|
|
if (attempt < maxRetries) {
|
|
await sleep(delay * attempt); // Exponential backoff
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError;
|
|
}
|
|
|
|
/**
|
|
* Validation basique d'email
|
|
* @param {string} email - Email à valider
|
|
* @returns {boolean} True si email valide
|
|
*/
|
|
function isValidEmail(email) {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return emailRegex.test(email);
|
|
}
|
|
|
|
/**
|
|
* Générer un ID unique simple
|
|
* @returns {string} ID unique basé sur timestamp + random
|
|
*/
|
|
function generateId() {
|
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* Truncate un texte à une longueur donnée
|
|
* @param {string} text - Texte à tronquer
|
|
* @param {number} maxLength - Longueur maximale
|
|
* @param {string} suffix - Suffixe à ajouter si tronqué (défaut: '...')
|
|
* @returns {string} Texte tronqué
|
|
*/
|
|
function truncate(text, maxLength, suffix = '...') {
|
|
if (!text || text.length <= maxLength) {
|
|
return text;
|
|
}
|
|
|
|
return text.substring(0, maxLength - suffix.length) + suffix;
|
|
}
|
|
|
|
// ============= EXPORTS =============
|
|
|
|
module.exports = {
|
|
createSuccessResponse,
|
|
createErrorResponse,
|
|
responseMiddleware,
|
|
cleanFAQInstructions,
|
|
sleep,
|
|
base64Encode,
|
|
base64Decode,
|
|
cleanSlug,
|
|
countWords,
|
|
formatDuration,
|
|
withRetry,
|
|
isValidEmail,
|
|
generateId,
|
|
truncate
|
|
};
|
|
|