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", ""),
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 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:
|
||||
"""Statistiques de l'index FAISS."""
|
||||
faiss_index_path = DATA_PATH / "faiss_index" / "main.index"
|
||||
|
||||
@@ -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