feat(dashboard): launch supervised competence tests

This commit is contained in:
Dom
2026-06-01 12:09:09 +02:00
parent 1a58a0d1f1
commit 335d576830
4 changed files with 476 additions and 3 deletions

View File

@@ -160,6 +160,9 @@ def _pause_action(competence: CompetenceSummary, *, phase: str) -> dict[str, Any
"attendu": failure.get("attendu", ""),
"demande": failure.get("demande", ""),
"phase": phase,
"verdict_required": phase == "after",
"verdict_endpoint": f"/api/v1/lea/competences/{competence.id}/verdict",
"competence_id": competence.id,
"write_back_enabled": False,
},
}

View File

@@ -12,6 +12,7 @@ import pytest
# Ajouter le répertoire racine au path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
import web_dashboard.app as dashboard_app
from web_dashboard.app import app
@@ -62,6 +63,82 @@ class TestDashboardRoutes:
assert 'competences' in data
assert 'items' in data['competences']
def test_dashboard_replay_competence_proxy(self, client, monkeypatch):
"""Le dashboard lance un replay competence supervise via streaming."""
calls = []
def fake_streaming(method, path, *, payload=None, timeout=10):
calls.append({
'method': method,
'path': path,
'payload': payload,
'timeout': timeout,
})
return dashboard_app.jsonify({
'success': True,
'status': 'started',
'replay': {'replay_id': 'replay_free_test'},
}), 200
monkeypatch.setattr(
dashboard_app,
'_dashboard_streaming_json_request',
fake_streaming,
)
resp = client.post(
'/api/v1/lea/competences/key_win_r_wait_explorer_exe/replay',
json={},
)
assert resp.status_code == 200
assert resp.get_json()['replay']['replay_id'] == 'replay_free_test'
assert calls == [{
'method': 'POST',
'path': '/api/v1/lea/competences/key_win_r_wait_explorer_exe/replay',
'payload': {
'supervised': True,
'start_replay': True,
'session_id': '',
},
'timeout': 30,
}]
def test_dashboard_submit_competence_verdict(self, client, monkeypatch):
"""Le dashboard journalise un verdict sans write-back YAML."""
import core.competences.verdicts as verdicts_module
def fake_store(competence_id, payload):
assert competence_id == 'key_win_r_wait_explorer_exe'
assert payload['verdict_kind'] == 'valid'
return {
'verdict_id': payload['verdict_id'],
'competence_id': competence_id,
'verdict_kind': 'valid',
'duplicate': False,
'write_back_enabled': False,
'yaml_write': False,
}
monkeypatch.setattr(verdicts_module, 'store_competence_verdict', fake_store)
resp = client.post(
'/api/v1/lea/competences/key_win_r_wait_explorer_exe/verdict',
json={
'verdict_id': '123e4567-e89b-42d3-a456-426614174000',
'verdict_kind': 'valid',
'workflow_id': 'free_task:test',
'step_results': [{'step_id': 's1', 'status': 'success'}],
'context_signature': {'machine_id': 'DESKTOP-58D5CAC_windows'},
},
)
assert resp.status_code == 201
data = resp.get_json()
assert data['success'] is True
assert data['write_back_enabled'] is False
assert data['yaml_write'] is False
def test_version(self, client):
"""L'API version retourne la version actuelle."""
resp = client.get('/api/version')

View File

@@ -2515,6 +2515,151 @@ def dashboard_promote_competence(competence_id):
}), status
def _dashboard_streaming_json_request(
method: str,
path: str,
*,
payload: dict | None = None,
timeout: int = 10,
):
"""Proxy JSON minimal vers le streaming server, avec Bearer token."""
import urllib.error
import urllib.request
base_url = os.environ.get('RPA_STREAMING_URL', 'http://localhost:5005').rstrip('/')
url = f"{base_url}{path}"
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
token = os.environ.get('RPA_API_TOKEN', '')
if token:
headers['Authorization'] = f'Bearer {token}'
data_bytes = None
if payload is not None:
data_bytes = json.dumps(payload).encode('utf-8')
try:
req = urllib.request.Request(url, data=data_bytes, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=timeout) as response:
raw = response.read().decode('utf-8')
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
data = {'raw': raw}
return jsonify(data), response.status
except urllib.error.HTTPError as exc:
raw = exc.read().decode('utf-8')
try:
detail = json.loads(raw) if raw else {'error': str(exc)}
except json.JSONDecodeError:
detail = {'error': raw or str(exc)}
return jsonify(detail), exc.code
except urllib.error.URLError as exc:
return jsonify({
'success': False,
'error': f'Serveur streaming (5005) inaccessible : {exc}',
'hint': 'Vérifiez que le service streaming est démarré et que lagent Windows est connecté.',
}), 502
@app.route('/api/v1/lea/competences/<competence_id>/replay', methods=['POST'])
def dashboard_replay_competence(competence_id):
"""Start a supervised competence replay through the dashboard."""
from urllib.parse import quote
payload = request.get_json(silent=True) or {}
replay_payload = {
'supervised': bool(payload.get('supervised', True)),
'start_replay': bool(payload.get('start_replay', True)),
'session_id': str(payload.get('session_id') or ''),
}
machine_id = str(payload.get('machine_id') or '').strip()
if machine_id:
replay_payload['machine_id'] = machine_id
encoded_id = quote(competence_id, safe='')
return _dashboard_streaming_json_request(
'POST',
f'/api/v1/lea/competences/{encoded_id}/replay',
payload=replay_payload,
timeout=30,
)
@app.route('/api/v1/lea/replays/<replay_id>', methods=['GET'])
def dashboard_replay_status(replay_id):
"""Return replay status from the streaming server."""
from urllib.parse import quote
encoded_id = quote(replay_id, safe='')
return _dashboard_streaming_json_request(
'GET',
f'/api/v1/traces/stream/replay/{encoded_id}',
timeout=5,
)
@app.route('/api/v1/lea/replays/<replay_id>/resume', methods=['POST'])
def dashboard_resume_replay(replay_id):
"""Resume a supervised replay from the dashboard modal."""
from urllib.parse import quote
payload = request.get_json(silent=True) or {}
encoded_id = quote(replay_id, safe='')
return _dashboard_streaming_json_request(
'POST',
f'/api/v1/traces/stream/replay/{encoded_id}/resume',
payload={
'acknowledged_check_ids': payload.get('acknowledged_check_ids') or [],
},
timeout=10,
)
@app.route('/api/v1/lea/competences/<competence_id>/verdict', methods=['POST'])
def dashboard_submit_competence_verdict(competence_id):
"""Persist a supervised competence verdict from the dashboard."""
try:
from core.competences.verdicts import (
CompetenceVerdictError,
store_competence_verdict,
)
payload = request.get_json(silent=True) or {}
verdict = store_competence_verdict(competence_id, payload)
except KeyError:
return jsonify({
'success': False,
'error': f"Competence '{competence_id}' introuvable",
'write_back_enabled': False,
'yaml_write': False,
}), 404
except CompetenceVerdictError as exc:
return jsonify({
'success': False,
'error': str(exc),
'write_back_enabled': False,
'yaml_write': False,
}), 400
except Exception as exc:
return jsonify({
'success': False,
'error': str(exc),
'write_back_enabled': False,
'yaml_write': False,
}), 500
return jsonify({
'success': True,
'competence_id': competence_id,
'verdict': verdict,
'duplicate': verdict['duplicate'],
'write_back_enabled': False,
'yaml_write': False,
}), 200 if verdict['duplicate'] else 201
def _kb_faiss_stats() -> dict:
"""Statistiques de l'index FAISS."""
faiss_index_path = DATA_PATH / "faiss_index" / "main.index"

View File

@@ -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');