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>
This commit is contained in:
Dom
2026-05-05 23:33:04 +02:00
parent 7c6945171e
commit af13cd80ff
5 changed files with 402 additions and 2 deletions

View File

@@ -25,6 +25,7 @@ import ExecutionOverlay from './components/ExecutionOverlay';
import type { Variable } from './components/VariableManager'; import type { Variable } from './components/VariableManager';
import RightPanel from './components/RightPanel'; import RightPanel from './components/RightPanel';
import SelfHealingDialog from './components/SelfHealingDialog'; import SelfHealingDialog from './components/SelfHealingDialog';
import PauseDialog from './components/PauseDialog';
import ConfidenceDashboard from './components/ConfidenceDashboard'; import ConfidenceDashboard from './components/ConfidenceDashboard';
import WorkflowValidation from './components/WorkflowValidation'; import WorkflowValidation from './components/WorkflowValidation';
import ReviewModal from './components/ReviewModal'; 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 */} {/* ConfidenceDashboard déplacé dans le header */}
</div> </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: default:
return <div className="prop-info">Pas de paramètres supplémentaires</div>; return <div className="prop-info">Pas de paramètres supplémentaires</div>;
} }

View File

@@ -4491,3 +4491,86 @@ body {
.right-panel-tabbed .capture-library { .right-panel-tabbed .capture-library {
border-top: 1px solid var(--border); 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 // 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 // Mode d'exécution
export type ExecutionMode = 'basic' | 'intelligent' | 'debug' | 'verified'; 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' } { 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: [ { 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: [ { 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}}")' }, { 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 { export interface Execution {
id: string; id: string;
workflow_id: string; workflow_id: string;
status: 'pending' | 'running' | 'paused' | 'completed' | 'error' | 'cancelled'; status: 'pending' | 'running' | 'paused' | 'paused_need_help' | 'completed' | 'error' | 'cancelled';
progress: number; progress: number;
current_step_index: number; current_step_index: number;
completed_steps: number; completed_steps: number;
failed_steps: number; failed_steps: number;
total_steps: number; total_steps: number;
error_message?: string; 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 { export interface Session {