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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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__)
|
||||
|
||||
Reference in New Issue
Block a user