feat(vwb): log supervised competence verdicts

This commit is contained in:
Dom
2026-05-29 18:36:06 +02:00
parent 7ad260d02f
commit aba849324a
18 changed files with 1082 additions and 5 deletions

View File

@@ -106,6 +106,24 @@ function App() {
setRuntimeVariables(status.variables as Record<string, unknown>);
}
const localExecution = status.execution;
if (status.human_pause && localExecution) {
const rawPause = status.human_pause as any;
const pause = rawPause.human_pause || rawPause;
setAppState((prev) => prev ? ({
...prev,
execution: {
...localExecution,
pause_message: pause.message || 'Validation humaine requise',
pause_reason: pause.pause_reason || 'supervised_pause',
safety_checks: [],
verdict_required: Boolean(pause.verdict_required),
verdict_endpoint: pause.verdict_endpoint,
competence_id: pause.competence_id,
},
}) : prev);
}
// Self-healing interactif: detecter si on attend un choix utilisateur
if (status.waiting_for_choice && status.candidates) {
setHealingCandidates(status.candidates);
@@ -640,12 +658,19 @@ function App() {
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)) && (
(
(appState?.execution?.safety_checks?.length ?? 0) > 0 ||
Boolean(appState?.execution?.pause_message)
))) && (
<div className="pause-dialog-overlay">
<PauseDialog
pauseMessage={appState.execution.pause_message || 'Validation requise'}
pauseReason={appState.execution.pause_reason}
safetyChecks={appState.execution.safety_checks || []}
verdictRequired={appState.execution.verdict_required}
verdictEndpoint={appState.execution.verdict_endpoint}
competenceId={appState.execution.competence_id}
executionId={appState.execution.id}
onResume={async (ackIds) => {
const replayId = appState.execution?.replay_id || appState.execution?.id;
if (replayId) {

View File

@@ -14,6 +14,10 @@ interface Props {
pauseMessage: string;
pauseReason?: string;
safetyChecks: SafetyCheck[];
verdictRequired?: boolean;
verdictEndpoint?: string;
competenceId?: string;
executionId?: string;
onResume: (acknowledgedIds: string[]) => Promise<void>;
onCancel: () => void;
}
@@ -22,6 +26,10 @@ export default function PauseDialog({
pauseMessage,
pauseReason,
safetyChecks,
verdictRequired = false,
verdictEndpoint,
competenceId,
executionId,
onResume,
onCancel,
}: Props) {
@@ -54,6 +62,57 @@ export default function PauseDialog({
}
};
const newVerdictId = (): string => {
if (window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
const value = Math.floor(Math.random() * 16);
const resolved = char === 'x' ? value : (value & 0x3) | 0x8;
return resolved.toString(16);
});
};
const submitVerdict = async (verdictKind: 'valid' | 'invalid' | 'inconclusive') => {
if (!verdictEndpoint || !competenceId) return;
setSubmitting(true);
setError(null);
try {
const response = await fetch(verdictEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
verdict_id: newVerdictId(),
verdict_kind: verdictKind,
verdict_by: 'human:dom',
context_signature: {
machine_id: `browser:${window.navigator.platform || 'unknown'}`,
screen_state_initial: '',
screen_state_after_action: '',
},
evidence: {
execution_id: executionId || '',
pause_reason: pauseReason || '',
},
source: {
frontend: 'vwb_v4',
execution_id: executionId || '',
},
comments: `Verdict humain VWB: ${verdictKind}`,
}),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || response.statusText);
}
await onResume([]);
} catch (e: any) {
setError(e?.message || 'Erreur lors du verdict');
} finally {
setSubmitting(false);
}
};
// Backward compat : pas de checks -> bulle simple legacy
if (safetyChecks.length === 0) {
return (
@@ -61,6 +120,19 @@ export default function PauseDialog({
<p>{pauseMessage}</p>
{pauseReason && <small className="pause-reason">Raison : {pauseReason}</small>}
<div className="pause-actions">
{verdictRequired && verdictEndpoint && competenceId && (
<>
<button onClick={() => submitVerdict('valid')} disabled={submitting}>
Valide
</button>
<button onClick={() => submitVerdict('invalid')} disabled={submitting}>
Invalide
</button>
<button onClick={() => submitVerdict('inconclusive')} disabled={submitting}>
Incertain
</button>
</>
)}
<button onClick={() => onResume([])} disabled={submitting}>
Continuer
</button>
@@ -110,6 +182,19 @@ export default function PauseDialog({
{error && <div className="pause-error">{error}</div>}
<div className="pause-actions">
{verdictRequired && verdictEndpoint && competenceId && (
<>
<button onClick={() => submitVerdict('valid')} disabled={submitting}>
Valide
</button>
<button onClick={() => submitVerdict('invalid')} disabled={submitting}>
Invalide
</button>
<button onClick={() => submitVerdict('inconclusive')} disabled={submitting}>
Incertain
</button>
</>
)}
<button
onClick={handleResume}
disabled={!allRequiredOK || submitting}

View File

@@ -191,7 +191,9 @@ export async function getExecutionStatus(): Promise<{
total: number;
original_bbox?: { x: number; y: number; width: number; height: number };
error?: string;
human_pause?: Record<string, unknown>;
};
human_pause?: Record<string, unknown> | null;
}> {
return request('GET', '/execute/status');
}

View File

@@ -350,6 +350,9 @@ export interface Execution {
pause_reason?: string;
pause_message?: string;
safety_checks?: SafetyCheck[];
verdict_required?: boolean;
verdict_endpoint?: string;
competence_id?: string;
// ID du replay (utile pour appeler /replay/resume avec acknowledged_check_ids)
replay_id?: string;
}