feat(dashboard): launch supervised competence tests
This commit is contained in:
@@ -160,6 +160,9 @@ def _pause_action(competence: CompetenceSummary, *, phase: str) -> dict[str, Any
|
|||||||
"attendu": failure.get("attendu", ""),
|
"attendu": failure.get("attendu", ""),
|
||||||
"demande": failure.get("demande", ""),
|
"demande": failure.get("demande", ""),
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
|
"verdict_required": phase == "after",
|
||||||
|
"verdict_endpoint": f"/api/v1/lea/competences/{competence.id}/verdict",
|
||||||
|
"competence_id": competence.id,
|
||||||
|
"write_back_enabled": False,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import pytest
|
|||||||
# Ajouter le répertoire racine au path
|
# Ajouter le répertoire racine au path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
import web_dashboard.app as dashboard_app
|
||||||
from web_dashboard.app import app
|
from web_dashboard.app import app
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +63,82 @@ class TestDashboardRoutes:
|
|||||||
assert 'competences' in data
|
assert 'competences' in data
|
||||||
assert 'items' in data['competences']
|
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):
|
def test_version(self, client):
|
||||||
"""L'API version retourne la version actuelle."""
|
"""L'API version retourne la version actuelle."""
|
||||||
resp = client.get('/api/version')
|
resp = client.get('/api/version')
|
||||||
|
|||||||
@@ -2515,6 +2515,151 @@ def dashboard_promote_competence(competence_id):
|
|||||||
}), status
|
}), 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 l’agent 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:
|
def _kb_faiss_stats() -> dict:
|
||||||
"""Statistiques de l'index FAISS."""
|
"""Statistiques de l'index FAISS."""
|
||||||
faiss_index_path = DATA_PATH / "faiss_index" / "main.index"
|
faiss_index_path = DATA_PATH / "faiss_index" / "main.index"
|
||||||
|
|||||||
@@ -292,7 +292,7 @@
|
|||||||
<th>État</th>
|
<th>État</th>
|
||||||
<th>Verdicts</th>
|
<th>Verdicts</th>
|
||||||
<th>Contextes</th>
|
<th>Contextes</th>
|
||||||
<th>Promotion</th>
|
<th>Test / Promotion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="competencesTableBody">
|
<tbody id="competencesTableBody">
|
||||||
@@ -335,11 +335,33 @@
|
|||||||
</div>
|
</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>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', loadKnowledgeBase);
|
document.addEventListener('DOMContentLoaded', loadKnowledgeBase);
|
||||||
|
|
||||||
let knowledgeBaseData = null;
|
let knowledgeBaseData = null;
|
||||||
let currentPromotion = null;
|
let currentPromotion = null;
|
||||||
|
let currentTest = null;
|
||||||
|
let testPollTimer = null;
|
||||||
|
|
||||||
async function loadKnowledgeBase() {
|
async function loadKnowledgeBase() {
|
||||||
try {
|
try {
|
||||||
@@ -433,6 +455,7 @@ function renderCompetences(competences) {
|
|||||||
|
|
||||||
tbody.innerHTML = items.map(item => {
|
tbody.innerHTML = items.map(item => {
|
||||||
const targetEntries = Object.entries(item.eligible_targets || {});
|
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
|
const buttons = targetEntries.length === 0
|
||||||
? '<span style="color:#64748b;font-size:12px;">Aucune promotion disponible</span>'
|
? '<span style="color:#64748b;font-size:12px;">Aucune promotion disponible</span>'
|
||||||
: targetEntries.map(([target, info]) => {
|
: targetEntries.map(([target, info]) => {
|
||||||
@@ -462,12 +485,237 @@ function renderCompetences(competences) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>${item.distinct_contexts || 0}</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>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).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) {
|
async function openPromotion(competenceId, targetState, verdictIds) {
|
||||||
currentPromotion = null;
|
currentPromotion = null;
|
||||||
document.getElementById('promotionModal').classList.add('visible');
|
document.getElementById('promotionModal').classList.add('visible');
|
||||||
|
|||||||
Reference in New Issue
Block a user