backup: snapshot post-démo GHT 2026-05-19
Some checks failed
tests / Lint (ruff + black) (push) Successful in 1m50s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped

Backup état complet après enregistrement vidéo démo de bout en bout.
À utiliser comme point de référence pour la consolidation post-démo.

Changements majeurs de la session 18-19 mai :
- AIVA-URGENCE : page autonome avec preset URL + auto-focus chain
- Workflow Demo_urgence_3_db : merge linux_db + steps AIVA + pause humaine NoMachine
- Bypass LLM (static_result / static_text) dans replay_engine
  pour démos déterministes sans appel Ollama
- Fix api_stream:3013 — replay_paused au premier polling /next
- dag_execute : lift duration_ms vers top-level pour wait runtime
- NPM bypass auth /aiva-urgence/ via location ^~ (proxy_host/10.conf hors git)
- scripts/cancel-replays.sh — workaround Stop VWB qui ne purge pas la queue

Anchors visuels (468) forcés dans le commit pour garantir restorabilité.
DB workflows actuelle + ~12 .bak DB de la journée incluses.

Sujets identifiés pour consolidation post-démo (TODO) :
1. Bug VWB recapture anchor ne régénère pas le PNG
2. Léa client accumule état mémoire (restart périodique requis)
3. Stop VWB ne purge pas la queue serveur (lien manquant vers /replay/cancel)
4. Bug coord client mss tronqué 2560x60 → mapping Y cassé
5. delay_before/delay_after ignorés au runtime (fix partiel duration_ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-19 14:55:06 +02:00
parent f2212e77e3
commit 5ea4960e65
627 changed files with 211348 additions and 169 deletions

View File

@@ -1,10 +1,94 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import type { Step, ActionType } from '../types';
import { ACTIONS } from '../types';
import { getAnchorThumbnailUrl } from '../services/api';
import { getAnchorThumbnailUrl, getAnchorOriginalUrl } from '../services/api';
import AIModelSelector from './AIModelSelector';
import type { AITaskType } from '../services/ollamaService';
/**
* Aperçu de la miniature d'une ancre avec tooltip d'agrandissement au survol.
* - Affiche la miniature compressée (80×50)
* - Au hover : tooltip flottant avec l'image originale (max 600px de large)
* - Positionnement intelligent (à droite si place, sinon à gauche, idem haut/bas)
* - Fallback sur thumbnail si l'image originale n'est pas accessible (onError)
*/
function AnchorPreview({ anchorId }: { anchorId: string }) {
const [hover, setHover] = useState(false);
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
const [largeSrc, setLargeSrc] = useState<string>(getAnchorOriginalUrl(anchorId));
const thumbRef = useRef<HTMLImageElement | null>(null);
// Recalcule la position du tooltip à chaque entrée souris pour éviter les débordements.
const handleEnter = () => {
const el = thumbRef.current;
if (!el) {
setHover(true);
return;
}
const rect = el.getBoundingClientRect();
const TOOLTIP_W = 620; // 600 image + ~20 padding/borders
const TOOLTIP_H = 480;
const MARGIN = 12;
const vw = window.innerWidth;
const vh = window.innerHeight;
// Horizontal : à droite de la miniature si possible, sinon à gauche
let left = rect.right + MARGIN;
if (left + TOOLTIP_W > vw - MARGIN) {
left = rect.left - TOOLTIP_W - MARGIN;
}
if (left < MARGIN) {
left = MARGIN; // dernier recours : collé à gauche de la fenêtre
}
// Vertical : aligné sur le haut de la miniature, mais clampé dans la fenêtre
let top = rect.top;
if (top + TOOLTIP_H > vh - MARGIN) {
top = vh - TOOLTIP_H - MARGIN;
}
if (top < MARGIN) {
top = MARGIN;
}
setTooltipStyle({ left: `${left}px`, top: `${top}px` });
setHover(true);
};
const handleLeave = () => setHover(false);
// Si l'image originale 404 (anciennes ancres sans fichier original), fallback sur thumbnail
const handleLargeError = () => {
if (largeSrc !== getAnchorThumbnailUrl(anchorId)) {
setLargeSrc(getAnchorThumbnailUrl(anchorId));
}
};
return (
<>
<img
ref={thumbRef}
src={getAnchorThumbnailUrl(anchorId)}
alt="Ancre"
className="anchor-thumb-zoomable"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
/>
{hover && (
<div className="anchor-tooltip" style={tooltipStyle} role="tooltip">
<img
src={largeSrc}
alt="Ancre (taille originale)"
onError={handleLargeError}
/>
<div className="anchor-tooltip-caption">
Ancre {anchorId.slice(0, 12)}
</div>
</div>
)}
</>
);
}
interface Props {
step: Step | null;
onUpdateParams: (id: string, params: Record<string, unknown>) => void;
@@ -294,6 +378,64 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
</>
);
case 'extract_text_scroll':
return (
<>
<div className="prop-field">
<label>Nom de la variable</label>
<input
type="text"
value={String(params.variable_name || '')}
onChange={(e) => updateParam('variable_name', e.target.value)}
placeholder="texte_dpi_complet"
/>
<small style={{ color: '#666', display: 'block', marginTop: 4 }}>
Concatène le texte haut + bas de page (Ctrl+End / Ctrl+Home automatiques).
</small>
</div>
<div className="prop-field">
<label>Méthode d'extraction</label>
<select
value="ocr"
disabled
onChange={() => { /* readonly v1 */ }}
>
<option value="ocr">OCR (image)</option>
</select>
</div>
<div className="prop-field">
<label>Pause après scroll (ms)</label>
<input
type="number"
min={0}
step={100}
value={
params.scroll_pause_ms !== undefined && params.scroll_pause_ms !== null && params.scroll_pause_ms !== ''
? Number(params.scroll_pause_ms)
: ''
}
onChange={(e) => {
const v = e.target.value;
updateParam('scroll_pause_ms', v === '' ? undefined : Number(v));
}}
placeholder="500"
/>
<small style={{ color: '#666', display: 'block', marginTop: 4 }}>
Délai après Ctrl+End avant la 2 capture. Défaut 500ms (Wikipedia). DPI Citrix lent : 1500-2000ms.
</small>
</div>
<div className="prop-field">
<label>Regex de filtrage (optionnel)</label>
<input
type="text"
value={String(params.regex_filter || '')}
onChange={(e) => updateParam('regex_filter', e.target.value)}
placeholder="Ex: \d{4}-\d{2}-\d{2}"
/>
</div>
</>
);
case 'extract_table':
return (
<>
@@ -307,25 +449,24 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
/>
</div>
<div className="prop-field">
<label>Format de sortie</label>
<select
value={String(params.output_format || 'json')}
onChange={(e) => updateParam('output_format', e.target.value)}
>
<option value="json">JSON</option>
<option value="csv">CSV</option>
<option value="array">Array</option>
</select>
<label>Filtre regex (optionnel)</label>
<input
type="text"
value={String(params.pattern || '')}
onChange={(e) => updateParam('pattern', e.target.value)}
placeholder="ex: ^25\d{6}$ (IPP) ou ^[A-Z][a-z]+$ (noms)"
/>
<small className="field-hint">Regex Python seuls les tokens OCR qui matchent sont retournés. Vide = tous les tokens.</small>
</div>
<div className="prop-field checkbox">
<label>
<input
type="checkbox"
checked={Boolean(params.include_headers)}
onChange={(e) => updateParam('include_headers', e.target.checked)}
/>
Inclure les en-têtes
</label>
<div className="prop-field">
<label>Nombre max d'entrées (optionnel)</label>
<input
type="number"
value={params.limit !== undefined && params.limit !== null && params.limit !== '' ? Number(params.limit) : ''}
onChange={(e) => updateParam('limit', e.target.value === '' ? '' : Number(e.target.value))}
placeholder="20"
/>
<small className="field-hint">0 ou vide = sans limite.</small>
</div>
</>
);
@@ -952,6 +1093,15 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
/>
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
</div>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.output_var || '')}
onChange={(e) => updateParam('output_var', e.target.value)}
placeholder="resume_patient"
/>
</div>
<div className="prop-field">
<label>Modèle Ollama (optionnel)</label>
<input
@@ -1510,7 +1660,7 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
<label>Ancre visuelle (obligatoire)</label>
{step.anchor_id ? (
<div className="anchor-preview">
<img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" />
<AnchorPreview anchorId={step.anchor_id} />
<span className="anchor-ok"> Définie</span>
</div>
) : (
@@ -1526,7 +1676,7 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
<div className="prop-anchor">
<label>Ancre visuelle (optionnelle)</label>
<div className="anchor-preview">
<img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" />
<AnchorPreview anchorId={step.anchor_id} />
<span className="anchor-ok"> Définie</span>
</div>
</div>

View File

@@ -101,6 +101,11 @@ function StepNode({ data, selected }: StepNodeProps) {
/>
<div className="step-node-header">
{typeof step.order === 'number' && Number.isFinite(step.order) && (
<span className="step-node-order" title={`Étape ${step.order + 1}`}>
{step.order + 1}
</span>
)}
<span className="step-icon">{action?.icon || '?'}</span>
<span className="step-label">{action?.label || step.action_type}</span>
</div>

View File

@@ -57,8 +57,8 @@ function extractWorkflowVariables(steps: Step[]): WorkflowVariable[] {
for (const step of steps) {
const p = step.parameters || {};
// Producteurs : output_variable ou variable_name
const outVar = (p.output_variable || p.variable_name) as string | undefined;
// Producteurs : output_variable, output_var ou variable_name
const outVar = (p.output_variable || p.output_var || p.variable_name) as string | undefined;
if (outVar && typeof outVar === 'string') {
const existing = vars.get(outVar);
if (existing) {
@@ -71,7 +71,7 @@ function extractWorkflowVariables(steps: Step[]): WorkflowVariable[] {
// Consommateurs : chercher {{var}} dans toutes les valeurs string des params
for (const val of Object.values(p)) {
if (typeof val === 'string') {
for (const match of val.matchAll(/\{\{(\w+)\}\}/g)) {
for (const match of val.matchAll(/\{\{(\w+)(?:\.[^}]*)?\}\}/g)) {
const varName = match[1];
const existing = vars.get(varName);
if (existing) {

View File

@@ -137,6 +137,15 @@ export function getAnchorThumbnailUrl(anchorId: string): string {
return `${API_BASE}/anchor/${anchorId}/thumbnail`;
}
/**
* URL de l'image originale (non compressée) d'une ancre.
* Endpoint Flask : GET /api/anchor-images/{anchor_id}/original
* (cf. visual_workflow_builder/backend/api/anchor_images.py)
*/
export function getAnchorOriginalUrl(anchorId: string): string {
return `/api/anchor-images/${anchorId}/original`;
}
// Execution
export async function startExecution(
workflowId?: string,
@@ -360,12 +369,18 @@ export async function exportForLea(
}
// Bibliothèque de captures — persistance serveur
//
// Format v2 (avril 2026) : le PNG HD vit sur disque côté backend, le JSON ne
// contient que les métadonnées + un thumbnail JPEG 640x360 q85 pour la grille.
// L'affichage plein écran utilise `fullImageUrl` (PNG HD).
export interface CaptureLibraryItem {
id: string;
capture: Capture;
timestamp: string;
sessionId?: string;
favorite?: boolean;
format?: 'v2'; // v2 = PNG HD côté backend
fullImageUrl?: string; // chemin GET vers le PNG HD (v2 uniquement)
}
export async function getCaptureLibrary(): Promise<{
@@ -379,3 +394,42 @@ export async function saveCaptureLibrary(library: CaptureLibraryItem[]): Promise
}> {
return request('POST', '/capture/library', { library });
}
/**
* Upload une capture HD (PNG) dans la bibliothèque.
* Le serveur stocke le PNG sur disque, génère le thumbnail 640x360 et retourne
* l'item complet avec `fullImageUrl` pointant vers le PNG.
*/
export async function uploadCaptureLibraryItem(params: {
pngBlob: Blob;
id?: string;
sessionId?: string;
timestamp?: string;
favorite?: boolean;
}): Promise<{ item: CaptureLibraryItem }> {
const form = new FormData();
form.append('file', params.pngBlob, `${params.id || 'capture'}.png`);
if (params.id) form.append('id', params.id);
if (params.sessionId) form.append('sessionId', params.sessionId);
if (params.timestamp) form.append('timestamp', params.timestamp);
if (params.favorite !== undefined) form.append('favorite', String(params.favorite));
const response = await fetch(`${API_BASE}/capture/library/upload`, {
method: 'POST',
body: form,
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Échec upload capture");
}
return data;
}
export async function deleteCaptureLibraryItem(id: string): Promise<{ id: string }> {
const response = await fetch(`${API_BASE}/capture/library/${id}`, { method: 'DELETE' });
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Échec suppression capture");
}
return data;
}

View File

@@ -23,7 +23,12 @@
*/
import type { Capture } from '../types';
import { getCaptureLibrary, saveCaptureLibrary } from './api';
import {
getCaptureLibrary,
saveCaptureLibrary,
uploadCaptureLibraryItem,
deleteCaptureLibraryItem,
} from './api';
export interface LibraryItem {
id: string;
@@ -31,6 +36,8 @@ export interface LibraryItem {
timestamp: Date | string; // JSON.parse ne restaure pas les Date
sessionId?: string;
favorite?: boolean;
format?: 'v2';
fullImageUrl?: string;
}
const STORAGE_KEY = 'captureLibrary_v2';
@@ -177,7 +184,12 @@ export function saveLibrary(library: LibraryItem[]): void {
}
}
// Sauvegarder sur le backend (fire-and-forget, pas de blocage)
// Sauvegarder sur le backend (fire-and-forget, pas de blocage).
// ATTENTION : pour les items v2, le backend a déjà reçu le PNG HD via
// /capture/library/upload. Ici on ne fait que synchroniser les métadonnées
// (timestamp, favorite, ordre, etc.). Les champs format/fullImageUrl doivent
// être préservés pour que le backend reconnaisse les items v2 et n'aille pas
// les purger.
saveCaptureLibrary(library.map((item) => ({
id: item.id,
capture: item.capture,
@@ -186,15 +198,118 @@ export function saveLibrary(library: LibraryItem[]): void {
: String(item.timestamp),
sessionId: item.sessionId,
favorite: item.favorite,
format: item.format,
fullImageUrl: item.fullImageUrl,
}))).catch((e) => {
console.warn('[CaptureLibrary] Échec sauvegarde backend', e);
});
}
/**
* Convertit une base64 PNG (sans préfixe data:) en Blob, pour upload multipart.
*/
function base64PngToBlob(base64Png: string): Blob {
const raw = atob(base64Png);
const len = raw.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = raw.charCodeAt(i);
}
return new Blob([bytes], { type: 'image/png' });
}
/**
* Ajoute une capture HD à la bibliothèque (format v2).
*
* Upload le PNG HD au backend (source de vérité) et retourne un LibraryItem
* avec `fullImageUrl` pour l'affichage plein écran. Le `capture.screenshot_base64`
* du LibraryItem est REMPLACÉ par le thumbnail 640x360 q85 généré par le serveur,
* de sorte que la grille reste légère sans perdre la qualité plein écran.
*
* Si l'upload échoue, on retombe sur l'ancien comportement (compression locale)
* pour ne pas perdre la capture.
*/
export async function addCaptureToLibrary(
capture: Capture,
meta: { id?: string; sessionId?: string; favorite?: boolean } = {}
): Promise<LibraryItem> {
const id = meta.id || `cap_${Date.now()}`;
const timestamp = new Date().toISOString();
try {
const blob = base64PngToBlob(capture.screenshot_base64);
const { item } = await uploadCaptureLibraryItem({
pngBlob: blob,
id,
sessionId: meta.sessionId,
timestamp,
favorite: meta.favorite,
});
return {
id: item.id,
capture: {
...capture,
// La base64 stockée localement = thumbnail 640x360 q85 (pour la grille)
// L'affichage plein écran passera par item.fullImageUrl
screenshot_base64: item.capture.screenshot_base64 ?? capture.screenshot_base64,
width: item.capture.width ?? capture.width,
height: item.capture.height ?? capture.height,
},
timestamp,
sessionId: meta.sessionId,
favorite: meta.favorite ?? false,
format: 'v2',
fullImageUrl: item.fullImageUrl,
};
} catch (e) {
console.warn('[CaptureLibrary] Upload HD échoué, fallback compression locale', e);
// Fallback : compression locale comme avant — entrée legacy v1, pas de plein écran HD
const compressed = await compressThumbnail(capture.screenshot_base64);
return {
id,
capture: { ...capture, screenshot_base64: compressed },
timestamp,
sessionId: meta.sessionId,
favorite: meta.favorite ?? false,
};
}
}
/**
* Supprime une capture de la bibliothèque (PNG HD côté serveur si v2).
*/
export async function removeCaptureFromLibrary(id: string, isV2: boolean): Promise<void> {
if (isV2) {
try {
await deleteCaptureLibraryItem(id);
} catch (e) {
console.warn('[CaptureLibrary] Échec suppression backend', e);
}
}
}
/**
* Construit l'URL plein écran pour un LibraryItem.
* - Format v2 : pointe vers le PNG HD servi par le backend
* - Format legacy v1 : retombe sur la base64 stockée (pixélisée)
*/
export function getLibraryItemFullSrc(item: LibraryItem): string {
if (item.format === 'v2' && item.fullImageUrl) {
return item.fullImageUrl;
}
// Legacy v1 : data URL base64
const b64 = item.capture.screenshot_base64;
return b64.startsWith('data:') ? b64 : `data:image/png;base64,${b64}`;
}
/**
* Compresse une image base64 (PNG ou JPEG) en JPEG basse qualité pour la
* bibliothèque. Retourne la base64 JPEG sans le préfixe data: URL.
* Fallback : renvoie la base64 d'origine si la compression échoue.
*
* NOTE (avril 2026) : conservé pour le fallback de addCaptureToLibrary.
* Pour les nouvelles captures, préférer addCaptureToLibrary qui upload le HD.
*/
export async function compressThumbnail(base64Png: string): Promise<string> {
return new Promise((resolve) => {

View File

@@ -499,6 +499,25 @@ body {
transform: scale(1.2);
}
/* Badge numéro d'ordre du step (1-indexed) */
.step-node-order {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 11px;
background: var(--primary);
color: #ffffff;
font-size: 14px;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Tooltip documentation outil */
.step-node-tooltip {
position: absolute;
@@ -838,6 +857,56 @@ body {
border: 1px solid var(--border);
}
/* Miniature cliquable/zoomable : indique au survol qu'un agrandissement est dispo */
.anchor-thumb-zoomable {
cursor: zoom-in;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.anchor-thumb-zoomable:hover {
border-color: var(--primary) !important;
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.25);
}
/* Tooltip flottant agrandi (position absolue dans le viewport) */
.anchor-tooltip {
position: fixed;
z-index: 9999;
background: var(--bg-paper);
border: 2px solid var(--primary);
border-radius: 6px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
pointer-events: none; /* le hover reste sur la miniature, pas sur le tooltip */
max-width: 620px;
max-height: 480px;
}
.anchor-tooltip img {
display: block;
max-width: 600px;
max-height: 420px;
width: auto;
height: auto;
border-radius: 3px;
/* damier pour distinguer le fond transparent éventuel d'une zone réelle blanche */
background-image:
linear-gradient(45deg, #eee 25%, transparent 25%),
linear-gradient(-45deg, #eee 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #eee 75%),
linear-gradient(-45deg, transparent 75%, #eee 75%);
background-size: 12px 12px;
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
}
.anchor-tooltip-caption {
margin-top: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
text-align: center;
font-family: monospace;
}
.anchor-ok {
color: var(--success);
font-size: 0.85rem;

View File

@@ -50,6 +50,7 @@ export type ActionType =
| 'drag_drop_anchor'
| 'keyboard_shortcut'
| 'extract_text'
| 'extract_text_scroll'
| 'extract_table'
| 'screenshot_evidence'
| 'visual_condition'
@@ -125,8 +126,14 @@ export const ACTIONS: ActionDefinition[] = [
{ name: 'output_var', type: 'string', description: 'Nom de la variable de sortie (ex: texte_motif). Réutilisable via {{nom}}.' },
{ name: 'paragraph', type: 'boolean', description: 'Regrouper en paragraphes (true) ou lignes brutes (false)' }
] },
{ type: 'extract_table', label: 'Extraire tableau', icon: '📊', description: 'Extrait un tableau structuré depuis la zone de l\'ancre.', category: 'data', needsAnchor: true, params: [
{ name: 'variable_name', type: 'string', description: 'Nom de la variable pour stocker le tableau' }
{ type: 'extract_text_scroll', label: 'Extraire texte (scroll auto)', icon: '📜', description: 'Capture haut + bas de page automatiquement (Ctrl+End / Ctrl+Home) puis OCR. Concatène le tout dans une seule variable. Idéal pour pages longues type DPI où le contenu est sous le scroll.', category: 'data', needsAnchor: false, params: [
{ name: 'variable_name', type: 'string', description: 'Nom de la variable de sortie (ex: texte_dpi_complet). Contient haut + bas concaténés.' },
{ name: 'scroll_pause_ms', type: 'number', description: 'Pause après Ctrl+End avant la 2e capture (ms). Défaut 500 (Wikipedia). DPI Citrix lent : 1500-2000.' }
] },
{ type: 'extract_table', label: 'Extraire tableau', icon: '📊', description: 'Extrait une liste structurée (top→bottom) depuis l\'écran via OCR + filtre regex. Retourne une liste de strings utilisable en boucle ou templating. Idéal pour récupérer une liste d\'IPP, de codes, de noms depuis un tableau.', category: 'data', needsAnchor: true, params: [
{ name: 'variable_name', type: 'string', description: 'Nom de la variable pour stocker la liste extraite (réutilisable via {{nom}})' },
{ name: 'pattern', type: 'string', description: 'Regex Python optionnelle pour filtrer les tokens OCR (ex: ^25\\d{6}$ pour des IPP). Vide = tous les tokens.' },
{ name: 'limit', type: 'number', description: 'Nombre max d\'entrées retournées (0 ou vide = sans limite)' }
] },
{ type: 'screenshot_evidence', label: 'Capture preuve', icon: '📸', description: 'Prend une capture d\'écran comme preuve d\'exécution.', category: 'data', needsAnchor: false, params: [
{ name: 'filename', type: 'string', description: 'Nom du fichier image de sortie' }
@@ -233,6 +240,7 @@ export const ACTIONS: ActionDefinition[] = [
{ type: 'llm_generate', label: 'Générer texte', icon: '✍️', description: 'Génère du texte libre à partir d\'un prompt et contexte.', category: 'llm', needsAnchor: false, params: [
{ name: 'prompt', type: 'string', description: 'Prompt de génération' },
{ name: 'context', type: 'string', description: 'Contexte additionnel' },
{ name: 'output_var', type: 'string', description: 'Variable de sortie (ex: resume_patient)' },
{ name: 'model', type: 'string', description: 'Modèle Ollama' }
] },