feat(lea): add dashboard competence promotion dry run
This commit is contained in:
@@ -89,6 +89,74 @@
|
||||
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;
|
||||
@@ -193,7 +261,48 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Section 4 : Workflows -->
|
||||
<!-- 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>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">
|
||||
@@ -205,9 +314,33 @@
|
||||
|
||||
</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>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', loadKnowledgeBase);
|
||||
|
||||
let knowledgeBaseData = null;
|
||||
let currentPromotion = null;
|
||||
|
||||
async function loadKnowledgeBase() {
|
||||
try {
|
||||
const resp = await fetch('/api/knowledge-base/stats');
|
||||
@@ -216,7 +349,9 @@ async function loadKnowledgeBase() {
|
||||
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);
|
||||
}
|
||||
@@ -283,11 +418,169 @@ 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 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">${buttons}</div></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user