890 lines
39 KiB
HTML
890 lines
39 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>RPA Vision V3 - Base de connaissances</title>
|
|
<style>
|
|
/* === Reset & base — identique au dashboard === */
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; }
|
|
|
|
/* === Header === */
|
|
.header { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: white; padding: 20px 30px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
|
|
.header h1 { font-size: 24px; display: flex; align-items: center; gap: 10px; }
|
|
.header-subtitle { color: rgba(255,255,255,0.75); font-size: 13px; margin-top: 4px; }
|
|
.header-nav { display: flex; align-items: center; gap: 8px; }
|
|
.header-nav a {
|
|
color: rgba(255,255,255,0.8); text-decoration: none; font-size: 13px;
|
|
padding: 6px 14px; border-radius: 6px; transition: all 0.2s;
|
|
background: rgba(255,255,255,0.1);
|
|
}
|
|
.header-nav a:hover { background: rgba(255,255,255,0.2); }
|
|
.header-nav a.active { background: rgba(255,255,255,0.25); color: #fff; font-weight: 600; }
|
|
|
|
/* === Layout === */
|
|
.container { max-width: 1600px; margin: 0 auto; padding: 20px; }
|
|
|
|
/* === Cards === */
|
|
.card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; margin-bottom: 20px; }
|
|
.card h2 { font-size: 16px; margin-bottom: 15px; color: #94a3b8; display: flex; align-items: center; gap: 8px; }
|
|
|
|
/* === Grille indicateurs === */
|
|
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 20px; }
|
|
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 20px; }
|
|
@media (max-width: 900px) { .grid-3, .grid-4 { grid-template-columns: repeat(2, 1fr); } }
|
|
@media (max-width: 500px) { .grid-3, .grid-4 { grid-template-columns: 1fr; } }
|
|
.stat-card { text-align: center; }
|
|
.stat-value { font-size: 36px; font-weight: bold; color: #3b82f6; }
|
|
.stat-value.warning { color: #f59e0b; }
|
|
.stat-value.success { color: #22c55e; }
|
|
.stat-label { font-size: 12px; color: #64748b; margin-top: 5px; text-transform: uppercase; }
|
|
|
|
/* === Section title === */
|
|
.section-title {
|
|
font-size: 18px; font-weight: 600; color: #e2e8f0;
|
|
margin-bottom: 15px; display: flex; align-items: center; gap: 10px;
|
|
padding-bottom: 10px; border-bottom: 1px solid #334155;
|
|
}
|
|
.section-title .icon { font-size: 22px; }
|
|
|
|
/* === Alertes === */
|
|
.alert {
|
|
padding: 12px 16px; border-radius: 8px; font-size: 13px;
|
|
margin-bottom: 15px; display: flex; align-items: center; gap: 10px;
|
|
}
|
|
.alert-warning {
|
|
background: #78350f; color: #fcd34d; border: 1px solid #92400e;
|
|
}
|
|
.alert-info {
|
|
background: #1e3a5f; color: #93c5fd; border: 1px solid #1e40af;
|
|
}
|
|
.alert-success {
|
|
background: #064e3b; color: #6ee7b7; border: 1px solid #065f46;
|
|
}
|
|
|
|
/* === Tableau === */
|
|
.table-wrapper { overflow-x: auto; margin-top: 15px; }
|
|
.kb-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.kb-table thead th {
|
|
background: #334155; color: #94a3b8; padding: 12px 10px;
|
|
text-align: left; font-weight: 600; font-size: 11px;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
white-space: nowrap;
|
|
}
|
|
.kb-table tbody tr { border-bottom: 1px solid #1e293b; transition: background 0.15s; }
|
|
.kb-table tbody tr:hover { background: #334155; }
|
|
.kb-table td { padding: 10px; vertical-align: middle; }
|
|
|
|
/* === Badges catégories === */
|
|
.category-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 10px; }
|
|
.category-item {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
padding: 8px 14px; background: #0f172a; border-radius: 8px;
|
|
border: 1px solid #334155; font-size: 13px;
|
|
}
|
|
.category-count {
|
|
background: #3b82f6; color: white; border-radius: 50%;
|
|
width: 24px; height: 24px; display: flex; align-items: center;
|
|
justify-content: center; font-size: 11px; font-weight: 700;
|
|
}
|
|
|
|
.state-badge {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
min-width: 78px; padding: 4px 8px; border-radius: 6px;
|
|
border: 1px solid #334155; background: #0f172a;
|
|
color: #cbd5e1; font-size: 11px; font-weight: 700;
|
|
text-transform: uppercase;
|
|
}
|
|
.state-badge.observed { color: #fbbf24; border-color: #92400e; }
|
|
.state-badge.candidate { color: #93c5fd; border-color: #1d4ed8; }
|
|
.state-badge.stable { color: #6ee7b7; border-color: #047857; }
|
|
.verdict-strip { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.verdict-pill {
|
|
display: inline-flex; gap: 5px; align-items: center;
|
|
padding: 4px 7px; border-radius: 6px; background: #0f172a;
|
|
border: 1px solid #334155; font-size: 12px;
|
|
}
|
|
.verdict-pill.valid { color: #6ee7b7; }
|
|
.verdict-pill.invalid { color: #fca5a5; }
|
|
.verdict-pill.inconclusive { color: #fcd34d; }
|
|
.action-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
.action-btn {
|
|
border: 1px solid #2563eb; background: #1d4ed8; color: #fff;
|
|
border-radius: 6px; padding: 7px 10px; font-size: 12px;
|
|
font-weight: 700; cursor: pointer; min-height: 32px;
|
|
}
|
|
.action-btn:hover:not(:disabled) { background: #2563eb; }
|
|
.action-btn:disabled {
|
|
cursor: not-allowed; opacity: 0.45; border-color: #475569;
|
|
background: #1e293b; color: #94a3b8;
|
|
}
|
|
.modal-backdrop {
|
|
display: none; position: fixed; inset: 0; z-index: 50;
|
|
background: rgba(15,23,42,0.82); padding: 24px;
|
|
align-items: center; justify-content: center;
|
|
}
|
|
.modal-backdrop.visible { display: flex; }
|
|
.modal {
|
|
width: min(980px, 96vw); max-height: 90vh; overflow: auto;
|
|
background: #1e293b; border: 1px solid #475569;
|
|
border-radius: 8px; box-shadow: 0 24px 70px rgba(0,0,0,0.35);
|
|
}
|
|
.modal-header {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 16px 18px; border-bottom: 1px solid #334155;
|
|
}
|
|
.modal-title { font-size: 17px; font-weight: 700; color: #f8fafc; }
|
|
.modal-close {
|
|
border: 1px solid #475569; background: #0f172a; color: #cbd5e1;
|
|
border-radius: 6px; padding: 5px 8px; cursor: pointer;
|
|
}
|
|
.modal-body { padding: 18px; }
|
|
.diff-box {
|
|
white-space: pre-wrap; overflow-x: auto; background: #020617;
|
|
border: 1px solid #334155; border-radius: 6px; padding: 12px;
|
|
color: #cbd5e1; font-size: 12px; line-height: 1.4;
|
|
max-height: 320px;
|
|
}
|
|
.evidence-list { margin: 10px 0 16px; color: #cbd5e1; font-size: 13px; }
|
|
.modal-actions {
|
|
display: flex; justify-content: flex-end; gap: 10px;
|
|
padding: 14px 18px; border-top: 1px solid #334155;
|
|
}
|
|
.secondary-btn {
|
|
border: 1px solid #475569; background: #0f172a; color: #cbd5e1;
|
|
border-radius: 6px; padding: 8px 12px; cursor: pointer;
|
|
}
|
|
.danger-note { color: #fcd34d; font-size: 12px; margin-top: 8px; }
|
|
|
|
/* === Loading === */
|
|
.loading {
|
|
text-align: center; padding: 40px; color: #64748b; font-size: 14px;
|
|
}
|
|
.loading::after {
|
|
content: ''; display: inline-block; width: 20px; height: 20px;
|
|
border: 2px solid #334155; border-top-color: #3b82f6;
|
|
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
margin-left: 10px; vertical-align: middle;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<div>
|
|
<h1>📚 Base de connaissances</h1>
|
|
<div class="header-subtitle">État de la mémoire et des apprentissages de Léa</div>
|
|
</div>
|
|
<nav class="header-nav">
|
|
<a href="/">🎛️ Dashboard</a>
|
|
<a href="/audit">⚖️ Audit</a>
|
|
<a href="/process-mining">🗺️ Cartographie</a>
|
|
<a href="/knowledge-base" class="active">📚 Connaissances</a>
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="container">
|
|
|
|
<!-- Section 1 : Mémoire visuelle (FAISS) -->
|
|
<div class="section-title"><span class="icon">🧠</span> Mémoire visuelle</div>
|
|
|
|
<div class="grid-3" id="faissGrid">
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="faissVectors">--</div>
|
|
<div class="stat-label">Vecteurs indexés</div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="faissEmbeddings">--</div>
|
|
<div class="stat-label">Embeddings calculés</div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="faissSize">--</div>
|
|
<div class="stat-label">Taille de l'index</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="faissAlert" style="display:none;"></div>
|
|
|
|
<!-- Section 2 : Sessions observées -->
|
|
<div class="section-title"><span class="icon">👁️</span> Sessions observées</div>
|
|
|
|
<div class="grid-3" id="sessionsGrid">
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="sessionCount">--</div>
|
|
<div class="stat-label">Sessions totales</div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="sessionVolume">--</div>
|
|
<div class="stat-label">Volume de données</div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="machineCount">--</div>
|
|
<div class="stat-label">Machines distinctes</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" id="machinesCard">
|
|
<h2><span class="icon">🖥️</span> Répartition par machine</h2>
|
|
<div class="table-wrapper">
|
|
<table class="kb-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Machine</th>
|
|
<th>Sessions</th>
|
|
<th>Dernière activité</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="machinesTableBody">
|
|
<tr><td colspan="3" class="loading">Chargement...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section 3 : Réflexes natifs (patterns UI) -->
|
|
<div class="section-title"><span class="icon">⚡</span> Réflexes natifs</div>
|
|
|
|
<div class="grid-3" id="patternsGrid">
|
|
<div class="card stat-card" style="grid-column: span 1;">
|
|
<div class="stat-value success" id="patternTotal">--</div>
|
|
<div class="stat-label">Patterns connus</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" id="patternsCard">
|
|
<h2><span class="icon">🎯</span> Par catégorie</h2>
|
|
<ul class="category-list" id="categoryList">
|
|
<li class="loading">Chargement...</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Section 4 : Compétences apprises par supervision -->
|
|
<div class="section-title"><span class="icon">🧪</span> Compétences apprises par supervision</div>
|
|
|
|
<div class="grid-3" id="competencesGrid">
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="competenceTotal">--</div>
|
|
<div class="stat-label">Compétences YAML</div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-value success" id="competenceCandidate">--</div>
|
|
<div class="stat-label">Candidates</div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-value warning" id="competenceObserved">--</div>
|
|
<div class="stat-label">Observées</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" id="competencesCard">
|
|
<h2><span class="icon">📌</span> Promotions supervisées</h2>
|
|
<div class="alert alert-info">
|
|
Les boutons ci-dessous font un dry-run, affichent le diff YAML, puis demandent confirmation. Aucun write-back silencieux.
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<table class="kb-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Compétence</th>
|
|
<th>État</th>
|
|
<th>Verdicts</th>
|
|
<th>Contextes</th>
|
|
<th>Test / Promotion</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="competencesTableBody">
|
|
<tr><td colspan="5" class="loading">Chargement...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section 5 : Workflows -->
|
|
<div class="section-title"><span class="icon">🔄</span> Workflows</div>
|
|
|
|
<div class="grid-3" id="workflowsGrid">
|
|
<div class="card stat-card">
|
|
<div class="stat-value" id="workflowCount">--</div>
|
|
<div class="stat-label">Workflows appris</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="modal-backdrop" id="promotionModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<div class="modal-title" id="promotionTitle">Promotion supervisée</div>
|
|
<button class="modal-close" onclick="closePromotionModal()">Fermer</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="promotionStatus"></div>
|
|
<h2 style="font-size:14px;margin:14px 0 8px;color:#94a3b8;">Verdicts utilisés</h2>
|
|
<div class="evidence-list" id="promotionEvidence">--</div>
|
|
<h2 style="font-size:14px;margin:14px 0 8px;color:#94a3b8;">Diff YAML</h2>
|
|
<pre class="diff-box" id="promotionDiff">Chargement...</pre>
|
|
<div class="danger-note">Le write-back ne s'exécute qu'après confirmation explicite.</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="secondary-btn" onclick="closePromotionModal()">Annuler</button>
|
|
<button class="action-btn" id="confirmPromotionButton" onclick="confirmPromotion()" disabled>Confirmer la promotion</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-backdrop" id="testModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<div class="modal-title" id="testTitle">Test supervisé</div>
|
|
<button class="modal-close" onclick="closeTestModal()">Fermer</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="testStatus"></div>
|
|
<h2 style="font-size:14px;margin:14px 0 8px;color:#94a3b8;">Progression</h2>
|
|
<div class="evidence-list" id="testProgress">--</div>
|
|
<h2 style="font-size:14px;margin:14px 0 8px;color:#94a3b8;">État replay</h2>
|
|
<pre class="diff-box" id="testEvidence">En attente...</pre>
|
|
<div class="danger-note">Le verdict est journalisé séparément. Aucun YAML n'est modifié par ce test.</div>
|
|
</div>
|
|
<div class="modal-actions" id="testActions">
|
|
<button class="secondary-btn" onclick="closeTestModal()">Fermer</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', loadKnowledgeBase);
|
|
|
|
let knowledgeBaseData = null;
|
|
let currentPromotion = null;
|
|
let currentTest = null;
|
|
let testPollTimer = null;
|
|
|
|
async function loadKnowledgeBase() {
|
|
try {
|
|
const resp = await fetch('/api/knowledge-base/stats');
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
renderFaiss(data.faiss);
|
|
renderSessions(data.sessions);
|
|
renderPatterns(data.patterns);
|
|
renderCompetences(data.competences);
|
|
renderWorkflows(data.workflows);
|
|
knowledgeBaseData = data;
|
|
} catch (err) {
|
|
console.error('Erreur chargement base de connaissances:', err);
|
|
}
|
|
}
|
|
|
|
function renderFaiss(faiss) {
|
|
document.getElementById('faissVectors').textContent = faiss.vectors_indexed.toLocaleString('fr-FR');
|
|
document.getElementById('faissEmbeddings').textContent = faiss.embeddings_computed.toLocaleString('fr-FR');
|
|
document.getElementById('faissSize').textContent = faiss.index_size_mb;
|
|
|
|
// Alerte si embeddings non consolidés
|
|
const diff = faiss.embeddings_computed - faiss.vectors_indexed;
|
|
const alertEl = document.getElementById('faissAlert');
|
|
if (diff > 10) {
|
|
alertEl.style.display = 'block';
|
|
alertEl.innerHTML = `<div class="alert alert-warning">⚠️ ${diff.toLocaleString('fr-FR')} embeddings non consolidés dans l'index FAISS</div>`;
|
|
} else if (faiss.vectors_indexed === 0 && !faiss.available) {
|
|
alertEl.style.display = 'block';
|
|
alertEl.innerHTML = `<div class="alert alert-info">ℹ️ FAISS non disponible sur ce système</div>`;
|
|
} else {
|
|
alertEl.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function renderSessions(sessions) {
|
|
document.getElementById('sessionCount').textContent = sessions.total_sessions.toLocaleString('fr-FR');
|
|
document.getElementById('sessionVolume').textContent = sessions.total_volume;
|
|
document.getElementById('machineCount').textContent = sessions.machines.length.toLocaleString('fr-FR');
|
|
|
|
const tbody = document.getElementById('machinesTableBody');
|
|
if (sessions.machines.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="3" style="color:#64748b;text-align:center;padding:20px;">Aucune session enregistrée</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = sessions.machines.map(m => `
|
|
<tr>
|
|
<td><strong>${escapeHtml(m.machine_id)}</strong></td>
|
|
<td>${m.session_count}</td>
|
|
<td style="color:#94a3b8;font-size:12px;">${m.last_activity || '—'}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function renderPatterns(patterns) {
|
|
document.getElementById('patternTotal').textContent = patterns.total.toLocaleString('fr-FR');
|
|
|
|
const list = document.getElementById('categoryList');
|
|
const cats = patterns.by_category;
|
|
if (!cats || Object.keys(cats).length === 0) {
|
|
list.innerHTML = '<li style="color:#64748b;">Aucun pattern chargé</li>';
|
|
return;
|
|
}
|
|
list.innerHTML = Object.entries(cats)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([cat, count]) => `
|
|
<li class="category-item">
|
|
<span class="category-count">${count}</span>
|
|
<span>${escapeHtml(cat)}</span>
|
|
</li>
|
|
`).join('');
|
|
}
|
|
|
|
function renderWorkflows(workflows) {
|
|
document.getElementById('workflowCount').textContent = workflows.total.toLocaleString('fr-FR');
|
|
}
|
|
|
|
function renderCompetences(competences) {
|
|
const items = competences?.items || [];
|
|
const byState = competences?.by_state || {};
|
|
document.getElementById('competenceTotal').textContent = (competences?.total || 0).toLocaleString('fr-FR');
|
|
document.getElementById('competenceCandidate').textContent = (byState.candidate || 0).toLocaleString('fr-FR');
|
|
document.getElementById('competenceObserved').textContent = (byState.observed || 0).toLocaleString('fr-FR');
|
|
|
|
const tbody = document.getElementById('competencesTableBody');
|
|
if (items.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" style="color:#64748b;text-align:center;padding:20px;">Aucune compétence YAML chargée</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = items.map(item => {
|
|
const targetEntries = Object.entries(item.eligible_targets || {});
|
|
const testButton = `<button class="secondary-btn" title="Lancer un replay supervisé sans CLI" onclick="openCompetenceTest('${escapeAttr(item.id)}')">Tester</button>`;
|
|
const buttons = targetEntries.length === 0
|
|
? '<span style="color:#64748b;font-size:12px;">Aucune promotion disponible</span>'
|
|
: targetEntries.map(([target, info]) => {
|
|
const title = (info.blocking_reasons || []).join(' · ') || `Promouvoir vers ${target}`;
|
|
const verdictIds = JSON.stringify(info.recommended_verdict_ids || []).replace(/"/g, '"');
|
|
return `<button class="action-btn" ${info.eligible ? '' : 'disabled'} title="${escapeHtml(title)}" onclick="openPromotion('${escapeAttr(item.id)}','${escapeAttr(target)}',${verdictIds})">Promouvoir ${escapeHtml(target)}</button>`;
|
|
}).join('');
|
|
|
|
const counts = item.verdict_counts || {};
|
|
const warning = item.regression_suspected
|
|
? '<div class="alert alert-warning" style="margin:8px 0 0;padding:7px 9px;">Régression suspectée</div>'
|
|
: '';
|
|
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<strong>${escapeHtml(item.id)}</strong>
|
|
<div style="color:#94a3b8;font-size:12px;margin-top:4px;">${escapeHtml(item.intent_fr || item.name || '')}</div>
|
|
${warning}
|
|
</td>
|
|
<td><span class="state-badge ${escapeAttr(item.learning_state)}">${escapeHtml(item.learning_state)}</span></td>
|
|
<td>
|
|
<div class="verdict-strip">
|
|
<span class="verdict-pill valid">${counts.valid || 0} valid</span>
|
|
<span class="verdict-pill invalid">${counts.invalid || 0} invalid</span>
|
|
<span class="verdict-pill inconclusive">${counts.inconclusive || 0} incertain</span>
|
|
</div>
|
|
</td>
|
|
<td>${item.distinct_contexts || 0}</td>
|
|
<td><div class="action-row">${testButton}${buttons}</div></td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function openCompetenceTest(competenceId) {
|
|
if (!confirmRunDialogReplay(competenceId)) {
|
|
return;
|
|
}
|
|
clearTestPolling();
|
|
currentTest = { competenceId, replayId: null, lastState: null };
|
|
document.getElementById('testModal').classList.add('visible');
|
|
document.getElementById('testTitle').textContent = `Test supervisé ${competenceId}`;
|
|
document.getElementById('testStatus').innerHTML = '<div class="alert alert-info">Lancement du replay supervisé...</div>';
|
|
document.getElementById('testProgress').textContent = '--';
|
|
document.getElementById('testEvidence').textContent = 'Initialisation...';
|
|
document.getElementById('testActions').innerHTML = '<button class="secondary-btn" onclick="closeTestModal()">Fermer</button>';
|
|
|
|
try {
|
|
const resp = await fetch(`/api/v1/lea/competences/${encodeURIComponent(competenceId)}/replay`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
supervised: true,
|
|
start_replay: true,
|
|
}),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || data.success === false) {
|
|
throw new Error(data.error || data.detail || resp.statusText);
|
|
}
|
|
const replay = data.replay || data;
|
|
const replayId = replay.replay_id;
|
|
if (!replayId) {
|
|
throw new Error('Replay lancé mais replay_id absent');
|
|
}
|
|
currentTest.replayId = replayId;
|
|
document.getElementById('testStatus').innerHTML = '<div class="alert alert-success">Replay lancé. Attente du premier état...</div>';
|
|
startTestPolling();
|
|
} catch (err) {
|
|
document.getElementById('testStatus').innerHTML = `<div class="alert alert-warning">${escapeHtml(err.message || String(err))}</div>`;
|
|
document.getElementById('testActions').innerHTML = '<button class="secondary-btn" onclick="closeTestModal()">Fermer</button>';
|
|
}
|
|
}
|
|
|
|
function confirmRunDialogReplay(competenceId) {
|
|
if (!mayOpenRunDialog(competenceId)) return true;
|
|
return window.confirm(
|
|
"Ce test peut ouvrir Win+R / Exécuter. Si la fenêtre Exécuter est déjà ouverte, le replay peut produire un faux positif. Fermez-la ou vérifiez l'état du poste avant de continuer."
|
|
);
|
|
}
|
|
|
|
function mayOpenRunDialog(competenceId) {
|
|
const normalized = String(competenceId || '')
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase();
|
|
return normalized.includes('win_r')
|
|
|| normalized.includes('windows_r')
|
|
|| normalized.includes('executer')
|
|
|| normalized.includes('run_dialog');
|
|
}
|
|
|
|
function startTestPolling() {
|
|
clearTestPolling();
|
|
pollCompetenceTest();
|
|
testPollTimer = setInterval(pollCompetenceTest, 1000);
|
|
}
|
|
|
|
function clearTestPolling() {
|
|
if (testPollTimer) {
|
|
clearInterval(testPollTimer);
|
|
testPollTimer = null;
|
|
}
|
|
}
|
|
|
|
async function pollCompetenceTest() {
|
|
if (!currentTest?.replayId) return;
|
|
try {
|
|
const resp = await fetch(`/api/v1/lea/replays/${encodeURIComponent(currentTest.replayId)}`);
|
|
const state = await resp.json();
|
|
if (!resp.ok || state.success === false) {
|
|
throw new Error(state.error || state.detail || resp.statusText);
|
|
}
|
|
currentTest.lastState = state;
|
|
renderTestState(state);
|
|
if (['completed', 'error', 'cancelled'].includes(state.status)) {
|
|
clearTestPolling();
|
|
if (state.status === 'completed') {
|
|
setTimeout(loadKnowledgeBase, 500);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
document.getElementById('testStatus').innerHTML = `<div class="alert alert-warning">${escapeHtml(err.message || String(err))}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderTestState(state) {
|
|
const completed = state.completed_actions ?? state.actions_completed ?? 0;
|
|
const total = state.total_actions ?? '?';
|
|
const remaining = state.actions_remaining ?? '';
|
|
const failed = state.failed_action || {};
|
|
const phase = state.pause_phase || failed.phase || inferPausePhase(failed.action_id || '');
|
|
const stepResults = compactStepResults(state.step_results || state.results || []);
|
|
const evidenceAvailable = hasReplayEvidence(state, stepResults);
|
|
const progress = [
|
|
`Replay: ${state.replay_id || currentTest?.replayId || '-'}`,
|
|
`Machine: ${state.machine_id || '-'}`,
|
|
`Statut: ${state.status || '-'}`,
|
|
`Actions: ${completed}/${total}`,
|
|
remaining !== '' ? `Restantes: ${remaining}` : '',
|
|
].filter(Boolean).join(' · ');
|
|
document.getElementById('testProgress').textContent = progress;
|
|
|
|
document.getElementById('testEvidence').textContent = JSON.stringify({
|
|
replay_id: state.replay_id || currentTest?.replayId,
|
|
status: state.status,
|
|
workflow_id: state.workflow_id,
|
|
machine_id: state.machine_id,
|
|
pause_phase: phase,
|
|
message: state.message || state.pause_message,
|
|
failed_action: failed,
|
|
results_count: stepResults.length,
|
|
evidence_available: evidenceAvailable,
|
|
}, null, 2);
|
|
|
|
if (state.status === 'paused_need_help') {
|
|
const msg = escapeHtml(state.message || state.pause_message || 'Validation humaine requise');
|
|
if (phase === 'after' || state.verdict_required || failed.verdict_required) {
|
|
const evidenceWarning = evidenceAvailable
|
|
? ''
|
|
: '<div class="alert alert-warning">Verdict valide bloqué : aucune step_results ni evidence replay disponible. Choisir Incertain/Invalide ou relancer avec evidence.</div>';
|
|
const validDisabled = evidenceAvailable ? '' : 'disabled title="Aucune step_results ni evidence replay disponible"';
|
|
document.getElementById('testStatus').innerHTML = `<div class="alert alert-info">${msg}</div>${evidenceWarning}`;
|
|
document.getElementById('testActions').innerHTML = `
|
|
<button class="action-btn" ${validDisabled} onclick="submitCompetenceVerdict('valid')">Valide</button>
|
|
<button class="secondary-btn" onclick="submitCompetenceVerdict('inconclusive')">Incertain</button>
|
|
<button class="secondary-btn" onclick="submitCompetenceVerdict('invalid')">Invalide</button>
|
|
<button class="secondary-btn" onclick="closeTestModal()">Fermer</button>
|
|
`;
|
|
} else {
|
|
document.getElementById('testStatus').innerHTML = `<div class="alert alert-info">${msg}</div>`;
|
|
document.getElementById('testActions').innerHTML = `
|
|
<button class="action-btn" onclick="resumeCompetenceTest()">Continuer le test</button>
|
|
<button class="secondary-btn" onclick="closeTestModal()">Fermer</button>
|
|
`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (state.status === 'completed') {
|
|
document.getElementById('testStatus').innerHTML = '<div class="alert alert-success">Replay terminé. La base de connaissances est rafraîchie.</div>';
|
|
document.getElementById('testActions').innerHTML = '<button class="action-btn" onclick="closeTestModal(); loadKnowledgeBase();">Fermer</button>';
|
|
return;
|
|
}
|
|
|
|
if (['error', 'cancelled'].includes(state.status)) {
|
|
document.getElementById('testStatus').innerHTML = `<div class="alert alert-warning">Replay ${escapeHtml(state.status)}.</div>`;
|
|
document.getElementById('testActions').innerHTML = '<button class="secondary-btn" onclick="closeTestModal()">Fermer</button>';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('testStatus').innerHTML = '<div class="alert alert-info">Replay en cours...</div>';
|
|
document.getElementById('testActions').innerHTML = '<button class="secondary-btn" onclick="closeTestModal()">Fermer</button>';
|
|
}
|
|
|
|
function inferPausePhase(actionId) {
|
|
if (String(actionId).includes('_pause_after')) return 'after';
|
|
if (String(actionId).includes('_pause_before')) return 'before';
|
|
return '';
|
|
}
|
|
|
|
async function resumeCompetenceTest() {
|
|
if (!currentTest?.replayId) return;
|
|
document.getElementById('testStatus').innerHTML = '<div class="alert alert-info">Reprise du replay...</div>';
|
|
try {
|
|
const resp = await fetch(`/api/v1/lea/replays/${encodeURIComponent(currentTest.replayId)}/resume`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ acknowledged_check_ids: [] }),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || data.success === false) {
|
|
throw new Error(data.error || data.detail?.error || data.detail || resp.statusText);
|
|
}
|
|
startTestPolling();
|
|
} catch (err) {
|
|
document.getElementById('testStatus').innerHTML = `<div class="alert alert-warning">${escapeHtml(err.message || String(err))}</div>`;
|
|
}
|
|
}
|
|
|
|
async function submitCompetenceVerdict(kind) {
|
|
if (!currentTest?.competenceId || !currentTest?.replayId) return;
|
|
const state = currentTest.lastState || {};
|
|
const stepResults = compactStepResults(state.step_results || state.results || []);
|
|
if (kind === 'valid' && !hasReplayEvidence(state, stepResults)) {
|
|
document.getElementById('testStatus').innerHTML = '<div class="alert alert-warning">Verdict valide refusé : aucune step_results ni evidence replay disponible.</div>';
|
|
return;
|
|
}
|
|
const payload = {
|
|
verdict_id: newPromotionId(),
|
|
verdict_kind: kind,
|
|
verdict_by: 'human:dom',
|
|
workflow_id: state.workflow_id || currentTest.replayId,
|
|
step_results: stepResults,
|
|
context_signature: {
|
|
machine_id: state.machine_id || 'unknown_machine',
|
|
screen_state_initial: '',
|
|
screen_state_after_action: state.last_screenshot || '',
|
|
},
|
|
evidence: {
|
|
replay_id: currentTest.replayId,
|
|
replay_status: state.status || '',
|
|
actions_completed: state.completed_actions ?? state.actions_completed ?? 0,
|
|
total_actions: state.total_actions ?? '',
|
|
pause_phase: state.pause_phase || state.failed_action?.phase || '',
|
|
},
|
|
source: {
|
|
frontend: 'dashboard_knowledge_base',
|
|
workflow_id: state.workflow_id || currentTest.replayId,
|
|
replay_id: currentTest.replayId,
|
|
},
|
|
comments: `Verdict humain dashboard: ${kind}`,
|
|
write_back_enabled: false,
|
|
yaml_write: false,
|
|
};
|
|
|
|
try {
|
|
const resp = await fetch(`/api/v1/lea/competences/${encodeURIComponent(currentTest.competenceId)}/verdict`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) {
|
|
throw new Error(data.error || resp.statusText);
|
|
}
|
|
document.getElementById('testStatus').innerHTML = '<div class="alert alert-success">Verdict enregistré. Finalisation du replay...</div>';
|
|
await resumeCompetenceTest();
|
|
setTimeout(loadKnowledgeBase, 500);
|
|
} catch (err) {
|
|
document.getElementById('testStatus').innerHTML = `<div class="alert alert-warning">${escapeHtml(err.message || String(err))}</div>`;
|
|
}
|
|
}
|
|
|
|
function compactStepResults(results) {
|
|
if (!Array.isArray(results)) return [];
|
|
return results.map((item, index) => ({
|
|
step_id: item.step_id || item.action_id || `step_${index + 1}`,
|
|
action_type: item.action_type || item.type || item.action?.type || '',
|
|
status: item.status || item.result || (item.success === false ? 'failed' : 'success'),
|
|
error: item.error || item.message || '',
|
|
resolution_method: item.resolution_method || item.method || '',
|
|
}));
|
|
}
|
|
|
|
function hasReplayEvidence(state, stepResults) {
|
|
if (Array.isArray(stepResults) && stepResults.length > 0) return true;
|
|
return [
|
|
state.evidence,
|
|
state.evidence_summary,
|
|
state.artifacts,
|
|
state.screenshots,
|
|
state.last_screenshot,
|
|
state.screenshot,
|
|
state.trace_path,
|
|
state.events,
|
|
].some(hasMeaningfulEvidenceValue);
|
|
}
|
|
|
|
function hasMeaningfulEvidenceValue(value) {
|
|
if (value === null || value === undefined) return false;
|
|
if (Array.isArray(value)) return value.length > 0;
|
|
if (typeof value === 'object') return Object.keys(value).length > 0;
|
|
if (typeof value === 'string') return value.trim() !== '';
|
|
return Boolean(value);
|
|
}
|
|
|
|
function closeTestModal() {
|
|
clearTestPolling();
|
|
document.getElementById('testModal').classList.remove('visible');
|
|
currentTest = null;
|
|
}
|
|
|
|
async function openPromotion(competenceId, targetState, verdictIds) {
|
|
currentPromotion = null;
|
|
document.getElementById('promotionModal').classList.add('visible');
|
|
document.getElementById('promotionTitle').textContent = `Promotion ${competenceId} -> ${targetState}`;
|
|
document.getElementById('promotionStatus').innerHTML = '<div class="alert alert-info">Dry-run en cours...</div>';
|
|
document.getElementById('promotionEvidence').textContent = '--';
|
|
document.getElementById('promotionDiff').textContent = 'Chargement...';
|
|
document.getElementById('confirmPromotionButton').disabled = true;
|
|
|
|
const promotionId = newPromotionId();
|
|
const payload = {
|
|
promotion_id: promotionId,
|
|
target_state: targetState,
|
|
verdict_ids: verdictIds,
|
|
confirmed_by: 'human:dom',
|
|
dry_run: true,
|
|
};
|
|
|
|
try {
|
|
const resp = await fetch(`/api/v1/lea/competences/${encodeURIComponent(competenceId)}/promote`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || resp.statusText);
|
|
currentPromotion = {
|
|
competenceId,
|
|
payload: {
|
|
...payload,
|
|
dry_run: false,
|
|
dry_run_token: data.promotion.dry_run_token,
|
|
},
|
|
};
|
|
renderPromotionDryRun(data.promotion);
|
|
} catch (err) {
|
|
document.getElementById('promotionStatus').innerHTML = `<div class="alert alert-warning">${escapeHtml(err.message || String(err))}</div>`;
|
|
document.getElementById('promotionDiff').textContent = '';
|
|
}
|
|
}
|
|
|
|
function renderPromotionDryRun(promotion) {
|
|
const evidence = promotion.evidence_summary?.verdicts || [];
|
|
document.getElementById('promotionEvidence').innerHTML = evidence.length
|
|
? evidence.map(v => `
|
|
<div>
|
|
<strong>${escapeHtml(String(v.verdict_id || '').slice(0, 8))}</strong>
|
|
· ${escapeHtml(v.verdict_kind || '')}
|
|
· ${escapeHtml(v.machine_id || 'machine inconnue')}
|
|
· steps ${v.step_results_count || 0}
|
|
</div>
|
|
`).join('')
|
|
: '<span style="color:#64748b;">Aucun verdict utilisable</span>';
|
|
document.getElementById('promotionDiff').textContent = promotion.yaml_diff || '(aucun changement)';
|
|
if (promotion.eligible) {
|
|
document.getElementById('promotionStatus').innerHTML = "<div class=\"alert alert-success\">Dry-run OK. Le YAML n'a pas été modifié.</div>";
|
|
document.getElementById('confirmPromotionButton').disabled = false;
|
|
} else {
|
|
const reasons = (promotion.blocking_reasons || []).join(' · ');
|
|
document.getElementById('promotionStatus').innerHTML = `<div class="alert alert-warning">Promotion bloquée : ${escapeHtml(reasons)}</div>`;
|
|
document.getElementById('confirmPromotionButton').disabled = true;
|
|
}
|
|
}
|
|
|
|
async function confirmPromotion() {
|
|
if (!currentPromotion) return;
|
|
const button = document.getElementById('confirmPromotionButton');
|
|
button.disabled = true;
|
|
button.textContent = 'Promotion...';
|
|
try {
|
|
const resp = await fetch(`/api/v1/lea/competences/${encodeURIComponent(currentPromotion.competenceId)}/promote`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(currentPromotion.payload),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || resp.statusText);
|
|
document.getElementById('promotionStatus').innerHTML = '<div class="alert alert-success">Promotion appliquée. Backup et audit trail enregistrés.</div>';
|
|
setTimeout(() => {
|
|
closePromotionModal();
|
|
loadKnowledgeBase();
|
|
}, 900);
|
|
} catch (err) {
|
|
document.getElementById('promotionStatus').innerHTML = `<div class="alert alert-warning">${escapeHtml(err.message || String(err))}</div>`;
|
|
button.disabled = false;
|
|
} finally {
|
|
button.textContent = 'Confirmer la promotion';
|
|
}
|
|
}
|
|
|
|
function closePromotionModal() {
|
|
document.getElementById('promotionModal').classList.remove('visible');
|
|
currentPromotion = null;
|
|
}
|
|
|
|
function newPromotionId() {
|
|
if (window.crypto?.randomUUID) return window.crypto.randomUUID();
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => {
|
|
const value = Math.floor(Math.random() * 16);
|
|
const resolved = char === 'x' ? value : (value & 0x3) | 0x8;
|
|
return resolved.toString(16);
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.appendChild(document.createTextNode(text));
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function escapeAttr(text) {
|
|
return String(text).replace(/[^a-zA-Z0-9_.:-]/g, '');
|
|
}
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|