seo-generator-server/public/validation-dashboard.html
StillHammer 9a2ef7da2b feat(human-simulation): Système d'erreurs graduées procédurales + anti-répétition complet
## 🎯 Nouveau système d'erreurs graduées (architecture SmartTouch)

### Architecture procédurale intelligente :
- **3 niveaux de gravité** : Légère (50%) → Moyenne (30%) → Grave (10%)
- **14 types d'erreurs** réalistes et subtiles
- **Sélection procédurale** selon contexte (longueur, technique, heure)
- **Distribution contrôlée** : max 1 grave, 2 moyennes, 3 légères par article

### 1. Erreurs GRAVES (10% articles max) :
- Accord sujet-verbe : "ils sont" → "ils est"
- Mot manquant : "pour garantir la qualité" → "pour garantir qualité"
- Double mot : "pour garantir" → "pour pour garantir"
- Négation oubliée : "n'est pas" → "est pas"

### 2. Erreurs MOYENNES (30% articles) :
- Accord pluriel : "plaques résistantes" → "plaques résistant"
- Virgule manquante : "Ainsi, il" → "Ainsi il"
- Registre inapproprié : "Par conséquent" → "Du coup"
- Préposition incorrecte : "résistant aux" → "résistant des"
- Connecteur illogique : "cependant" → "donc"

### 3. Erreurs LÉGÈRES (50% articles) :
- Double espace : "de votre" → "de  votre"
- Trait d'union : "c'est-à-dire" → "c'est à dire"
- Espace ponctuation : "qualité ?" → "qualité?"
- Majuscule : "Toutenplaque" → "toutenplaque"
- Apostrophe droite : "l'article" → "l'article"

##  Système anti-répétition complet :

### Corrections critiques :
- **HumanSimulationTracker.js** : Tracker centralisé global
- **Word boundaries (\b)** sur TOUS les regex → FIX "maison" → "néanmoinson"
- **Protection 30+ expressions idiomatiques** françaises
- **Anti-répétition** : max 2× même mot, jamais 2× même développement
- **Diversification** : 48 variantes (hésitations, développements, connecteurs)

### Nouvelle structure (comme SmartTouch) :
```
lib/human-simulation/
├── error-profiles/                (NOUVEAU)
│   ├── ErrorProfiles.js          (définitions + probabilités)
│   ├── ErrorGrave.js             (10% articles)
│   ├── ErrorMoyenne.js           (30% articles)
│   ├── ErrorLegere.js            (50% articles)
│   └── ErrorSelector.js          (sélection procédurale)
├── HumanSimulationCore.js         (orchestrateur)
├── HumanSimulationTracker.js      (anti-répétition)
└── [autres modules]
```

## 🔄 Remplace ancien système :
-  SpellingErrors.js (basique, répétitif, "et" → "." × 8)
-  error-profiles/ (gradué, procédural, intelligent, diversifié)

## 🎲 Fonctionnalités procédurales :
- Analyse contexte : longueur texte, complexité technique, heure rédaction
- Multiplicateurs adaptatifs selon contexte
- Conditions application intelligentes
- Tracking global par batch (respecte limites 10%/30%/50%)

## 📊 Résultats validation :
Sur 100 articles → ~40-50 avec erreurs subtiles et diverses (plus de spam répétitif)

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

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

1404 lines
49 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pipeline Validator - SEO Generator</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #667eea;
--secondary: #764ba2;
--success: #48bb78;
--warning: #ed8936;
--error: #f56565;
--bg-light: #f7fafc;
--bg-dark: #1a202c;
--text-dark: #2d3748;
--text-light: #a0aec0;
--border-light: #e2e8f0;
}
body {
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: var(--text-dark);
padding: 20px;
}
.container { max-width: 1400px; margin: 0 auto; }
header {
background: white;
border-radius: 10px;
padding: 20px 30px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 { color: var(--text-dark); font-size: 1.8em; }
.btn-back {
background: var(--bg-light);
color: var(--text-dark);
padding: 10px 20px;
border-radius: 8px;
text-decoration: none;
transition: all 0.2s;
}
.btn-back:hover { background: var(--border-light); }
/* Tabs Navigation */
.tabs-nav {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.tab-btn {
flex: 1;
background: white;
border: none;
padding: 15px 20px;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
font-size: 14px;
}
.tab-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0,0,0,0.15); }
.tab-btn.active {
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
}
/* Tab Content */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.panel {
background: white;
border-radius: 10px;
padding: 25px;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.panel h2 {
color: var(--text-dark);
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid var(--border-light);
}
.form-group { margin-bottom: 20px; }
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-dark);
}
.form-group select,
.form-group input {
width: 100%;
padding: 12px;
border: 2px solid var(--border-light);
border-radius: 8px;
font-size: 14px;
}
button {
padding: 12px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
}
.btn-primary {
background: linear-gradient(135deg, var(--success), #38a169);
color: white;
width: 100%;
font-size: 16px;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(72, 187, 120, 0.4);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-small {
padding: 8px 16px;
font-size: 12px;
}
.btn-stop {
background: var(--error);
color: white;
}
.btn-view {
background: var(--primary);
color: white;
}
.btn-export {
background: var(--warning);
color: white;
}
/* Validation Cards */
.validation-list {
display: grid;
gap: 15px;
}
.validation-card {
background: var(--bg-light);
border-radius: 8px;
padding: 20px;
border-left: 4px solid var(--primary);
}
.validation-card.completed { border-left-color: var(--success); }
.validation-card.error { border-left-color: var(--error); }
.validation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.validation-title {
font-weight: 700;
font-size: 16px;
color: var(--text-dark);
}
.validation-id {
font-size: 11px;
color: var(--text-light);
font-family: monospace;
}
.validation-actions {
display: flex;
gap: 10px;
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--border-light);
border-radius: 4px;
overflow: hidden;
margin: 10px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--secondary));
transition: width 0.5s ease;
}
.progress-text {
font-size: 12px;
color: var(--text-light);
margin-top: 5px;
}
.validation-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-top: 10px;
}
.meta-item {
font-size: 13px;
}
.meta-label {
color: var(--text-light);
font-size: 11px;
}
.meta-value {
color: var(--text-dark);
font-weight: 600;
}
/* Score Badge */
.score-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-weight: 700;
font-size: 14px;
}
.score-high { background: #c6f6d5; color: #22543d; }
.score-medium { background: #feebc8; color: #7c2d12; }
.score-low { background: #fed7d7; color: #822727; }
/* Charts Container */
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.chart-container {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.chart-title {
font-weight: 600;
margin-bottom: 15px;
color: var(--text-dark);
}
/* Details Section */
.details-section {
margin-top: 20px;
}
.timeline {
position: relative;
padding-left: 30px;
}
.timeline::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 2px;
background: var(--border-light);
}
.timeline-item {
position: relative;
margin-bottom: 20px;
}
.timeline-item::before {
content: '';
position: absolute;
left: -26px;
top: 5px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--primary);
border: 2px solid white;
}
.timeline-content {
background: var(--bg-light);
padding: 15px;
border-radius: 8px;
}
.timeline-phase {
font-weight: 600;
color: var(--text-dark);
margin-bottom: 5px;
}
.timeline-duration {
font-size: 12px;
color: var(--text-light);
}
/* Sample Cards */
.samples-list {
display: grid;
gap: 15px;
}
.sample-card {
background: var(--bg-light);
padding: 15px;
border-radius: 8px;
border-left: 3px solid var(--primary);
}
.sample-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.sample-tag {
font-family: monospace;
font-weight: 700;
color: var(--primary);
}
.sample-type {
font-size: 11px;
color: var(--text-light);
background: white;
padding: 4px 8px;
border-radius: 4px;
}
.sample-scores {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.criterion-score {
font-size: 12px;
padding: 4px 8px;
background: white;
border-radius: 4px;
}
.accordion {
cursor: pointer;
background: white;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
font-size: 12px;
font-weight: 600;
user-select: none;
}
.accordion:hover { background: var(--border-light); }
.accordion-content {
display: none;
padding: 10px;
margin-top: 5px;
background: white;
border-radius: 4px;
font-size: 12px;
color: var(--text-dark);
line-height: 1.6;
}
.accordion-content.active {
display: block;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-light);
}
.empty-state-icon {
font-size: 4em;
margin-bottom: 20px;
}
/* Status Badge */
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.status-running { background: #bee3f8; color: #2b6cb0; }
.status-completed { background: #c6f6d5; color: #22543d; }
.status-error { background: #fed7d7; color: #822727; }
.status-stopped { background: #e2e8f0; color: #4a5568; }
/* Responsive */
@media (max-width: 768px) {
.charts-grid {
grid-template-columns: 1fr;
}
.validation-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.validation-actions {
width: 100%;
}
.validation-actions button {
flex: 1;
}
}
/* Loading Spinner */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid var(--border-light);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.pipeline-preview {
background: var(--bg-light);
padding: 15px;
border-radius: 8px;
margin-top: 15px;
display: none;
}
.pipeline-preview.visible {
display: block;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-top: 10px;
}
.summary-item {
background: white;
padding: 10px;
border-radius: 6px;
text-align: center;
}
.summary-label {
font-size: 11px;
color: var(--text-light);
margin-bottom: 3px;
}
.summary-value {
font-size: 16px;
font-weight: 700;
color: var(--primary);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🧪 Pipeline Validator</h1>
<a href="index.html" class="btn-back">← Retour Accueil</a>
</header>
<!-- Tabs Navigation -->
<div class="tabs-nav">
<button class="tab-btn active" onclick="switchTab('new')">
🚀 Nouvelle Validation
</button>
<button class="tab-btn" onclick="switchTab('active')">
⚡ Validations Actives <span id="activeCount" style="opacity: 0.7;">(0)</span>
</button>
<button class="tab-btn" onclick="switchTab('results')">
📊 Résultats & Détails
</button>
</div>
<!-- TAB 1: Nouvelle Validation -->
<div id="tab-new" class="tab-content active">
<div class="panel">
<h2>📂 Configuration</h2>
<div class="form-group">
<label for="pipelineSelect">Pipeline à valider :</label>
<select id="pipelineSelect" onchange="loadPipelinePreview()">
<option value="">-- Sélectionner un pipeline --</option>
</select>
</div>
<div id="pipelinePreview" class="pipeline-preview">
<h3 style="margin-bottom: 10px; color: var(--text-dark);" id="previewName"></h3>
<p style="font-size: 13px; color: var(--text-light);" id="previewDesc"></p>
<div class="summary-grid">
<div class="summary-item">
<div class="summary-label">Étapes</div>
<div class="summary-value" id="previewSteps">-</div>
</div>
<div class="summary-item">
<div class="summary-label">Durée Estimée</div>
<div class="summary-value" id="previewDuration">-</div>
</div>
</div>
</div>
<div class="form-group">
<label for="rowNumber">Ligne Google Sheets :</label>
<input type="number" id="rowNumber" value="2" min="2" max="1000">
</div>
<div class="form-group" style="margin-top: 25px;">
<label>Mode de validation :</label>
<div id="presetsContainer" style="margin-top: 10px;">
<div style="margin-bottom: 8px;">
<label style="display: flex; align-items: center; padding: 10px; background: var(--bg-light); border-radius: 6px; cursor: pointer;">
<input type="radio" name="validationPreset" value="economical" checked style="margin-right: 10px;">
<div style="flex: 1;">
<strong>Économique</strong> <span style="color: var(--success); font-weight: 600;">($0.12, 1min)</span><br>
<span style="font-size: 12px; color: var(--text-light);">v1.0 + v2.0, 3 critères, 5 échantillons</span>
</div>
</label>
</div>
<div style="margin-bottom: 8px;">
<label style="display: flex; align-items: center; padding: 10px; background: var(--bg-light); border-radius: 6px; cursor: pointer;">
<input type="radio" name="validationPreset" value="standard" style="margin-right: 10px;">
<div style="flex: 1;">
<strong>Standard</strong> <span style="color: var(--warning); font-weight: 600;">($0.30, 2min)</span><br>
<span style="font-size: 12px; color: var(--text-light);">3 versions, tous critères, 5 échantillons</span>
</div>
</label>
</div>
<div style="margin-bottom: 8px;">
<label style="display: flex; align-items: center; padding: 10px; background: var(--bg-light); border-radius: 6px; cursor: pointer;">
<input type="radio" name="validationPreset" value="complete" style="margin-right: 10px;">
<div style="flex: 1;">
<strong>Complet</strong> <span style="color: var(--error); font-weight: 600;">($1.00, 5min)</span><br>
<span style="font-size: 12px; color: var(--text-light);">Toutes versions, tous critères, tous échantillons</span>
</div>
</label>
</div>
</div>
</div>
<button class="btn-primary" id="btnStart" onclick="startValidation()" disabled>
🚀 Démarrer Validation
</button>
</div>
</div>
<!-- TAB 2: Validations Actives -->
<div id="tab-active" class="tab-content">
<div class="panel">
<h2>⚡ Validations en cours</h2>
<div id="activeValidations" class="validation-list">
<div class="empty-state">
<div class="empty-state-icon">💤</div>
<p>Aucune validation active</p>
</div>
</div>
</div>
<div class="panel">
<h2>📜 Historique</h2>
<div id="historyValidations" class="validation-list">
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<p>Aucune validation terminée</p>
</div>
</div>
</div>
</div>
<!-- TAB 3: Résultats & Détails -->
<div id="tab-results" class="tab-content">
<div class="panel">
<h2>📊 Sélection</h2>
<div class="form-group">
<label for="validationSelect">Choisir une validation :</label>
<select id="validationSelect" onchange="loadValidationResults()">
<option value="">-- Sélectionner --</option>
</select>
</div>
</div>
<div id="resultsContainer" style="display: none;">
<!-- Summary Panel -->
<div class="panel">
<h2>📈 Résumé</h2>
<div class="summary-grid">
<div class="summary-item">
<div class="summary-label">Score Global</div>
<div class="summary-value" id="summaryScore">-</div>
</div>
<div class="summary-item">
<div class="summary-label">Progression v1.0 → v2.0</div>
<div class="summary-value" id="summaryDelta">-</div>
</div>
<div class="summary-item">
<div class="summary-label">Total Évaluations</div>
<div class="summary-value" id="summaryTotal">-</div>
</div>
<div class="summary-item">
<div class="summary-label">Durée</div>
<div class="summary-value" id="summaryDurationResult">-</div>
</div>
</div>
<div style="margin-top: 15px; text-align: right;">
<button class="btn-small btn-export" onclick="exportReport()">
💾 Export JSON
</button>
</div>
</div>
<!-- Charts -->
<div class="charts-grid">
<div class="chart-container">
<div class="chart-title">📈 Évolution par Version</div>
<canvas id="chartVersions"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">📊 Scores par Critère</div>
<canvas id="chartCriteria"></canvas>
</div>
<div class="chart-container" style="grid-column: span 2;">
<div class="chart-title">🎯 Vue Radar Multi-Critères</div>
<canvas id="chartRadar"></canvas>
</div>
</div>
<!-- Samples Details -->
<div class="panel">
<h2>🔍 Détails Échantillons</h2>
<div id="samplesContainer" class="samples-list"></div>
</div>
</div>
</div>
</div>
<script>
// ========================================
// GLOBAL STATE
// ========================================
let ws = null;
let activeValidations = new Map();
let currentPipelines = [];
let currentReport = null;
let currentEvaluations = null;
let charts = { versions: null, criteria: null, radar: null };
// ========================================
// INITIALIZATION
// ========================================
async function init() {
console.log('🚀 Initialisation...');
await loadPipelines();
await loadValidationsList();
connectWebSocket();
// Auto-refresh every 5s
setInterval(loadValidationsList, 5000);
}
// ========================================
// WEBSOCKET
// ========================================
function connectWebSocket() {
try {
ws = new WebSocket('ws://localhost:8081');
ws.onopen = () => {
console.log('✅ WebSocket connecté');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'validation_progress') {
updateValidationProgress(data);
}
} catch (error) {
console.error('❌ Erreur parsing WebSocket:', error);
}
};
ws.onerror = (error) => {
console.error('❌ WebSocket erreur:', error);
};
ws.onclose = () => {
console.log('🔌 WebSocket déconnecté, reconnexion dans 5s...');
setTimeout(connectWebSocket, 5000);
};
} catch (error) {
console.error('❌ Erreur connexion WebSocket:', error);
setTimeout(connectWebSocket, 5000);
}
}
function updateValidationProgress(data) {
const card = document.querySelector(`[data-validation-id="${data.validationId}"]`);
if (!card) return;
const progressBar = card.querySelector('.progress-fill');
const progressText = card.querySelector('.progress-text');
if (progressBar) {
progressBar.style.width = `${data.progress.percentage}%`;
}
if (progressText) {
progressText.textContent = `${data.progress.phase}: ${data.progress.message}`;
}
}
// ========================================
// TABS
// ========================================
function switchTab(tabName) {
// Update buttons
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
// Update content
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.getElementById(`tab-${tabName}`).classList.add('active');
// Load data if needed
if (tabName === 'active') {
loadValidationsList();
} else if (tabName === 'results') {
populateValidationSelect();
}
}
// ========================================
// TAB 1: NEW VALIDATION
// ========================================
async function loadPipelines() {
try {
const response = await fetch('/api/pipeline/list');
const data = await response.json();
if (data.success && data.pipelines) {
currentPipelines = data.pipelines;
const select = document.getElementById('pipelineSelect');
data.pipelines.forEach(pipeline => {
const option = document.createElement('option');
option.value = pipeline.filename;
option.textContent = pipeline.name || pipeline.filename;
select.appendChild(option);
});
}
} catch (error) {
console.error('❌ Erreur chargement pipelines:', error);
}
}
async function loadPipelinePreview() {
const select = document.getElementById('pipelineSelect');
const filename = select.value;
const btnStart = document.getElementById('btnStart');
if (!filename || filename === 'undefined' || filename === '') {
document.getElementById('pipelinePreview').classList.remove('visible');
btnStart.disabled = true;
return;
}
try {
const response = await fetch(`/api/pipeline/load?filename=${encodeURIComponent(filename)}`);
const data = await response.json();
if (data.success && data.pipeline) {
const pipeline = data.pipeline;
document.getElementById('previewName').textContent = pipeline.name || 'Sans nom';
document.getElementById('previewDesc').textContent = pipeline.description || 'Aucune description';
document.getElementById('previewSteps').textContent = pipeline.pipeline.length;
// Estimate duration (rough estimate)
const estimatedMinutes = pipeline.pipeline.length * 1.5;
document.getElementById('previewDuration').textContent = `~${estimatedMinutes.toFixed(0)}min`;
document.getElementById('pipelinePreview').classList.add('visible');
btnStart.disabled = false;
}
} catch (error) {
console.error('❌ Erreur chargement preview:', error);
}
}
async function startValidation() {
const pipelineFilename = document.getElementById('pipelineSelect').value;
const rowNumber = parseInt(document.getElementById('rowNumber').value);
if (!pipelineFilename) {
alert('Veuillez sélectionner un pipeline');
return;
}
// ✅ NOUVEAU: Récupérer le preset sélectionné
const selectedPreset = document.querySelector('input[name="validationPreset"]:checked')?.value || 'economical';
try {
// Load pipeline config
const pipelineResponse = await fetch(`/api/pipeline/load?filename=${encodeURIComponent(pipelineFilename)}`);
const pipelineData = await pipelineResponse.json();
if (!pipelineData.success) {
throw new Error('Impossible de charger le pipeline');
}
// Start validation with preset
const response = await fetch('/api/validation/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pipelineConfig: pipelineData.pipeline,
rowNumber,
config: selectedPreset // ✅ Passer le preset au lieu d'objet vide
})
});
const data = await response.json();
if (data.success) {
console.log('✅ Validation démarrée:', data.data.validationId);
// Switch to active tab
document.querySelectorAll('.tab-btn')[1].click();
// Reload list
setTimeout(loadValidationsList, 1000);
} else {
alert(`Erreur: ${data.error}`);
}
} catch (error) {
console.error('❌ Erreur démarrage validation:', error);
alert(`Erreur: ${error.message}`);
}
}
// ========================================
// TAB 2: ACTIVE VALIDATIONS
// ========================================
async function loadValidationsList() {
try {
const response = await fetch('/api/validation/list?limit=100');
const data = await response.json();
if (data.success) {
const active = data.data.validations.filter(v => v.status === 'running');
const history = data.data.validations.filter(v => v.status !== 'running');
renderActiveValidations(active);
renderHistoryValidations(history);
// Update count badge
document.getElementById('activeCount').textContent = `(${active.length})`;
}
} catch (error) {
console.error('❌ Erreur chargement liste:', error);
}
}
function renderActiveValidations(validations) {
const container = document.getElementById('activeValidations');
if (validations.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">💤</div>
<p>Aucune validation active</p>
</div>
`;
return;
}
container.innerHTML = validations.map(v => `
<div class="validation-card" data-validation-id="${v.validationId}">
<div class="validation-header">
<div>
<div class="validation-title">${v.pipelineName || 'Sans nom'}</div>
<div class="validation-id">${v.validationId}</div>
</div>
<div class="validation-actions">
<button class="btn-small btn-view" onclick="viewValidation('${v.validationId}')">
👁️ Voir
</button>
<button class="btn-small btn-stop" onclick="stopValidation('${v.validationId}')">
⏹️ Stop
</button>
</div>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${v.progress?.percentage || 0}%"></div>
</div>
<div class="progress-text">${v.progress?.message || 'Démarrage...'}</div>
<div class="validation-meta">
<div class="meta-item">
<div class="meta-label">Phase</div>
<div class="meta-value">${v.progress?.phase || 'init'}</div>
</div>
<div class="meta-item">
<div class="meta-label">Durée</div>
<div class="meta-value">${formatDuration(v.duration)}</div>
</div>
<div class="meta-item">
<div class="meta-label">Statut</div>
<div class="meta-value">
<span class="status-badge status-${v.status}">${v.status}</span>
</div>
</div>
</div>
</div>
`).join('');
}
function renderHistoryValidations(validations) {
const container = document.getElementById('historyValidations');
if (validations.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<p>Aucune validation terminée</p>
</div>
`;
return;
}
container.innerHTML = validations.map(v => `
<div class="validation-card ${v.status}">
<div class="validation-header">
<div>
<div class="validation-title">${v.pipelineName || 'Sans nom'}</div>
<div class="validation-id">${v.validationId}</div>
</div>
<div class="validation-actions">
<button class="btn-small btn-view" onclick="viewValidation('${v.validationId}')">
📊 Voir Résultats
</button>
</div>
</div>
<div class="validation-meta">
<div class="meta-item">
<div class="meta-label">Statut</div>
<div class="meta-value">
<span class="status-badge status-${v.status}">${v.status}</span>
</div>
</div>
<div class="meta-item">
<div class="meta-label">Durée</div>
<div class="meta-value">${formatDuration(v.duration)}</div>
</div>
<div class="meta-item">
<div class="meta-label">Date</div>
<div class="meta-value">${new Date(v.endTime).toLocaleString('fr-FR')}</div>
</div>
</div>
</div>
`).join('');
}
async function stopValidation(validationId) {
if (!confirm('Arrêter cette validation ?')) return;
try {
const response = await fetch(`/api/validation/stop/${validationId}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
console.log('✅ Validation arrêtée');
loadValidationsList();
} else {
alert(`Erreur: ${data.error}`);
}
} catch (error) {
console.error('❌ Erreur arrêt validation:', error);
alert(`Erreur: ${error.message}`);
}
}
function viewValidation(validationId) {
// Set select value and load results
const select = document.getElementById('validationSelect');
select.value = validationId;
// Switch to results tab
document.querySelectorAll('.tab-btn')[2].click();
// Load results
loadValidationResults();
}
// ========================================
// TAB 3: RESULTS & DETAILS
// ========================================
async function populateValidationSelect() {
try {
const response = await fetch('/api/validation/list?limit=100');
const data = await response.json();
if (data.success) {
const select = document.getElementById('validationSelect');
// Keep current selection
const currentValue = select.value;
// Clear and repopulate
select.innerHTML = '<option value="">-- Sélectionner --</option>';
data.data.validations
.filter(v => v.status === 'completed')
.forEach(v => {
const option = document.createElement('option');
option.value = v.validationId;
option.textContent = `${v.pipelineName || 'Sans nom'} - ${new Date(v.endTime).toLocaleString('fr-FR')}`;
select.appendChild(option);
});
// Restore selection if exists
if (currentValue) {
select.value = currentValue;
}
}
} catch (error) {
console.error('❌ Erreur population select:', error);
}
}
async function loadValidationResults() {
const validationId = document.getElementById('validationSelect').value;
if (!validationId) {
document.getElementById('resultsContainer').style.display = 'none';
return;
}
try {
// Load report and evaluations in parallel
const [reportResponse, evalResponse] = await Promise.all([
fetch(`/api/validation/${validationId}/report`),
fetch(`/api/validation/${validationId}/evaluations`)
]);
const reportData = await reportResponse.json();
const evalData = await evalResponse.json();
if (reportData.success && evalData.success) {
currentReport = reportData.data;
currentEvaluations = evalData.data;
renderSummary();
renderCharts();
renderSamples();
document.getElementById('resultsContainer').style.display = 'block';
} else {
alert('Erreur chargement résultats');
}
} catch (error) {
console.error('❌ Erreur chargement résultats:', error);
alert(`Erreur: ${error.message}`);
}
}
function renderSummary() {
const evals = currentReport.evaluations;
document.getElementById('summaryScore').textContent = evals.overallScore.toFixed(1);
// Calculate delta
const versions = Object.keys(evals.byVersion).sort();
const firstVersion = evals.byVersion[versions[0]];
const lastVersion = evals.byVersion[versions[versions.length - 1]];
const delta = lastVersion.avgScore - firstVersion.avgScore;
const deltaEl = document.getElementById('summaryDelta');
deltaEl.textContent = `${delta > 0 ? '+' : ''}${delta.toFixed(1)}`;
deltaEl.style.color = delta >= 0 ? 'var(--success)' : 'var(--error)';
document.getElementById('summaryTotal').textContent = evals.totalEvaluations;
document.getElementById('summaryDurationResult').textContent = formatDuration(currentReport.pipeline.totalDuration);
}
function renderCharts() {
renderVersionsChart();
renderCriteriaChart();
renderRadarChart();
}
function renderVersionsChart() {
const ctx = document.getElementById('chartVersions');
if (charts.versions) {
charts.versions.destroy();
}
const versions = Object.keys(currentReport.evaluations.byVersion).sort();
const scores = versions.map(v => currentReport.evaluations.byVersion[v].avgScore);
charts.versions = new Chart(ctx, {
type: 'line',
data: {
labels: versions,
datasets: [{
label: 'Score Global',
data: scores,
borderColor: 'rgb(102, 126, 234)',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
max: 10
}
}
}
});
}
function renderCriteriaChart() {
const ctx = document.getElementById('chartCriteria');
if (charts.criteria) {
charts.criteria.destroy();
}
const criteria = Object.keys(currentReport.evaluations.byCriteria);
const scores = criteria.map(c => currentReport.evaluations.byCriteria[c].avgScore);
charts.criteria = new Chart(ctx, {
type: 'bar',
data: {
labels: criteria.map(c => c.charAt(0).toUpperCase() + c.slice(1)),
datasets: [{
label: 'Score Moyen',
data: scores,
backgroundColor: [
'rgba(102, 126, 234, 0.8)',
'rgba(118, 75, 162, 0.8)',
'rgba(72, 187, 120, 0.8)',
'rgba(237, 137, 54, 0.8)',
'rgba(245, 101, 101, 0.8)'
]
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
max: 10
}
}
}
});
}
function renderRadarChart() {
const ctx = document.getElementById('chartRadar');
if (charts.radar) {
charts.radar.destroy();
}
const criteria = Object.keys(currentReport.evaluations.byCriteria);
const versions = Object.keys(currentReport.evaluations.byVersion).sort();
// Get first and last version for comparison
const firstVersion = versions[0];
const lastVersion = versions[versions.length - 1];
const datasets = [
{
label: firstVersion,
data: criteria.map(c => {
const evalsByCriteria = currentEvaluations.aggregated.byCriteria[c];
return evalsByCriteria.byVersion[firstVersion]?.avgScore || 0;
}),
borderColor: 'rgba(237, 137, 54, 0.8)',
backgroundColor: 'rgba(237, 137, 54, 0.2)'
},
{
label: lastVersion,
data: criteria.map(c => {
const evalsByCriteria = currentEvaluations.aggregated.byCriteria[c];
return evalsByCriteria.byVersion[lastVersion]?.avgScore || 0;
}),
borderColor: 'rgba(72, 187, 120, 0.8)',
backgroundColor: 'rgba(72, 187, 120, 0.2)'
}
];
charts.radar = new Chart(ctx, {
type: 'radar',
data: {
labels: criteria.map(c => c.charAt(0).toUpperCase() + c.slice(1)),
datasets
},
options: {
responsive: true,
scales: {
r: {
beginAtZero: true,
max: 10
}
}
}
});
}
function renderSamples() {
const container = document.getElementById('samplesContainer');
const evaluations = currentEvaluations.evaluations;
const samples = Object.keys(evaluations).map(tag => {
const sampleEvals = evaluations[tag];
const criteria = Object.keys(sampleEvals);
// Calculate average score for this sample
let totalScore = 0;
let count = 0;
criteria.forEach(criterion => {
Object.values(sampleEvals[criterion]).forEach(versionData => {
totalScore += versionData.score;
count++;
});
});
const avgScore = totalScore / count;
return { tag, evaluations: sampleEvals, avgScore };
});
container.innerHTML = samples.map(sample => {
const scoreClass = sample.avgScore >= 7 ? 'score-high' : sample.avgScore >= 5 ? 'score-medium' : 'score-low';
return `
<div class="sample-card">
<div class="sample-header">
<span class="sample-tag">${sample.tag}</span>
<span class="score-badge ${scoreClass}">${sample.avgScore.toFixed(1)}/10</span>
</div>
<div class="sample-scores">
${Object.keys(sample.evaluations).map(criterion => {
const criterionData = sample.evaluations[criterion];
const versions = Object.keys(criterionData).sort();
const lastVersion = versions[versions.length - 1];
const score = criterionData[lastVersion].score;
return `<div class="criterion-score">${criterion}: ${score}/10</div>`;
}).join('')}
</div>
${Object.keys(sample.evaluations).map(criterion => {
const versions = Object.keys(sample.evaluations[criterion]).sort();
const lastVersion = versions[versions.length - 1];
const reasoning = sample.evaluations[criterion][lastVersion].reasoning;
return `
<div class="accordion" onclick="toggleAccordion(this)">
📝 ${criterion} - Reasoning (${lastVersion})
</div>
<div class="accordion-content">
${reasoning}
</div>
`;
}).join('')}
</div>
`;
}).join('');
}
function toggleAccordion(element) {
const content = element.nextElementSibling;
content.classList.toggle('active');
}
function exportReport() {
const dataStr = JSON.stringify(currentReport, null, 2);
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
const exportFileDefaultName = `validation-report-${currentReport.validationId}.json`;
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
}
// ========================================
// UTILITIES
// ========================================
function formatDuration(ms) {
if (!ms) return '-';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
// ========================================
// STARTUP
// ========================================
window.onload = init;
</script>
</body>
</html>