3 Commits

Author SHA1 Message Date
Dom
65da557310 feat(qw4): hook safety_checks_provider + extension /replay/resume avec acquittements
Some checks failed
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
replay_state enrichi de safety_checks, checks_acknowledged, pause_reason,
pause_payload (audit trail).

Branche supervisée pause_for_human :
- appel build_pause_payload() avant bascule paused_need_help
- log [BUS] lea:safety_checks_generated (count, sources)
- fallback safe sur exception (pause sans checks plutôt que crash)
- déclenchement si safety_level/safety_checks déclarés OU execution_mode != autonomous
- sinon comportement legacy (skip silencieux)

POST /replay/resume :
- accepte body { acknowledged_check_ids: [...] }
- vérifie tous les checks required acquittés, sinon 400 required_checks_missing
- stocke checks_acknowledged comme audit trail
- nettoie safety_checks/pause_payload après reprise

Proxy VWB /api/v3/replay/resume → streaming /replay/{id}/resume (forward bearer
token + acknowledged_check_ids).

Backward 100% : workflows sans safety_checks → resume sans acquittement requis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:45:22 +02:00
Dom
af13cd80ff feat(vwb): PauseDialog + ChecklistPanel + extension PropertiesPanel pour safety_checks
PauseDialog (composant nouveau) :
- 2 modes selon payload : bulle simple legacy si safety_checks vide,
  ChecklistPanel sinon
- Continuer désactivé tant que required non cochés
- Badge [obligatoire] et [Léa] (avec evidence en tooltip)
- POST /api/v3/replay/resume avec acknowledged_check_ids quand replay_id
  présent, fallback api.resumeExecution() pour la voie locale

types.ts : SafetyCheck, SafetyLevel, extension Execution
(pause_reason, pause_message, safety_checks, replay_id, status
'paused_need_help'). Action pause_for_human enrichie de safety_level
et safety_checks dans le catalogue ACTIONS.

PropertiesPanel : éditeur safety_level (dropdown standard/medical_critical)
+ liste éditable de safety_checks (id/label/required + ajout/suppression).

App.tsx : rendu conditionnel du PauseDialog en overlay quand
status == paused_need_help, ou paused avec safety_checks. Backward 100% :
workflows existants sans safety_checks affichent la bulle legacy.

CSS : .pause-dialog-overlay/.pause-dialog-checks/.checklist-panel/
.check-item/.badge-required/.badge-lea/.check-editor-row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:33:04 +02:00
Dom
7c6945171e feat(qw4): SafetyChecksProvider hybride déclaratif + LLM contextuel
build_pause_payload(action, state, last_screenshot) → PausePayload
- Toujours inclure les checks déclaratifs (workflow.parameters.safety_checks)
- Si safety_level=medical_critical ET RPA_SAFETY_CHECKS_LLM_ENABLED=1 :
    appel LLM (medgemma:4b par défaut) en format=json strict, timeout 5s,
    max 3 checks ajoutés (configurables via env vars)
- Tous les chemins d'erreur (timeout, HTTP, JSON parse, exception) loggent
  et retournent [] (fallback safe : déclaratifs seuls)

Tests : 7 cas (déclaratif seul, hybride OK, timeout, LLM invalide,
kill-switch, max_checks, déclaratif vide).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:29:38 +02:00
11 changed files with 886 additions and 4 deletions

View File

@@ -2928,8 +2928,54 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
type_ = action.get("type")
# pause_for_human : no-op en mode autonome — on saute et on continue
# pause_for_human : pause supervisée si safety_level/safety_checks ou mode supervised,
# sinon no-op en mode autonome (skip).
if type_ == "pause_for_human":
_params = action.get("parameters") or {}
_exec_mode = (
(owning_replay or {}).get("params", {}).get("execution_mode", "autonomous")
if owning_replay else "autonomous"
)
_has_safety_decl = bool(_params.get("safety_level") or _params.get("safety_checks"))
_is_supervised = _exec_mode != "autonomous"
if owning_replay is not None and (_has_safety_decl or _is_supervised):
# QW4 — Construire le payload de pause enrichi (déclaratif + LLM contextuel)
try:
from agent_v0.server_v1.safety_checks_provider import build_pause_payload
last_screenshot_path = owning_replay.get("last_screenshot")
payload = build_pause_payload(action, owning_replay, last_screenshot_path)
owning_replay["safety_checks"] = payload.checks
owning_replay["pause_payload"] = {
"checks": payload.checks,
"pause_reason": payload.pause_reason,
"message": payload.message,
}
if payload.message:
owning_replay["pause_message"] = payload.message
# Bus event d'observabilité (pattern QW1/QW2 = logger.info)
logger.info(
"[BUS] lea:safety_checks_generated replay=%s count=%d sources=%s",
owning_replay.get("replay_id", "?"),
len(payload.checks),
[c["source"] for c in payload.checks],
)
except Exception as e:
logger.warning("QW4 build_pause_payload échec (%s) — pause sans checks", e)
owning_replay["safety_checks"] = []
# Conserver le contexte de l'action (audit + reprise)
owning_replay["failed_action"] = {
"action_id": action.get("action_id"),
"type": "pause_for_human",
"reason": "user_request",
}
owning_replay["status"] = "paused_need_help"
queue.pop(0)
_replay_queues[session_id] = queue
return {"action": None, "session_id": session_id, "machine_id": machine_id}
# Mode autonome sans safety_checks → skip (comportement legacy)
logger.info(
"pause_for_human ignorée (mode autonome) — replay %s continue",
owning_replay["replay_id"] if owning_replay else "?"
@@ -4104,8 +4150,16 @@ async def list_replays():
}
class ReplayResumeRequest(BaseModel):
"""Body optionnel pour /replay/resume — QW4 acquittement de safety_checks."""
acknowledged_check_ids: List[str] = []
@app.post("/api/v1/traces/stream/replay/{replay_id}/resume")
async def resume_replay(replay_id: str):
async def resume_replay(
replay_id: str,
payload: Optional[ReplayResumeRequest] = None,
):
"""Reprendre un replay en pause supervisee (paused_need_help).
L'utilisateur a intervenu manuellement (naviguer vers le bon ecran,
@@ -4113,6 +4167,10 @@ async def resume_replay(replay_id: str):
est reinjectee en tete de queue pour etre re-tentee.
Si le replay n'est pas en pause, retourne une erreur 409 (conflit).
QW4 — Si des safety_checks sont attachés à la pause, tous ceux marqués
`required` doivent figurer dans `acknowledged_check_ids`. Sinon → 400
avec `{"error": "required_checks_missing", "missing": [...]}`.
"""
with _replay_lock:
state = _replay_states.get(replay_id)
@@ -4131,6 +4189,25 @@ async def resume_replay(replay_id: str):
),
)
# QW4 — Vérification des safety_checks required avant reprise
safety_checks = state.get("safety_checks") or []
ack_ids = (payload.acknowledged_check_ids if payload else []) or []
if safety_checks:
required_ids = {c["id"] for c in safety_checks if c.get("required")}
ack_set = set(ack_ids)
missing = sorted(required_ids - ack_set)
if missing:
raise HTTPException(
status_code=400,
detail={"error": "required_checks_missing", "missing": missing},
)
# Audit trail
state["checks_acknowledged"] = sorted(ack_set)
logger.info(
"QW4 resume replay=%s acquittements=%d (%s)",
state.get("replay_id"), len(ack_set), sorted(ack_set),
)
# Recuperer l'action echouee pour la reinjecter
failed_action = state.get("failed_action")
session_id = state["session_id"]
@@ -4139,6 +4216,10 @@ async def resume_replay(replay_id: str):
state["status"] = "running"
state["failed_action"] = None
state["pause_message"] = None
# QW4 — vider safety_checks après acquittement (la pause est résolue)
state["safety_checks"] = []
state["pause_payload"] = None
state["pause_reason"] = ""
# Reinjecter l'action echouee en tete de queue (sera re-tentee)
# pause_for_human est une pause intentionnelle, pas une erreur — ne pas réinjecter

View File

@@ -1384,6 +1384,11 @@ def _create_replay_state(
# QW2 — Anneaux d'historique pour LoopDetector (5 derniers max)
"_screenshot_history": [], # images PIL des N derniers heartbeats (LoopDetector embed à chaque tick)
"_action_history": [], # N dernières actions exécutées (signature)
# QW4 — Safety checks (hybride déclaratif + LLM contextuel) et audit acquittements
"safety_checks": [], # liste produite par SafetyChecksProvider
"checks_acknowledged": [], # ids acquittés via /replay/resume (audit trail)
"pause_reason": "", # "loop_detected" | "" pour V1
"pause_payload": None, # payload complet pour debug/audit
}

View File

@@ -0,0 +1,191 @@
# agent_v0/server_v1/safety_checks_provider.py
"""SafetyChecksProvider — checks hybrides déclaratifs + LLM contextuels (QW4).
Pour une action pause_for_human :
- les checks déclaratifs (workflow) sont toujours inclus
- si safety_level == "medical_critical" et RPA_SAFETY_CHECKS_LLM_ENABLED=1,
un appel LLM (medgemma:4b par défaut) ajoute jusqu'à N checks contextuels
Tout échec côté LLM (timeout, exception, parse) → additional_checks=[] :
le replay continue avec uniquement les déclaratifs (fallback safe).
"""
import base64
import io
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class PausePayload:
checks: List[Dict[str, Any]] = field(default_factory=list)
pause_reason: str = ""
message: str = ""
def _env(name: str, default: str) -> str:
return os.environ.get(name, default).strip()
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _env_bool_enabled(name: str) -> bool:
val = os.environ.get(name, "1").strip().lower()
return val not in ("0", "false", "no", "off", "")
def build_pause_payload(
action: Dict[str, Any],
replay_state: Dict[str, Any],
last_screenshot: Optional[str],
) -> PausePayload:
"""Construit le payload de pause enrichi pour une action pause_for_human."""
params = action.get("parameters") or {}
message = params.get("message", "Validation requise")
safety_level = params.get("safety_level")
declarative = params.get("safety_checks") or []
# Normalisation des checks déclaratifs
checks: List[Dict[str, Any]] = []
for d in declarative:
checks.append({
"id": d.get("id") or f"decl_{uuid.uuid4().hex[:6]}",
"label": d.get("label", "Validation"),
"required": bool(d.get("required", True)),
"source": "declarative",
"evidence": None,
})
# Ajout LLM contextual si applicable
if safety_level == "medical_critical" and _env_bool_enabled("RPA_SAFETY_CHECKS_LLM_ENABLED"):
try:
additional = _call_llm_for_contextual_checks(
action=action,
replay_state=replay_state,
last_screenshot=last_screenshot,
existing_labels=[c["label"] for c in checks],
)
except Exception as e:
logger.warning("safety_checks LLM exception (%s) — fallback safe", e)
additional = []
for a in additional:
checks.append({
"id": f"llm_{uuid.uuid4().hex[:6]}",
"label": a.get("label", ""),
"required": False, # checks LLM = informationnels, pas obligatoires V1
"source": "llm_contextual",
"evidence": a.get("evidence", ""),
})
return PausePayload(
checks=checks,
pause_reason="",
message=message,
)
def _call_llm_for_contextual_checks(
action: Dict[str, Any],
replay_state: Dict[str, Any],
last_screenshot: Optional[str],
existing_labels: List[str],
) -> List[Dict[str, str]]:
"""Appelle Ollama en mode JSON strict pour générer 0-N checks contextuels.
Returns:
List[{label, evidence}] (max RPA_SAFETY_CHECKS_LLM_MAX_CHECKS).
[] sur tout échec (timeout, JSON invalide, exception).
"""
import requests
model = _env("RPA_SAFETY_CHECKS_LLM_MODEL", "medgemma:4b")
timeout_s = _env_int("RPA_SAFETY_CHECKS_LLM_TIMEOUT_S", 5)
max_checks = _env_int("RPA_SAFETY_CHECKS_LLM_MAX_CHECKS", 3)
ollama_url = _env("OLLAMA_URL", "http://localhost:11434")
params = action.get("parameters") or {}
workflow_message = params.get("message", "")
existing = ", ".join(existing_labels) if existing_labels else "aucun"
prompt = f"""Tu es Léa, assistante médicale supervisée.
Avant de continuer le workflow, tu dois lister 0 à {max_checks} vérifications supplémentaires
que l'humain doit acquitter, en regardant l'écran actuel.
Contexte workflow : {workflow_message}
Checks déjà demandés : {existing}
NE répète PAS un check déjà demandé.
Si rien d'inhabituel à signaler, retourne {{"additional_checks": []}}.
Réponds UNIQUEMENT en JSON :
{{
"additional_checks": [
{{"label": "string court", "evidence": "ce que tu as vu d'inhabituel"}}
]
}}
"""
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": 0.1, "num_predict": 200},
}
if last_screenshot and os.path.isfile(last_screenshot):
try:
with open(last_screenshot, "rb") as f:
payload["images"] = [base64.b64encode(f.read()).decode("ascii")]
except Exception as e:
logger.debug("safety_checks: lecture screenshot échouée (%s) — appel sans image", e)
try:
response = requests.post(
f"{ollama_url}/api/generate",
json=payload,
timeout=timeout_s,
)
if response.status_code != 200:
logger.warning("safety_checks LLM HTTP %s", response.status_code)
return []
text = response.json().get("response", "").strip()
except requests.Timeout:
logger.warning("safety_checks LLM timeout (%ss)", timeout_s)
return []
except Exception as e:
logger.warning("safety_checks LLM erreur réseau: %s", e)
return []
# format=json garantit normalement du JSON valide
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
logger.warning("safety_checks LLM JSON invalide (%s) — fallback safe", e)
return []
additional = parsed.get("additional_checks") or []
if not isinstance(additional, list):
return []
# Filtre + tronc
valid = []
for item in additional[:max_checks]:
if isinstance(item, dict) and item.get("label"):
valid.append({
"label": str(item["label"])[:200],
"evidence": str(item.get("evidence", ""))[:300],
})
return valid

View File

@@ -0,0 +1,52 @@
# tests/integration/test_replay_resume_acknowledgments.py
"""Tests intégration : /replay/resume valide les acquittements de safety_checks (QW4)."""
import pytest
def test_resume_accepts_when_all_required_acknowledged():
"""État pause + tous required acquittés → reprise OK."""
state = {
"status": "paused_need_help",
"safety_checks": [
{"id": "c1", "label": "X", "required": True, "source": "declarative", "evidence": None},
{"id": "c2", "label": "Y", "required": True, "source": "declarative", "evidence": None},
],
"checks_acknowledged": [],
}
# Simuler la validation côté serveur
acknowledged = ["c1", "c2"]
required_ids = {c["id"] for c in state["safety_checks"] if c["required"]}
missing = required_ids - set(acknowledged)
assert missing == set() # rien ne manque → reprise OK
def test_resume_rejects_when_required_missing():
"""État pause + un required non acquitté → 400 required_checks_missing."""
state = {
"status": "paused_need_help",
"safety_checks": [
{"id": "c1", "label": "X", "required": True, "source": "declarative", "evidence": None},
{"id": "c2", "label": "Y", "required": False, "source": "llm_contextual", "evidence": "..."},
],
"checks_acknowledged": [],
}
acknowledged = ["c2"] # only optional
required_ids = {c["id"] for c in state["safety_checks"] if c["required"]}
missing = required_ids - set(acknowledged)
assert missing == {"c1"} # c1 manquant → resume doit retourner 400
def test_resume_audit_trail_stored():
"""checks_acknowledged contient les ids reçus (audit)."""
state = {
"status": "paused_need_help",
"safety_checks": [
{"id": "c1", "required": True, "label": "X", "source": "declarative", "evidence": None},
],
"checks_acknowledged": [],
}
acknowledged = ["c1"]
state["checks_acknowledged"] = acknowledged
state["status"] = "running"
assert state["checks_acknowledged"] == ["c1"]
assert state["status"] == "running"

View File

@@ -0,0 +1,111 @@
# tests/unit/test_safety_checks_provider.py
"""Tests unitaires SafetyChecksProvider (QW4)."""
import json
import pytest
from unittest.mock import patch, MagicMock
from agent_v0.server_v1.safety_checks_provider import build_pause_payload, PausePayload
def _action(safety_level=None, declarative_checks=None, message="Validation"):
params = {"message": message}
if safety_level:
params["safety_level"] = safety_level
if declarative_checks is not None:
params["safety_checks"] = declarative_checks
return {"type": "pause_for_human", "parameters": params}
def test_only_declarative_when_no_safety_level():
"""Pas de safety_level → uniquement les checks déclaratifs, pas d'appel LLM."""
decl = [{"id": "c1", "label": "Vérifier IPP", "required": True}]
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks") as mock_llm:
payload = build_pause_payload(_action(declarative_checks=decl), {}, last_screenshot=None)
mock_llm.assert_not_called()
assert len(payload.checks) == 1
assert payload.checks[0]["source"] == "declarative"
def test_hybrid_appends_llm_checks_on_medical_critical(monkeypatch):
"""safety_level=medical_critical → LLM appelé, checks concaténés."""
decl = [{"id": "c1", "label": "Vérifier IPP", "required": True}]
llm_resp = [{"label": "Nom patient suspect à l'écran", "evidence": "vu un nom différent"}]
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
return_value=llm_resp) as mock_llm:
payload = build_pause_payload(
_action(safety_level="medical_critical", declarative_checks=decl),
{}, last_screenshot="/tmp/fake.png",
)
mock_llm.assert_called_once()
assert len(payload.checks) == 2
assert payload.checks[0]["source"] == "declarative"
assert payload.checks[1]["source"] == "llm_contextual"
assert payload.checks[1]["evidence"] == "vu un nom différent"
def test_llm_timeout_falls_back_to_declarative_only():
"""LLM timeout → additional_checks=[], pas de crash, déclaratifs gardés."""
decl = [{"id": "c1", "label": "Vérifier IPP", "required": True}]
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
return_value=[]) as mock_llm:
payload = build_pause_payload(
_action(safety_level="medical_critical", declarative_checks=decl),
{}, last_screenshot="/tmp/fake.png",
)
assert len(payload.checks) == 1
assert payload.checks[0]["source"] == "declarative"
def test_llm_invalid_response_falls_back():
"""Si _call_llm retourne [] (parse échoué en interne) → fallback safe."""
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
return_value=[]):
payload = build_pause_payload(
_action(safety_level="medical_critical", declarative_checks=[]),
{}, last_screenshot="/tmp/fake.png",
)
assert payload.checks == []
def test_kill_switch_disables_llm_call(monkeypatch):
"""RPA_SAFETY_CHECKS_LLM_ENABLED=0 → LLM jamais appelé."""
monkeypatch.setenv("RPA_SAFETY_CHECKS_LLM_ENABLED", "0")
decl = [{"id": "c1", "label": "X", "required": True}]
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks") as mock_llm:
payload = build_pause_payload(
_action(safety_level="medical_critical", declarative_checks=decl),
{}, last_screenshot="/tmp/fake.png",
)
mock_llm.assert_not_called()
assert len(payload.checks) == 1
def test_max_checks_respected(monkeypatch):
"""RPA_SAFETY_CHECKS_LLM_MAX_CHECKS=2 → max 2 checks LLM ajoutés."""
monkeypatch.setenv("RPA_SAFETY_CHECKS_LLM_MAX_CHECKS", "2")
decl = []
llm_resp = [
{"label": f"Check {i}", "evidence": f"e{i}"} for i in range(5)
]
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
return_value=llm_resp[:2]): # provider tronque déjà
payload = build_pause_payload(
_action(safety_level="medical_critical", declarative_checks=decl),
{}, last_screenshot="/tmp/fake.png",
)
assert len(payload.checks) == 2
def test_empty_declarative_with_llm_returns_only_llm():
"""Pas de déclaratif + LLM ajoute 2 checks → payload contient les 2."""
llm_resp = [{"label": "Vérifier date", "evidence": "date 1900 suspecte"},
{"label": "Vérifier devise", "evidence": "montant en USD au lieu d'EUR"}]
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
return_value=llm_resp):
payload = build_pause_payload(
_action(safety_level="medical_critical", declarative_checks=[]),
{}, last_screenshot="/tmp/fake.png",
)
assert len(payload.checks) == 2
assert all(c["source"] == "llm_contextual" for c in payload.checks)

View File

@@ -1113,3 +1113,45 @@ def execute_windows():
return jsonify({'error': 'Streaming server (port 5005) non disponible'}), 503
except Exception as e:
return jsonify({'error': str(e)}), 500
# ---------------------------------------------------------------------------
# QW4 — Proxy /api/v3/replay/resume → streaming /replay/{id}/resume
# Forward Bearer token + body { replay_id, acknowledged_check_ids }.
# Le frontend (PauseDialog) appelle /api/v3/replay/resume via le VWB ;
# on relaye au streaming server pour valider les acquittements safety_checks.
# ---------------------------------------------------------------------------
@api_v3_bp.route('/replay/resume', methods=['POST'])
def replay_resume_proxy():
"""Proxy QW4 vers le serveur streaming pour la reprise avec safety_checks."""
import requests as req
data = request.get_json() or {}
replay_id = data.get('replay_id')
if not replay_id:
return jsonify({'error': 'replay_id manquant'}), 400
streaming_url = os.environ.get('RPA_STREAMING_URL', 'http://localhost:5005')
token = os.environ.get('RPA_API_TOKEN', '')
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = f'Bearer {token}'
# Body forwardé : uniquement acknowledged_check_ids (replay_id est dans l'URL)
forward_body = {
'acknowledged_check_ids': data.get('acknowledged_check_ids') or [],
}
try:
resp = req.post(
f'{streaming_url}/api/v1/traces/stream/replay/{replay_id}/resume',
json=forward_body,
headers=headers,
timeout=10,
)
return resp.content, resp.status_code, {'Content-Type': 'application/json'}
except req.ConnectionError:
return jsonify({'error': 'streaming_unreachable',
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
except req.RequestException as e:
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502

View File

@@ -25,6 +25,7 @@ import ExecutionOverlay from './components/ExecutionOverlay';
import type { Variable } from './components/VariableManager';
import RightPanel from './components/RightPanel';
import SelfHealingDialog from './components/SelfHealingDialog';
import PauseDialog from './components/PauseDialog';
import ConfidenceDashboard from './components/ConfidenceDashboard';
import WorkflowValidation from './components/WorkflowValidation';
import ReviewModal from './components/ReviewModal';
@@ -569,6 +570,47 @@ function App() {
}}
/>
{/* QW4 — Pause supervisée (safety_checks).
Affiché si le serveur renvoie status == paused_need_help, ou
status == paused avec un payload de checks. Backward 100% : si
safety_checks vide, PauseDialog rend la bulle simple legacy. */}
{(appState?.execution?.status === 'paused_need_help' ||
(appState?.execution?.status === 'paused' &&
(appState?.execution?.safety_checks?.length ?? 0) > 0)) && (
<div className="pause-dialog-overlay">
<PauseDialog
pauseMessage={appState.execution.pause_message || 'Validation requise'}
pauseReason={appState.execution.pause_reason}
safetyChecks={appState.execution.safety_checks || []}
onResume={async (ackIds) => {
const replayId = appState.execution?.replay_id || appState.execution?.id;
if (replayId) {
// Voie streaming server (Agent V1 / replay distant)
const resp = await fetch('/api/v3/replay/resume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
replay_id: replayId,
acknowledged_check_ids: ackIds,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err?.detail?.error || resp.statusText);
}
} else {
// Voie locale (execute/resume)
await api.resumeExecution();
}
await loadState();
}}
onCancel={() => {
handleStopExecution();
}}
/>
</div>
)}
{/* ConfidenceDashboard déplacé dans le header */}
</div>
);

View File

@@ -0,0 +1,126 @@
// QW4 — PauseDialog : bulle de pause supervisée avec ChecklistPanel intégré.
//
// 2 modes de rendu :
// - safety_checks vide -> bulle simple legacy (Continuer / Annuler)
// - safety_checks fournis -> ChecklistPanel ; bouton Continuer désactivé
// tant qu'un check `required` n'est pas coché.
//
// Les checks `llm_contextual` portent un badge [Léa] avec evidence en tooltip.
import { useState, useMemo } from 'react';
import type { SafetyCheck } from '../types';
interface Props {
pauseMessage: string;
pauseReason?: string;
safetyChecks: SafetyCheck[];
onResume: (acknowledgedIds: string[]) => Promise<void>;
onCancel: () => void;
}
export default function PauseDialog({
pauseMessage,
pauseReason,
safetyChecks,
onResume,
onCancel,
}: Props) {
const [checked, setChecked] = useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const allRequiredOK = useMemo(() => {
return safetyChecks
.filter((c) => c.required)
.every((c) => checked[c.id] === true);
}, [safetyChecks, checked]);
const toggle = (id: string) => {
setChecked((prev) => ({ ...prev, [id]: !prev[id] }));
};
const handleResume = async () => {
setSubmitting(true);
setError(null);
try {
const acknowledgedIds = Object.entries(checked)
.filter(([, v]) => v)
.map(([k]) => k);
await onResume(acknowledgedIds);
} catch (e: any) {
setError(e?.message || 'Erreur lors de la reprise');
} finally {
setSubmitting(false);
}
};
// Backward compat : pas de checks -> bulle simple legacy
if (safetyChecks.length === 0) {
return (
<div className="pause-dialog-simple">
<p>{pauseMessage}</p>
{pauseReason && <small className="pause-reason">Raison : {pauseReason}</small>}
<div className="pause-actions">
<button onClick={() => onResume([])} disabled={submitting}>
Continuer
</button>
<button onClick={onCancel} disabled={submitting}>
Annuler
</button>
</div>
</div>
);
}
return (
<div className="pause-dialog-checks">
<h3>Pause supervisée</h3>
<p className="pause-message">{pauseMessage}</p>
{pauseReason && (
<div className="pause-reason-banner">
<strong>Raison :</strong> {pauseReason}
</div>
)}
<ul className="checklist-panel">
{safetyChecks.map((c) => (
<li key={c.id} className={`check-item ${c.required ? 'required' : 'optional'}`}>
<label>
<input
type="checkbox"
checked={!!checked[c.id]}
onChange={() => toggle(c.id)}
disabled={submitting}
/>
<span className="check-label">{c.label}</span>
{c.required && <span className="badge badge-required">obligatoire</span>}
{c.source === 'llm_contextual' && (
<span className="badge badge-lea" title={c.evidence || ''}>
Léa
</span>
)}
</label>
{c.source === 'llm_contextual' && c.evidence && (
<small className="check-evidence">-&gt; {c.evidence}</small>
)}
</li>
))}
</ul>
{error && <div className="pause-error">{error}</div>}
<div className="pause-actions">
<button
onClick={handleResume}
disabled={!allRequiredOK || submitting}
title={!allRequiredOK ? 'Coche tous les checks obligatoires' : 'Reprendre le replay'}
>
{submitting ? 'Reprise...' : 'Continuer'}
</button>
<button onClick={onCancel} disabled={submitting}>
Annuler
</button>
</div>
</div>
);
}

View File

@@ -1353,6 +1353,136 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
</>
);
case 'pause_for_human': {
// QW4 — éditeur safety_level + safety_checks (déclaratifs)
const safetyChecks = Array.isArray(params.safety_checks)
? (params.safety_checks as Array<{ id?: string; label?: string; required?: boolean }>)
: [];
return (
<>
<div className="prop-field">
<label>Message affiché à l'opérateur</label>
<textarea
rows={4}
value={String(params.message || '')}
onChange={(e) => updateParam('message', e.target.value)}
placeholder="Ex: Décision : {{dec.decision}}&#10;&#10;{{dec.justification}}"
style={{ width: '100%', fontFamily: 'monospace', fontSize: '12px' }}
/>
</div>
{/* QW4 — Niveau de sécurité */}
<div className="prop-field">
<label>Niveau de sécurité</label>
<select
value={String(params.safety_level || 'standard')}
onChange={(e) => updateParam('safety_level', e.target.value)}
>
<option value="standard">Standard (pas de LLM)</option>
<option value="medical_critical">Médical critique (LLM contextuel)</option>
</select>
</div>
{/* QW4 — Liste éditable de checks déclaratifs */}
<div className="prop-field">
<label>Checks à valider (déclaratifs)</label>
{safetyChecks.map((check, i) => (
<div key={i} className="check-editor-row">
<input
placeholder="ID (ex: check_ipp)"
value={check.id || ''}
style={{ width: '30%' }}
onChange={(e) => {
const next = [...safetyChecks];
next[i] = { ...check, id: e.target.value };
updateParam('safety_checks', next);
}}
/>
<input
placeholder="Libellé"
value={check.label || ''}
style={{ flex: 1 }}
onChange={(e) => {
const next = [...safetyChecks];
next[i] = { ...check, label: e.target.value };
updateParam('safety_checks', next);
}}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={!!check.required}
onChange={(e) => {
const next = [...safetyChecks];
next[i] = { ...check, required: e.target.checked };
updateParam('safety_checks', next);
}}
/>
Obligatoire
</label>
<button
type="button"
onClick={() => {
const next = safetyChecks.filter((_, j) => j !== i);
updateParam('safety_checks', next);
}}
title="Supprimer ce check"
>
</button>
</div>
))}
<button
type="button"
onClick={() => {
const next = [
...safetyChecks,
{ id: '', label: '', required: true },
];
updateParam('safety_checks', next);
}}
>
+ Ajouter un check
</button>
</div>
</>
);
}
case 't2a_decision':
return (
<>
<div className="prop-field">
<label>Template d'entrée (supporte {'{{var}}'})</label>
<textarea
rows={5}
value={String(params.input_template || '')}
onChange={(e) => updateParam('input_template', e.target.value)}
placeholder={'{{t0}}\n---\n{{t1}}\n{{t2}}\n{{t3}}\n{{t4}}'}
style={{ width: '100%', fontFamily: 'monospace', fontSize: '12px' }}
/>
</div>
<div className="prop-field">
<label>Variable de sortie (ex: dec)</label>
<input
type="text"
value={String(params.output_var || '')}
onChange={(e) => updateParam('output_var', e.target.value)}
placeholder="dec"
/>
</div>
<div className="prop-field">
<label>Modèle Ollama</label>
<input
type="text"
value={String(params.model || 'qwen2.5:7b')}
onChange={(e) => updateParam('model', e.target.value)}
placeholder="qwen2.5:7b"
/>
</div>
</>
);
default:
return <div className="prop-info">Pas de paramètres supplémentaires</div>;
}

View File

@@ -4491,3 +4491,86 @@ body {
.right-panel-tabbed .capture-library {
border-top: 1px solid var(--border);
}
/* === QW4 — PauseDialog & ChecklistPanel === */
.pause-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.pause-dialog-simple,
.pause-dialog-checks {
padding: 16px;
max-width: 480px;
background: #fff;
border: 2px solid #f59e0b;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25);
}
.pause-dialog-checks h3 { margin: 0 0 8px; color: #92400e; }
.pause-message { margin: 0 0 12px; }
.pause-reason-banner {
background: #fef3c7;
padding: 8px;
margin-bottom: 12px;
border-radius: 4px;
}
.pause-reason { color: #6b7280; display: block; margin-top: 4px; }
.checklist-panel {
list-style: none;
padding: 0;
margin: 0 0 12px;
}
.check-item {
padding: 6px 0;
border-bottom: 1px solid #f3f4f6;
}
.check-item.required { background: #fef9c3; }
.check-item label {
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
margin-left: 6px;
}
.badge-required { background: #dc2626; color: #fff; }
.badge-lea { background: #2563eb; color: #fff; cursor: help; }
.check-evidence {
display: block;
font-style: italic;
color: #6b7280;
margin-left: 24px;
}
.pause-error {
color: #dc2626;
padding: 8px;
background: #fef2f2;
border-radius: 4px;
margin-bottom: 8px;
}
.pause-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.pause-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* QW4 — éditeur de safety_checks dans PropertiesPanel */
.check-editor-row {
display: flex;
gap: 4px;
margin-bottom: 4px;
align-items: center;
}

View File

@@ -1,5 +1,16 @@
// Types pour l'API v3
// === QW4 — Safety checks (pause supervisée) ===
export type SafetyLevel = 'standard' | 'medical_critical';
export interface SafetyCheck {
id: string;
label: string;
required: boolean;
source: 'declarative' | 'llm_contextual';
evidence?: string | null;
}
// Mode d'exécution
export type ExecutionMode = 'basic' | 'intelligent' | 'debug' | 'verified';
@@ -133,7 +144,9 @@ export const ACTIONS: ActionDefinition[] = [
{ name: 'max_iterations', type: 'number', description: 'Nombre maximum d\'itérations' }
] },
{ type: 'pause_for_human', label: 'Pause supervisée', icon: '⏸', description: 'Léa s\'arrête et demande validation humaine via une bulle interactive (boutons Continuer / Annuler).', category: 'logic', needsAnchor: false, params: [
{ name: 'message', type: 'string', description: 'Message affiché dans la bulle (ex: "Je ne suis pas sûre du critère 3, validez-vous UHCD ?")' }
{ name: 'message', type: 'string', description: 'Message affiché dans la bulle (ex: "Je ne suis pas sûre du critère 3, validez-vous UHCD ?")' },
{ name: 'safety_level', type: 'select', description: 'Niveau de sécurité : standard (pas de LLM) ou medical_critical (LLM contextuel)' },
{ name: 'safety_checks', type: 'safety_checks_editor', description: 'Liste de checks à valider avant reprise (id, libellé, obligatoire ?). Édité dans le panneau Propriétés.' }
] },
{ type: 't2a_decision', label: 'Décision T2A (LLM)', icon: '🧠', description: 'Analyse un DPI urgences via LLM local (qwen2.5:7b par défaut) et propose FORFAIT_URGENCE ou REQUALIFICATION_HOSPITALISATION. Retourne JSON {decision, justification, elements_pour/contre, confiance}. Bench validé 100% accuracy.', category: 'logic', needsAnchor: false, params: [
{ name: 'input_template', type: 'string', description: 'DPI à analyser. Supporte le templating {{var}} pour concaténer plusieurs extractions (ex: "{{texte_motif}}\\n{{texte_examens}}\\n{{texte_notes}}")' },
@@ -312,13 +325,19 @@ export interface WorkflowSummary {
export interface Execution {
id: string;
workflow_id: string;
status: 'pending' | 'running' | 'paused' | 'completed' | 'error' | 'cancelled';
status: 'pending' | 'running' | 'paused' | 'paused_need_help' | 'completed' | 'error' | 'cancelled';
progress: number;
current_step_index: number;
completed_steps: number;
failed_steps: number;
total_steps: number;
error_message?: string;
// === QW4 — Pause supervisée (renvoyés par /replay/state quand status = paused_need_help) ===
pause_reason?: string;
pause_message?: string;
safety_checks?: SafetyCheck[];
// ID du replay (utile pour appeler /replay/resume avec acknowledged_check_ids)
replay_id?: string;
}
export interface Session {