diff --git a/core/competences/replay.py b/core/competences/replay.py index c4c4ebf97..141d1c828 100644 --- a/core/competences/replay.py +++ b/core/competences/replay.py @@ -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, }, } - diff --git a/tests/unit/test_dashboard_routes.py b/tests/unit/test_dashboard_routes.py index 2c042f036..c24998f55 100644 --- a/tests/unit/test_dashboard_routes.py +++ b/tests/unit/test_dashboard_routes.py @@ -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') diff --git a/web_dashboard/app.py b/web_dashboard/app.py index 64f6f8af4..b7140e717 100644 --- a/web_dashboard/app.py +++ b/web_dashboard/app.py @@ -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//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/', 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//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//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" diff --git a/web_dashboard/templates/knowledge_base.html b/web_dashboard/templates/knowledge_base.html index d01bd47eb..d65d3d8ee 100644 --- a/web_dashboard/templates/knowledge_base.html +++ b/web_dashboard/templates/knowledge_base.html @@ -292,7 +292,7 @@ État Verdicts Contextes - Promotion + Test / Promotion @@ -335,11 +335,33 @@ + +