feat(dashboard): launch supervised competence tests
This commit is contained in:
@@ -292,7 +292,7 @@
|
||||
<th>État</th>
|
||||
<th>Verdicts</th>
|
||||
<th>Contextes</th>
|
||||
<th>Promotion</th>
|
||||
<th>Test / Promotion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="competencesTableBody">
|
||||
@@ -335,11 +335,33 @@
|
||||
</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 {
|
||||
@@ -433,6 +455,7 @@ function renderCompetences(competences) {
|
||||
|
||||
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]) => {
|
||||
@@ -462,12 +485,237 @@ function renderCompetences(competences) {
|
||||
</div>
|
||||
</td>
|
||||
<td>${item.distinct_contexts || 0}</td>
|
||||
<td><div class="action-row">${buttons}</div></td>
|
||||
<td><div class="action-row">${testButton}${buttons}</div></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function openCompetenceTest(competenceId) {
|
||||
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 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 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: Array.isArray(state.step_results || state.results) ? (state.step_results || state.results).length : 0,
|
||||
}, 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) {
|
||||
document.getElementById('testStatus').innerHTML = `<div class="alert alert-info">${msg}</div>`;
|
||||
document.getElementById('testActions').innerHTML = `
|
||||
<button class="action-btn" 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 || []);
|
||||
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 closeTestModal() {
|
||||
clearTestPolling();
|
||||
document.getElementById('testModal').classList.remove('visible');
|
||||
currentTest = null;
|
||||
}
|
||||
|
||||
async function openPromotion(competenceId, targetState, verdictIds) {
|
||||
currentPromotion = null;
|
||||
document.getElementById('promotionModal').classList.add('visible');
|
||||
|
||||
Reference in New Issue
Block a user