fix(stream+vwb): chaîne replay robuste — auth, anchor type_text, lock async, drift, prompt LLM

Six modifications structurelles côté serveur, non destructives, aboutissant à un
pipeline replay bien plus stable pour la démo GHT Sud 95 (Urgences UHCD).

1. visual_workflow_builder/backend/app.py
   load_dotenv() chargeait .env (cwd) au lieu de .env.local racine projet.
   Conséquence : RPA_API_TOKEN absent après chaque restart manuel du backend
   et tous les proxies VWB→streaming échouaient en 401 « Token API invalide ».
   Charge maintenant explicitement .env.local du project root.

2. visual_workflow_builder/backend/api_v3/learned_workflows.py
   Quatre appels proxy /api/v1/traces/stream/* ne portaient pas le Bearer.
   Helper _stream_headers() factorisé et appliqué (workflows list/detail,
   workflow detail, reload-workflows).

3. visual_workflow_builder/backend/api_v3/dag_execute.py
   _ANCHOR_CLICK_TYPES excluait type_text/type_secret : pas de pre-click de
   focus avant la frappe → texte tapé sans focus → textareas vides au replay.
   Helper _inject_anchor_targeting() factorisé (centre bbox + visual_mode +
   target_spec) appliqué aux click_anchor* ET aux type_text/type_secret dès
   qu'un anchor_id est présent. Workflows historiques sans anchor sur
   type_text → comportement inchangé.

4. agent_v0/server_v1/api_stream.py — endpoint /replay/next
   _replay_lock (threading.Lock global) tenu pendant les actions serveur
   lentes (extract_text OCR ~5s, t2a_decision LLM ~8-13s). Comme le handler
   est async def, l'event loop FastAPI était bloqué : les polls clients
   timeout à 5s, leurs actions étaient popped serveur sans destinataire,
   perdues silencieusement. Mesure : 8 actions/25 perdues sur replay Urgence.

   acquire(timeout=4.5) puis run_in_executor pour libérer l'event loop
   pendant l'attente du lock ET pendant les handlers serveur synchrones.
   Pendant un t2a_decision en cours, les polls concurrents reçoivent
   immédiatement {action: null, server_busy: true} → l'agent ne timeout
   plus, aucune action n'est popped sans destinataire.

5. agent_v0/server_v1/resolve_engine.py — _validate_resolution_quality
   Drift > 0.20 par rapport aux coords enregistrées → fallback aux coords
   enregistrées même quand le template matching trouve l'image avec un
   score quasi parfait. Or un score >= 0.95 signifie que l'image EST
   visuellement à l'écran à l'endroit indiqué, le drift reflète juste
   un changement de layout (scroll, F11, redimensionnement), pas une
   erreur. Exception ajoutée : score >= 0.95 sur template_matching →
   ignore drift check, utilise position visuelle.

6. core/llm/t2a_decision.py — prompt T2A/PMSI
   Ancien prompt autorisait « Critère non validé » en fallback creux.
   Nouveau prompt impose au moins une CITATION LITTÉRALE entre « ... »
   du DPI dans chaque preuve_critereN, qu'elle soutienne ou infirme le
   critère. Si non validé : factualisation explicite (« Aucune ... »,
   « Sortie à H+2 ») citée du dossier. Sortie = preuves cliniques
   traçables et professionnelles, pas du remplissage.

État DB : aucun changement net (bbox patchés puis revertés depuis backup
visual_anchors_backup_20260501 ; by_text re-aligné sur 25003284). Le
re-enregistrement du workflow Urgence en conditions bureau standard
(Chrome normal, taille fenêtre standard) est l'étape suivante côté Dom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-02 00:32:57 +02:00
parent b584bbabc3
commit 35b27ae492
6 changed files with 206 additions and 84 deletions

View File

@@ -868,6 +868,60 @@ def _load_anchor_metadata(anchor_id: str) -> Optional[Dict]:
return None
def _inject_anchor_targeting(action: Dict, anchor_id: str) -> None:
"""Enrichit une action avec la cible visuelle (x_pct/y_pct + visual_mode/target_spec).
Mutation in-place de `action`. Utilisé pour click_anchor*, type_text et
type_secret — toute action qui doit cibler une zone visuelle précise avant
d'agir (clic ou frappe avec focus).
Sans cette injection, l'agent côté Windows ne peut pas faire le pre-click
de focus avant `_type_text`, et le texte tape dans le vide.
"""
if not anchor_id:
return
anchor_meta = _load_anchor_metadata(anchor_id)
# Coordonnées du centre du bbox (fallback si template matching échoue)
if anchor_meta:
bbox = anchor_meta.get('bounding_box', {})
orig = anchor_meta.get('original_size', {})
orig_w = orig.get('width', 1920)
orig_h = orig.get('height', 1080)
if bbox.get('x') is not None and orig_w > 0 and orig_h > 0:
cx = (bbox['x'] + bbox.get('width', 0) / 2) / orig_w
cy = (bbox['y'] + bbox.get('height', 0) / 2) / orig_h
action['x_pct'] = round(cx, 4)
action['y_pct'] = round(cy, 4)
# Image de l'ancre pour template matching côté agent
anchor_b64 = _load_anchor_image_b64(anchor_id)
if anchor_b64:
target_spec = {
'anchor_image_base64': anchor_b64,
'anchor_id': anchor_id,
}
if anchor_meta:
target_spec['anchor_bbox'] = anchor_meta.get('bounding_box', {})
target_spec['original_size'] = anchor_meta.get('original_size', {})
action['visual_mode'] = True
action['target_spec'] = target_spec
logger.info(
"Action %s : ancre '%s' chargée (%d Ko), visual_mode activé",
action.get('action_id', '?'),
anchor_id,
len(anchor_b64) // 1024,
)
else:
logger.warning(
"Action %s : ancre '%s' introuvable, fallback blind mode",
action.get('action_id', '?'),
anchor_id,
)
@api_v3_bp.route('/execute-windows', methods=['POST'])
def execute_windows():
"""Proxy les actions du workflow vers le streaming server pour exécution sur Windows.
@@ -932,45 +986,7 @@ def execute_windows():
if vwb_type in _ANCHOR_CLICK_TYPES:
anchor_id = action.get('anchor_id')
if anchor_id:
anchor_meta = _load_anchor_metadata(anchor_id)
# Calculer les coordonnées du centre du bbox (fallback si visual échoue)
if anchor_meta:
bbox = anchor_meta.get('bounding_box', {})
orig = anchor_meta.get('original_size', {})
orig_w = orig.get('width', 1920)
orig_h = orig.get('height', 1080)
if bbox.get('x') is not None and orig_w > 0 and orig_h > 0:
cx = (bbox['x'] + bbox.get('width', 0) / 2) / orig_w
cy = (bbox['y'] + bbox.get('height', 0) / 2) / orig_h
action['x_pct'] = round(cx, 4)
action['y_pct'] = round(cy, 4)
# Tenter aussi le visual_mode (template matching)
anchor_b64 = _load_anchor_image_b64(anchor_id)
if anchor_b64:
target_spec = {
'anchor_image_base64': anchor_b64,
'anchor_id': anchor_id,
}
if anchor_meta:
target_spec['anchor_bbox'] = anchor_meta.get('bounding_box', {})
target_spec['original_size'] = anchor_meta.get('original_size', {})
action['visual_mode'] = True
action['target_spec'] = target_spec
logger.info(
"Action %s : ancre '%s' chargée (%d Ko), visual_mode activé",
action.get('action_id', '?'),
anchor_id,
len(anchor_b64) // 1024,
)
else:
logger.warning(
"Action %s : ancre '%s' introuvable, fallback blind mode",
action.get('action_id', '?'),
anchor_id,
)
_inject_anchor_targeting(action, anchor_id)
# Propagation du by_text (ciblage textuel prioritaire sur template)
_by_text = params.get('by_text', '')
@@ -986,13 +1002,18 @@ def execute_windows():
action['button'] = 'right'
# ---------------------------------------------------------------
# type_text / type_secret → extraire le texte
# type_text / type_secret → extraire le texte + cibler la zone
# de saisie si une ancre visuelle est associée au step.
# Sans ancre, l'agent tape là où le focus se trouve déjà
# (compatibilité avec les workflows historiques sans anchor).
# ---------------------------------------------------------------
if vwb_type in ('type_text', 'type_secret') and 'text' in params:
action['text'] = params['text']
# Ne pas forcer un clic préalable à (0,0) si pas de coordonnées
# L'exécuteur ne cliquera que si x_pct > 0 et y_pct > 0
# (le clic de positionnement est fait par l'action click_anchor précédente)
anchor_id = action.get('anchor_id') or (
params.get('visual_anchor') or {}
).get('anchor_id')
if anchor_id:
_inject_anchor_targeting(action, anchor_id)
# ---------------------------------------------------------------
# keyboard_shortcut / hotkey → extraire les touches

View File

@@ -40,6 +40,17 @@ if _ROOT not in sys.path:
STREAMING_SERVER_URL = "http://localhost:5005"
def _stream_headers() -> Dict[str, str]:
"""Bearer token pour les appels proxy VWB → streaming server.
Retourne un dict vide si RPA_API_TOKEN n'est pas défini ; dans ce cas
les appels échoueront en 401 (auth obligatoire côté streaming).
"""
import os as _os
token = _os.environ.get("RPA_API_TOKEN", "")
return {"Authorization": f"Bearer {token}"} if token else {}
# ---------------------------------------------------------------------------
# Helpers — nom par défaut à l'import
# ---------------------------------------------------------------------------
@@ -162,6 +173,7 @@ def list_learned_workflows():
resp = http_requests.get(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/workflows",
params=params,
headers=_stream_headers(),
timeout=3,
)
if resp.ok:
@@ -526,6 +538,7 @@ def _load_core_workflow(
resp = http_requests.get(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/workflows",
params=params,
headers=_stream_headers(),
timeout=3,
)
if resp.ok:
@@ -538,6 +551,7 @@ def _load_core_workflow(
try:
detail_resp = http_requests.get(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/workflow/{workflow_id}",
headers=_stream_headers(),
timeout=5,
)
if detail_resp.ok:
@@ -573,6 +587,7 @@ def _notify_streaming_reload():
try:
http_requests.post(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/reload-workflows",
headers=_stream_headers(),
timeout=2,
)
logger.debug("Streaming server notifié pour rechargement des workflows")

View File

@@ -13,11 +13,17 @@ from flask_caching import Cache
from flask_migrate import Migrate
import os
import logging
from pathlib import Path
from logging.handlers import RotatingFileHandler
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Charger .env.local depuis la racine du projet AVANT tout : il contient
# RPA_API_TOKEN utilisé pour le proxy VWB → streaming server. Sans cela,
# le token est absent après chaque restart manuel du backend et tous les
# appels proxy renvoient 401 « Token API invalide ».
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
load_dotenv(_PROJECT_ROOT / '.env.local')
load_dotenv() # fallback .env dans cwd (n'écrase pas les vars déjà définies)
# Initialize Flask app
app = Flask(__name__)