164 Commits

Author SHA1 Message Date
Dom
5ea4960e65 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>
2026-05-19 14:55:06 +02:00
Dom
f2212e77e3 feat(t2a): bench_t2a_dryrun.py + t2a_mappings.py - mini-bench standalone 11 dossiers POC
Bench standalone qui exécute build_dpi_enriched + appel LLM sur les 11
dossiers POC GHT Sud 95 (docs/clients/ght_sud_95/mockup_easily_assure/data.js),
sans passer par Demo_urgence_2 ni Léa/Windows. Permet de mesurer la
convergence durée/décision Python ↔ LLM sur un panel représentatif AVANT
d'écrire le garde-fou serveur du commit 2.

core/llm/t2a_mappings.py :
- Module partagé TERRAIN_VERS_T2A (4 entrées validées par Dom 12/05)
- Importé par le bench, sera importé aussi par le garde-fou serveur commit 2
- Cas non mappés volontairement documentés (Retour structure d'origine,
  chaîne vide pour statut_attente)

scripts/bench_t2a_dryrun.py :
- Parsing data.js via node (vm.runInContext) → 11 dossiers en JSON
- Reconstruction d'un dpi_raw plat simulant l'OCR scroll auto :
  bandeau Easily Assure répété 5x (1 par onglet) + sections motif /
  examens / imagerie / notes médicales / Synthèse Urgences au format
  LIBELLÉ    VALEUR
- NE bypasse PAS build_dpi_enriched : le dpi_raw est texte plat re-parsé
  par la fonction (test de robustesse réel du parser regex)
- Appel LLM déterministe : temperature=0, seed=42, model=gemma4:31b-cloud
- Vérification empirique du respect du seed (2 appels successifs sur 1er
  dossier, comparaison decision/durée/justif) → warning si bruit cloud
- 4 traces structurées par dossier dans logs/t2a_dryrun/<IPP>_<ts>.log :
  [t2a_dryrun_metadata] / [t2a_dryrun_prompt] / [t2a_dryrun_response]
  ou [t2a_dryrun_error] en cas d'échec API
- Filet data_quality_warning (incohérence âge déclaré vs date naissance,
  motif vs diagnostic principal, décision vide) — filet, pas analyse
  exhaustive ; signale sans corriger (anonymisation v1 incertaine)
- Tableau récap stdout 9 colonnes + CSV scripts/bench_t2a_dryrun_<ts>.csv
- Stats agrégées : convergence durée X/N, convergence décision X/N
  mappés, liste détaillée des divergences avec pointeurs vers logs
- Recommandation auto : réécrire PROMPT 3 ou non selon convergence durée

Activation : T2A_DRYRUN=1 python scripts/bench_t2a_dryrun.py
Options : --ipp <IPP> (1 dossier), --skip-seed-check

Smoke test pré-commit (sans LLM) : parsing + dpi_raw + build_dpi_enriched
sur les 11 dossiers → 11/11 metadata complets, 0 parsing_warning,
durées calculées de 2.0h à 12.02h, décompo décisions terrain conforme
(7 Consultation + 1 Hosp + 1 UHCD + 1 Transfert + 1 Retour structure).

Brief complet : docs/handoffs/2026-05-12_brief_S1_build_dpi_enriched.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:55:57 +02:00
Dom
9872f4510c feat(t2a): build_dpi_enriched - extraction déterministe horaires + classifications cliniques
Préprocesseur Python qui injecte un bloc FAITS_CALCULÉS en tête du DPI
avant l'appel LLM, pour neutraliser l'hallucination de durée (bug "23h"
sur cas MOREL, confusion avec "depuis 23h" de l'Observ. IDE Urg).

Extrait depuis le bandeau Easily Assure et la Synthèse Urgences :
- âge (dateutil.relativedelta)
- date admission / sortie + durée passage (format humain + décimal)
- CCMU / GEMSA libellé complet (parser multi-ligne)
- priorité IAO, mode de venue / médicalisation / mode d'entrée
- diagnostic principal
- decision_terrain + orientation_terrain (metadata only, jamais injectés
  dans le prompt pour ne pas biaiser le LLM)

Retour tuple (dpi_enriched, metadata) pour permettre les garde-fous
serveur Python ↔ LLM au commit 2.

Robustesse :
- re.search 1re occurrence + WARNING si bandeau divergent multi-occurrences
- Synthèse Urgences priorité sur bandeau pour dates
- Valeur exigée sur même ligne que label (évite capture de section title)
- Cas négatif (horaires absents) → "NON CALCULABLE" + parsing_warnings
- Jamais de crash, retour tuple toujours valide

Tests : 4/4 verts (golden MOREL string + metadata, négatif sortie absente,
DPI vide). Pas de régression sur tests/integration/test_t2a_extract.py.

Brief complet : docs/handoffs/2026-05-12_brief_S1_build_dpi_enriched.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:49:49 +02:00
Dom
2eeaa806bb docs(handoff): clôture session 2026-05-09
Session de 6h consacrée au fix DETTE-006 (bug d'échelle pixel
grounding). Bilan : 2/5 commits fix faits (smart_resize + refactor
parser bbox_2d), 3/5 bloqués par découverte DETTE-010 (divergence
factor 28 vs 32 sur checkpoint Qwen3-VL-8B-Instruct, à instruire
demain matin).

Effets de bord positifs : registre dette technique créé
(14 entrées P1/P2/P3), investigation mémoire visuelle orpheline
documentée, infra clarifiée (vLLM absent, Transformers direct retenu,
checkpoint Qwen3-VL-8B fp16 téléchargé 17 GB).

Voir docs/handoffs/2026-05-09_session_audit.md pour détail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:53:26 +02:00
Dom
df5ad59330 docs(dette): MAJ DETTE-010 (config trouvé, divergences) + création DETTE-014 (smart_resize calé sur mauvaise référence)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:46:46 +02:00
Dom
bfbf0f9c3e refactor(grounding): centralise parser bbox_2d
Avant : 4 occurrences de parsing en cascade dans resolve_engine.py
(L840-885, L903-915, L2569-2580, ~110 lignes au total).

Après : centralisation dans core/grounding/bbox_parser.py avec
paramètre formats= permettant de filtrer les formats reconnus
selon le contrat sémantique de chaque site d'appel.

Préservation des contrats sémantiques (strict no-op) :
- Occ 1+2 (cascade principale) : tous formats (par défaut)
- Occ 3 (retry multi-image) : formats={"xy_json", "raw_array"}
  pour respecter le prompt qui impose {"x": NNN, "y": NNN} in pixels
- Occ 4 (_locate_popup_button) : formats={"bbox_2d"} pour respecter
  le prompt qui demande "bounding box"

Notes :
- Mini-bug Occ 3 retry multi-image (division systématique sans
  heuristique x>1, produisait coordonnées aberrantes ~0.0004 si
  VLM retournait déjà du pourcentage) corrigé incidemment via
  centralisation. Pas de régression possible (résultat précédent
  aberrant par construction).
- Occ 4 : bbox_2d strict 4-coords élargi à bbox_2d 2 ou 4 coords.
  Contrat sémantique "bounding box" respecté ; un point 2-coords
  interprété comme centre de bbox.

Tests : 26 cas dans test_bbox_parser.py (tous formats × cascade
+ filtre formats= + validated). 121 PASS / 0 FAIL sur le périmètre
refactor (5 fichiers ciblés).

Net : -96 lignes dans resolve_engine.py, +120 lignes module
+ 250 lignes tests.

refs DETTE-006 (étape 2/5 du fix smart_resize)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:30:25 +02:00
Dom
ecc5a233a7 docs(dette): création DETTE-013 env tests dev local
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:49:57 +02:00
Dom
293e54b4e6 docs(dette): création DETTE-012 (vLLM hors scope) + maj DETTE-010 (cible Transformers + AWQ)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:47:21 +02:00
Dom
0d7bcd18ac feat(grounding): module smart_resize officiel Qwen3-VL
Module pur core/grounding/smart_resize.py implémentant la formule
smart_resize officielle (transformers.qwen2_vl.image_processing_qwen2_vl,
utilisée par Qwen3VLProcessor pour les images via wrap Qwen2VLImageProcessor).

Helpers exposés : _round_by_factor, _floor_by_factor, _ceil_by_factor.
Constantes : FACTOR_DEFAULT=28, MIN_PIXELS_DEFAULT=3136,
MAX_PIXELS_DEFAULT=1_003_520, MAX_RATIO_DEFAULT=200.

Tests : tests/unit/test_smart_resize.py — 32 cas, 100% coverage sur le
module (mesure via coverage API directe, pytest-cov bloqué par bug cv2
préexistant tracé dans DETTE-011).

refs DETTE-006 (étape 1/5 du fix smart_resize)
refs DETTE-007 (création de la 3ème implémentation, à unifier post-démo)
refs DETTE-010 (vérif preprocessor_config.json checkpoint Qwen3-VL-8B
                bloquante avant Étape 2)
refs DETTE-011 (bug cv2 contourné pour mesure coverage)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:42:47 +02:00
Dom
4df1ba5779 docs(dette): création DETTE-011 bug cv2 Python 3.12
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:42:36 +02:00
Dom
e9702b4df9 docs(dette): création DETTE-010 vérif preprocessor_config Qwen3-VL
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:08:08 +02:00
Dom
e0b47e4518 docs(refs): commit groupé docs de référence session 2026-05-08
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:32:52 +02:00
Dom
5dc20cc85b docs(dette): rectif mapping DETTE-005 + DETTE-008/009 + investigation mémoire visuelle orpheline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:23:45 +02:00
Dom
88ed103de5 docs(dette): création registre dette technique + 7 entrées rétroactives
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:43:27 +02:00
Dom
194853cebb docs(handoff): clôture session 2026-05-08
3 commits du jour : pré-check OCR réactivé + instrumenté + bug
spatial documenté. Plan demain : fix smart_resize vLLM ciblé
selon MIGRATION_VLM_PLAN_2026-05-09.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:33:35 +02:00
Dom
626823d327 docs(bug): pré-check OCR spatialement aveugle - dette identifiée
Bug découvert pendant test live du 2026-05-08.
_text_match_fuzzy valide la présence du texte dans le crop (560×560 px)
sans vérifier sa position au point cliqué. Sur onglets serrés (3 px),
valide à tort les clics adjacents.

À fixer post-démo Kerella - Option B préférée
(bboxes EasyOCR + distance).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:31:56 +02:00
Dom
2e76b44ff3 feat(observability): log positif pré-check OCR pour traçabilité runtime
Avant : succès silencieux (seul rejet loggé)
Après : log INFO à chaque appel avec by_text, position, méthode,
observed, is_valid, latence

Permet de valider en runtime que le pré-check OCR tourne bien
sur les résolutions resolved=True (cf commit 731b5bcae).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:23:32 +02:00
Dom
731b5bcae2 fix(replay): réactivation pré-check OCR avec calibrage chirurgical
- Flag RPA_ENABLE_TEXT_PRECHECK défaut true (vs false pendant prépa démo)
- radius_px 200 → 280 (englobe textes longs type "Synthèse Urgences")
- min_token_ratio 0.60 → 0.50 (tolère onglets fragmentés par OCR)
- Commentaire historique restructuré avec procédure troubleshooting
- Docstring synchronisée avec valeur effective

Audit complet : docs/AUDIT_CONTROLES_DEBRANCHES_2026-05-08.md
Réactive contrôle #3 sur 5 identifiés (les 4 autres restent désactivés
pour aujourd'hui — décision chirurgicale 1 par 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:27:21 +02:00
Dom
8648e375fe docs(handoff): session audit 2026-05-08 - controles debranches 2026-05-08 11:37:40 +02:00
Dom
56e869c467 fix(replay): bug TypeError log + flag pré-check OCR off par défaut (démo GHT)
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Diagnostic post-bench E2E (rapport docs/E2E_TEST_RUN_2026-05-08.md) :

1. BUG SILENCIEUX MAJEUR (api_stream.py:4549) — quand le pré-check OCR
   rejette, mon code de rejet hier soir met x_pct=None / y_pct=None.
   Le log structuré faisait result.get('x_pct', 0):.4f → None:.4f →
   TypeError → réponse "analysis_error" qui MASQUE le vrai motif
   "rejected_text_mismatch". Conséquence : pendant toute la session
   du 7 mai soir, les rejets pré-check ont été silencieusement
   transformés en erreurs analyse → cascade locale Léa V1 → clic au pif.
   Fix : `(result.get('x_pct') or 0):.4f` traite None | None | 0
   uniformément.

2. FLAG ENV pré-check OFF par défaut — le pré-check
   _validate_text_at_position introduit hier soir a 2 défauts
   identifiés par le bench E2E sur 8 click_anchor :
   * radius_px=200 trop petit pour les tabs à 2 tokens (Examens
     cliniques, Synthèse Urgences) — OCR voit un crop tronqué
     "Maquette POC ler en cours Codage Statistiques" qui n'inclut
     pas "Examens" → fuzzy match 1/2 = 50% < seuil 0.60 → REJET.
     À radius 300/400 le mot est inclus → match passe.
   * min_token_ratio=0.60 trop strict pour cibles 2 tokens.

   Solution démo : flag env RPA_ENABLE_TEXT_PRECHECK (défaut "false").
   Le pré-check est désactivé par défaut → retour au comportement
   stable d'avant-hier (hybrid_text_direct ≥ 0.80 utilisé direct,
   exemption drift préservée). Code et fonction _validate_text_at_position
   conservés en place pour reprise post-démo après calibrage radius
   adaptatif (≈ 0.17 × min(screen_w, screen_h)) et token_ratio descendu
   à 0.50.

   Pour ré-activer en dev/test : `RPA_ENABLE_TEXT_PRECHECK=true`
   dans .env.local ou env du service rpa-streaming.

Inclus aussi :
- docs/E2E_TEST_RUN_2026-05-08.md (rapport agent test E2E ~1700 mots)
- tests/e2e/urgence_aiva_demo_expected.yaml (tolérances re-écrites)
- tests/e2e/fixtures/urgence_aiva_demo/live/*.png (8 fixtures
  recapturées headless 1920x1080 pour itérer demain)
- _ocr_inventory.json + _run_resolve_results.json (raw runs)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:09:23 +02:00
Dom
f8dc3c3af4 docs(audit): rapport curateur mémoire Claude — santé index 7 mai 2026
Some checks failed
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Audit exhaustif des 101 fichiers .md de ~/.claude/projects/-home-dom-ai-rpa-vision-v3/memory/.
Aucun fichier mémoire modifié — diagnostic seul, à valider par Dom.

Constats critiques :
- MEMORY.md = 273 lignes (limite chargement 200) → ~73 lignes
  silencieusement perdues à chaque démarrage de session
- ~50% des fichiers réels ne sont pas indexés dans MEMORY.md
- Référence cassée : MEMORY ligne 257 pointe vers
  feedback_pull_not_push.md qui n'existe pas
- 3 feedback NEW créés le 7 mai (non ajoutés à l'index) sont
  précisément les règles qui sécurisent la démo GHT jeudi 8 mai :
  * feedback_orphans_are_projections.md
  * feedback_verifier_avant_apres_clic.md
  * architecture_lea_v1_find_text_client.md

Risque concret : un Claude futur (sans ces feedback en mémoire active)
va reproposer les bourdes que Dom a explicitement nommées hier soir :
"re-capturer les ancres" et "nettoyer les modules orphelins".

Top 7 feedback proposés en TOP CRITICAL :
1. prendre_le_temps (DEVISE)
2. orphans_are_projections (NEW)
3. verifier_avant_apres_clic (NEW)
4. lea_v1_find_text_client (NEW architecture)
5. ollama_vs_transformers
6. no_rustine
7. anonymisation_stricte

Proposition réorganisation 4 zones :
- 🔥 TOP CRITICAL ~12 fichiers
- 📌 ACTIVE ~25 fichiers
- 📚 REFERENCE ~12 fichiers
- 🗄️ ARCHIVE ~50 fichiers

Compactage cible : MEMORY.md → 150 lignes (marge 50 avant
retrigger limite chargement).

4 décisions ouvertes pour Dom (cf rapport §8) :
1. feedback_pull_not_push.md — créer ou supprimer la référence
2. Valider l'archivage des ~45 fichiers proposés
3. Trancher 4 fichiers INCERTAIN (dashboard_config, data_extraction,
   objectif_6avril, actor_*)
4. Approuver 7 règles de gestion future (1 feedback = 1 violation
   observée, MEMORY ≤ 180 lignes, rotation sessions > 21j, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 05:11:08 +02:00
Dom
ca81850a20 docs(audit): rapport médecin DIM senior + TIM sur arbre décisionnel UHCD/Forfait
Some checks failed
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Audit du cœur métier de la démo GHT Sud 95 (8 mai 2026), du point de vue
d'un médecin DIM senior qui se ferait challenger par le DSI Carvella.
Confronte : arbre officiel RPU UHCD IA.pptx (7 slides), code métier
agent_chat/urgences_orchestrator.py + core/llm/t2a_decision.py, prompts
LLM en place, 11 dossiers anonymisés data.js, bench Dom 18 modèles,
référentiels officiels (SFMU 2024, instructions DGOS, arrêtés 2021/2024
ATIH, recommandations IPAQSS).

Findings critiques (avant démo) :

1. Bug silencieux modèle — t2a_decision.py:28 met DEFAULT_MODEL=qwen2.5:7b
   (64 % accuracy au bench Dom) alors que gemma3:27b-cloud (73 %) est
   retenu par BENCH_T2A_DECISION_11DOSSIERS. Si T2A_MODEL pas posé via
   env, on tourne sur le mauvais modèle. 9 points d'accuracy laissés
   sur la table.

2. Règle de combinaison incorrecte dans le prompt — code dit "au moins
   2 sur 3 ⇒ REQUALIFICATION" alors que l'arbre PPTX d'Eaubonne dit
   "si oui aux 3 critères". Cause probable des faux positifs UHCD du
   bench (25003284, 25056615). Quick win = passer à 3/3.

3. Trous métier dans le prompt : aucune mention CCMU, GEMSA, durée,
   mode de sortie, type de forfait précis (SU2/PE2/Standard). C'est
   exactement où se loge le ROI 100k€/mois. 5 quick wins prompt
   rédigés prêts à coller dans §E.4 du rapport.

4. Trois dossiers à NE PAS montrer en démo (25056615, 25151530, 25003475,
   25048485) — trop ambigus, hallucinations LLM, structure non tranchée.

5. Trois dossiers à mettre en avant (25003451 SU2 plaie 2h, 25010621
   PE2 laryngite, 25003364 UHCD pneumo SLA) — décisions justes,
   justifications béton.

Argumentaire pré-démo : 9 questions/réponses face à Carvella
(instructions DGOS, SFMU, cumul SU2+PE2, hallucination LLM, ROI 100k€).

Roadmap post-démo pour Amina : bench étendu 50-100 dossiers + 3
inférences/dossier, fine-tune t2a-gemma3-27b, distinction forfaits
fine, module ATIH-aware, couverture pédia/géria/psy, sortie contre
avis, transferts.

Note : aucun changement de code dans ce commit. Rapport seul. Les
quick wins identifiés (3/3, modèle par défaut, prompts enrichis)
sont à appliquer demain matin avec validation Dom + Amina.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:21:13 +02:00
Dom
35fd6cf4c5 test(e2e): harness replay reproductible — mock client Léa V1 contre serveur réel
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Réduit le cycle debug d'un workflow de 1-2 min (replay manuel via
Windows + Léa V1 + maquette) à ~2-5s (mock client Linux contre
serveur de streaming localhost:5005). 30-60× plus rapide.

Architecture :
- tools/test_replay_e2e.py — harness CLI (~580 lignes), reproduit la
  chaîne réelle : VWB /api/v3/execute-windows → streaming /replay/raw
  → boucle /replay/next côté harness avec resolve_target sur un
  screenshot fixture → POST /replay/result. Pas de modification serveur.
- tests/e2e/test_urgence_aiva_demo.py — wrapper pytest (smoke).
- tests/e2e/urgence_aiva_demo_expected.yaml — référence générée par
  --export-expected, pour comparaison régression auto.
- pytest.ini — ajout du marqueur e2e.

Usage :
    python tools/test_replay_e2e.py --execution-mode autonomous --max-iter 120 --verbose
    python tools/test_replay_e2e.py --single-step 8 --shot <heartbeat>.png
    python tools/test_replay_e2e.py --expected tests/e2e/urgence_aiva_demo_expected.yaml
    pytest tests/e2e -v -m e2e

Sortie : tableau Markdown step × méthode × score × pos × status × diag.

Limitations connues (extensions post-démo) :
- Une seule fixture screenshot pour tout le replay → click_anchor réalistes
  échouent dès qu'on dépasse l'écran fixture. Carte step_id → fixture à venir.
- extract_text/table/t2a_decision exécutés côté serveur, observables mais
  pas modifiables.
- Pas de simulation screenshot_after → ReplayVerifier (Critic VLM) ne tourne pas.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:11:07 +02:00
Dom
7847a0e829 feat(agent_v1): toast paused supervisée Tkinter + Plan B + threshold FIND-TEXT 0.75
Some checks failed
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Démo GHT 8 mai 2026 — Dom utilise UNIQUEMENT Léa V1 sur Windows pendant
la démo (pas le frontend VWB Linux), donc les pause_message du serveur
doivent être visuellement évidents sur l'écran Windows. Modifications
client validées par Dom + redéployées via SCP (procédure 2026-04-28).

1. ui/paused_toast.py (NEW) — Toast Tkinter custom autonome :
   Toplevel topmost overrideredirect, fond bleu Léa (#2563EB), 380px,
   haut-droite, auto-close 15s, click-to-close. Re-pin -topmost à
   100/500/2000 ms (Windows démet le flag quand le focus part). Rate
   limit 3s sur message identique. Aucune dépendance externe (tkinter
   stdlib uniquement). Thread-safe : root.after si Tk root existe,
   sinon Tk dédié dans un daemon thread. Remplace plyer qui s'avère
   silencieux sur Windows 11 (Focus Assist + manque app-id COM).

2. ui/chat_window.py — _add_paused_bubble force la visibilité :
   La fenêtre Léa démarrait avec root.withdraw() — la bulle paused
   était bien rendue mais invisible. Ajout deiconify+lift+focus_force
   avant render, plus appel à show_paused_toast en complément.

3. ui/notifications.py — niveau BLOCAGE déclenche aussi le toast :
   Quand notify_message reçoit un MessageUtilisateur.BLOCAGE (cible
   non trouvée, mode apprentissage, fenêtre incorrecte), appelle
   show_paused_toast en plus de plyer. Couvre la branche supervision
   client (executor.py:1012) qui ne passe pas par Plan B serveur.

4. core/executor.py — Plan B replay_paused (lignes 1812-1850) :
   Intercepte data["replay_paused"]=True dans la réponse /replay/next,
   appelle chat_window._add_paused_bubble si _chat_window_ref défini,
   sinon fallback notifier.notify. Idempotence via _last_pause_msg_shown
   pour ne pas spammer (1 toast par (replay_id, message) unique).
   Threshold FIND-TEXT _find_text_on_screen : 0.50 → 0.75 pour rejeter
   les faux positifs (placeholders italiques, tabs voisins) et tomber
   en mode apprentissage humain plutôt qu'un clic au pif.

5. main.py — Wiring ChatWindow → Executor pour Plan B.

6. tools/test_lea_toast.py + ui/_test_paused_toast.py (NEW) — Scripts
   de test isolé pour validation visuelle rapide sans relancer un
   replay complet (commande dans les docstrings).

Validé visuellement sur DESKTOP-58D5CAC. Toasts apparaissent en haut-
droite, fond bleu, auto-close 15s. Test isolé Dom : 3 toasts successifs
visibles sans accroc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:03:51 +02:00
Dom
40440f1ca0 fix(replay): cure régression b584bbabc — fallback recorded_coords aveugle
Trois changements complémentaires dans la cascade de résolution serveur,
finis ce soir 7 mai pour la démo GHT 8 mai. Restaure le comportement strict
d'avril 2026 (workflow qui passait 20 fois d'affilée sans incident).

1. resolve_engine.py — _validate_resolution_quality (lignes 2255-2289) :
   Le commit b584bbabc du 1er mai 2026 ("fix(stream): démo UHCD") avait
   transformé le rejet strict (resolved=False, method="rejected_drift_*")
   en fallback aveugle (resolved=True, method="fallback_recorded_coords",
   coords du record). Symptôme observé : Léa cliquait sur "Dossier en
   cours" du menu au lieu de "Synthèse Urgences" du tab — le VLM Quick
   Find Ollama hallucinait à (0.526, 0.918), drift dépassé, fallback
   ratait. Restauré : resolved=False explicite, le client passe en
   pause supervisée comme prévu (philosophie échec = apprentissage).

2. resolve_engine.py — exemption high-confidence élargie :
   L'exemption drift>0.20 IGNORÉ ne couvrait que template_matching ≥ 0.95
   (commit 35b27ae49 du 2 mai). Étendue à hybrid_text_direct ≥ 0.80 :
   un OCR direct qui trouve le texte cible exact à score 0.80+ est aussi
   sûr qu'un template à 0.95 — la position est sémantiquement vraie,
   le drift reflète juste un changement de layout (résolution écran,
   refonte UI, scroll), pas une erreur de résolution.

3. resolve_engine.py + api_stream.py — pré-check OCR sémantique :
   Nouvelle fonction _validate_text_at_position (singleton EasyOCR fr+en,
   crop 200px autour de la coord résolue, fuzzy match 60% des tokens
   ≥3 caractères de l'expected_text). Câblée dans api_stream.py juste
   après _validate_resolution_quality. Si le by_text attendu n'est PAS
   présent dans la zone autour de la coord résolue → resolved=False
   method="rejected_text_mismatch" → pause supervisée.

Pattern Verification-Aware Planning (state of the art 2026 — voir
recommandations agent archéologue + agent SOTA review) : le serveur
ne renvoie une coord que s'il est sémantiquement sûr du résultat.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:03:18 +02:00
Dom
7233df2bb9 fix(replay): câblage execution_mode supervised + seuil large fallback heartbeat
Deux corrections liées au scenario démo Urgence GHT (workflow lecture
multi-onglets + t2a_decision + pause_for_human + saisies dans Codage) :

1. Mode supervised propagé jusqu'au pipeline replay
---------------------------------------------------

Symptôme constaté ce 7 mai : Léa lit les onglets, t2a_decision tourne
(variable `dec` présente avec decision="FORFAIT_URGENCE"), mais la
pause_for_human est SKIPPÉE silencieusement et les saisies type_text
s'enchaînent dans le mauvais écran.

Cause : api_stream.py:2140 passait `params={}` codé en dur lors de la
création du replay_state. Conséquence : le code en aval qui lit
`replay_state.params.execution_mode` (api_stream.py:2964) avait toujours
le défaut "autonomous" → branche QW4 :

    # Mode autonome sans safety_checks → skip (comportement legacy)
    logger.info("pause_for_human ignorée (mode autonome)")

Modifications :
- RawReplayRequest gagne un champ `params: Optional[Dict[str, Any]]`
- start_raw_replay propage `request.params or {}` à _create_replay_state
- dag_execute.execute_windows force par défaut
  `data['params']['execution_mode'] = 'supervised'` quand le frontend
  ne précise rien (cas démo VWB → Windows). Override possible.

Conséquence : la pause_for_human du workflow Urgence déclenche bien la
PauseDialog VWB ("Décision : {{dec.decision_court}}"). Le médecin valide
ou annule avant que les saisies type_text ne s'exécutent dans Codage.

Note pour la démo réelle (post-aujourd'hui) : le scénario crédible
veut que Léa soit déclenchée depuis SON chat (port 5004), pas depuis
VWB. C'est un autre commit à venir — pour l'instant VWB suffit pour
le développement (cf. handoff session).

2. Seuil détection image tronquée élargi
----------------------------------------

Le seuil initial (height < 200 OR width < 400) ne capturait que les
cas extrêmes 2560x60 / 600x72. Mais le client envoie aussi 622x856
(Edge en fenêtre réduite ?) qui passait sous le radar. Élargi à
height < 800 OR width < 1200 — un écran moderne fait toujours ≥
1920x1080, donc le seuil est sain.

Sans ce fallback élargi, _resolve_target_sync recevait une image
trop petite pour matcher l'anchor → cascade VLM hallucinante.
2026-05-07 10:34:29 +02:00
Dom
f62fda575f fix(stream): /resolve_target — fallback heartbeat full si image client tronquée
Bug client constaté ce 2026-05-07 sur PC Windows 192.168.1.11 (agent V1) :
mss.monitors[1] retourne parfois une image tronquée type 2560x60, 2560x108,
600x72 — possiblement la barre des tâches Windows confondue avec un monitor,
ou un état mss corrompu. Reproduit même PC en mono physique. Cause exacte
non isolée côté client.

Sans cette image, _resolve_target_sync ne peut rien résoudre :
- Template matching échoue (anchor 104x31 vs image 600x72)
- OCR direct ne trouve pas la cible (texte hors de l'image tronquée)
- VLM Quick Find hallucine systématiquement la même position
- Fallback recorded_coords clique au mauvais endroit

Conséquence reproduite hier soir : "Léa clique partout au pif"
(cf. session_20260506_handoff_v2.md).

Filet de sécurité côté serveur : si l'image reçue est anormalement
tronquée (height < 200 ou width < 400), le serveur la remplace par le
dernier heartbeat full screen avant la cascade _resolve_target_sync.

Sources de fallback dans l'ordre :
1. _last_heartbeat (mémoire, peuplé par /stream/image en runtime)
2. Scan disque data/training/live_sessions/*/bg_*/shots/heartbeat_*.png
   (utile après restart serveur ou si l'agent V1 ne polle pas)

Validé en isolation : image tronquée 600x60 → fallback heartbeat 2560x1600
→ template matching score 0.999 → coords (0.0312, 0.3500) = exactement
la position de l'IPP cible '25003284' en première ligne d'Easily Assure.

Bug client à traiter post-démo. Le fallback heartbeat reste utile en
roadmap autonome (résilience aux états mss transitoires).

Note : également retiré un import os local redondant dans le finally
(masquait la variable globale et provoquait UnboundLocalError dans
le scope du bloc fallback).
2026-05-07 09:31:07 +02:00
Dom
22c0a2ba61 revert: désactiver self-healing Win+D auto (cercle vicieux)
Revert effectif du commit c969f93a2.

Le Win+D auto au retry 1 produit un cercle vicieux quand combiné avec
le VLM-first qui hallucine systématiquement (positions répétitives
type 0.529/0.874 avec confidence 0.93 sans justification) :

  click rate (cible mal localisée par VLM) → no_screen_change
  → Win+D auto → minimise Easily Assure
  → retry click → cible plus visible (Easily masquée par Win+D)
  → no_screen_change → Win+D encore → boucle infernale

Reproduit ce 2026-05-06 sur le workflow Urgence : 10 Win+D dispatchés
en moins de 2 minutes. Régression majeure ressentie par Dom :
"clic partout au pif, aucune action contrôlée".

L'idée du self-healing par gesture reste valide mais demande :
1. un déclenchement plus sélectif (genre overlay/popup détecté
   visuellement, pas no_screen_change générique)
2. ou un Alt+Tab plutôt que Win+D (fait passer la fenêtre arrière
   sans minimiser l'app cible)
3. ou une vraie analyse "y a-t-il une fenêtre qui obstrue ma cible"
   avant de décider du gesture

À retravailler post-démo avec un vrai détecteur d'obstruction.
2026-05-06 20:31:31 +02:00
Dom
6fdedbfe9d fix(vwb): execute-windows route vers la machine la plus active (pas alphabétique)
Quand le frontend ne passe pas de machine_id explicite, le backend VWB
auto-sélectionne une machine Windows en interrogeant /api/v1/traces/
stream/machines. Le code prenait la première de la liste sans tri, donc
l'ordre dépendait de l'ordre arbitraire renvoyé par le streaming server.

Conséquence reproduite ce 2026-05-06 : un replay du workflow Urgence a
été dispatché vers DESKTOP-ST3VBSD_windows alors que l'agent V1 actif
polait depuis DESKTOP-58D5CAC_windows. /replay/next ne dispatchait
aucune action puisque state.machine_id != polling_machine_id.
Symptôme côté Dom : "rien ne se passe sur Windows".

Correction : tri explicite par last_activity desc avant sélection.
La machine retenue est désormais celle qui a heartbeaté le plus
récemment (= celle qui POLLE actuellement le serveur).

Le workflow.machine_id (machine d'origine d'enregistrement) reste
distinct de la cible d'exécution : un workflow enregistré sur PC A
peut être rejoué sur PC B grâce au pipeline 100% visuel qui recalcule
anchors et coordonnées selon la résolution courante. C'était la
vraie intention architecturale, masquée par le bug de tri.
2026-05-06 20:23:44 +02:00
Dom
c969f93a23 fix(replay): self-healing Win+D auto au retry 1 (verification_failed)
Audit project-quality-guardian (2026-05-06) Cas #2 : le mécanisme
qui invoquait gesture_catalog.win_minimize_all (Win+D) en cas
d'échec de grounding a été archivé le 24/04 dans
_archive/dead_code_20260424/core/visual/rpa_integration_manager.py
(_attempt_self_healing_resolution). Le catalogue
agent_chat/gesture_catalog.py:84 reste intact mais orphelin —
aucun caller actif.

Conséquence : quand une fenêtre/popup obstrue la cible, Léa
retente N fois la même action ratée puis pose une pause supervisée,
alors qu'un Win+D ("Afficher le bureau") règle souvent le problème
en 200 ms.

L'audit proposait observe_reason_act.py mais ce module est utilisé
uniquement par /execute/instruction (lui aussi sans client actif,
Cas #10). Le bon point d'insertion dans le pipeline replay actif
est _schedule_retry (replay_engine.py) — la fonction qui construit
la liste d'actions à réinjecter en tête de queue avant chaque retry.

Modification :

Au next_retry == 1 ET reason in ("verification_failed",
"no_screen_change"), insertion en tête de queue de :

  1. Action key_combo {keys: ["super", "d"]} (format reconnu par
     agent_v1/core/executor.py:1151), tagué
     _recovery_gesture: "win_minimize_all" pour audit.
  2. Wait 500 ms pour laisser l'OS terminer l'animation Win+D.
  3. Le retry de l'action originale.

Au retry 2 et au-delà, comportement inchangé (wait 2s + retry).

Tests : 27/27 baseline sprint QW verts.
2026-05-06 19:27:16 +02:00
Dom
1cbec2806e fix(resolve): rebrancher hybrid_text_direct dans _resolve_target_sync
Audit project-quality-guardian (2026-05-06) : la fonction
_resolve_by_ocr_text (resolve_engine.py:1447) existait déjà mais
n'était appelée QUE depuis _resolve_with_precompiled_order (V4),
endpoint sans client côté frontend (Cas #5 du même audit). La
cascade legacy _resolve_target_sync sautait directement d'étape 0
(grounding-window) → étape 0' (template icônes) → étape 1 (VLM
Quick Find) sans tenter l'OCR direct.

Conséquence reproduite ce 2026-05-06 sur le workflow Urgence :
chaque action visuelle avec by_text payait 2-23 s de VLM Quick
Find (ui-tars-1.5-7b-q8_0 sur Ollama) au lieu de <500 ms d'OCR
direct, total replay > 10 min vs quelques secondes attendues.
Constat utilisateur : "habituellement on est plutôt à quelques
secondes". Régression silencieuse.

Modification :

Étape 0.5 ajoutée entre l'étape 0' (template icônes) et l'étape 1
(VLM Quick Find). Si by_text_strict est non vide, appel à
_resolve_by_ocr_text — fonction docTR existante, cache singleton
_V4_OCR_PREDICTOR, score 1.0 si match exact, 0.9 si mot exact,
0.8 si contenu. Seuil de retour : 0.80 (cohérent avec
_RESOLUTION_MIN_SCORES["hybrid_text_direct"]).

Le method retourné est rebadgé "hybrid_text_direct" pour cohérence
avec :
- _RESOLUTION_MIN_SCORES (seuil 0.80, ligne 2092)
- agent_v0/agent_v1/core/executor.py:1534 (client Windows)
- logs Learning historiques ([hybrid_text_direct])

Tests : 39/39 sprint QW + grounding/resolver verts.
2026-05-06 19:24:53 +02:00
Dom
864530c851 fix(stream): _async_replay_lock helper + 17 endpoints async non-bloquants
Suite directe des commits 35b27ae49 (lock async sur /replay/next) et
87dbe8c5f (get_replay_status non-bloquant) qui n'avaient traité que
2 endpoints sur les 19 utilisant _replay_lock dans api_stream.py.

Reproduit aujourd'hui en pré-démo : un replay urgences a réussi
extract_text + t2a_decision (50s, OK), puis a hang sur l'action
suivante. start_raw_replay (POST /replay) du nouveau replay a tenté
`with _replay_lock:` synchrone à la ligne 2085 → MainThread asyncio
gelé → tous les endpoints derrière. Stack via py-spy confirmée.

Le pattern systémique : 17 sites `with _replay_lock:` synchrones
dans des handlers `async def` (start_replay, start_raw_replay,
replay_from_session, enqueue_single_action, launch_replay_from_plan,
get_next_action [×3], report_action_result [×5], register_error_callback,
list_replays, resume_replay, cancel_replay). Chacun gèle l'event
loop FastAPI dès qu'un autre thread tient le lock.

Modifications :

1. Helper _async_replay_lock(timeout=4.5) (api_stream.py:516).
   Acquire via run_in_executor (event loop libre pendant l'attente),
   timeout 4.5s puis HTTPException 503 plutôt que gel infini.
   Sémantique acquire+release identique au `with` synchrone.

2. Remplacement automatisé des 17 sites async :
   `with _replay_lock:` → `async with _async_replay_lock():`
   2 sites sync intentionnellement préservés (cleanup loop ligne 689,
   chat_status_provider ligne 5048 — pas dans des handlers async).

3. Import contextlib ajouté en haut du fichier.

Tests : 27/27 baseline sprint QW verts, /health 200 (3ms),
/replays 200 (2ms — endpoint qui utilise le nouveau helper).
2026-05-06 18:06:42 +02:00
Dom
d1ebf62217 fix(infra): durcissement headless — pyautogui robuste + cleanup .service
Suite à la mise à jour système qui a basculé Dom de Xorg vers Wayland,
les 4 services systemd côté serveur partaient en boucle restart :
pyautogui levait DisplayConnectionError / KeyError(DISPLAY) à l'import
dans 3 modules, mais l'except n'attrapait qu'ImportError → crash fatal.

Le contournement « ajouter DISPLAY=:1 + XAUTHORITY=/run/user/1000/gdm/
Xauthority dans .service » introduit fin avril était fragile : chemin
invalide en Wayland (Mutter utilise un Xauthority à suffixe aléatoire
qui change à chaque login). Le bon fix est de rendre les imports
pyautogui robustes — le serveur n'a aucun usage légitime de pyautogui,
c'est le client Agent V1 Windows qui pilote souris/clavier.

Modifications :

1. Élargi `except ImportError` → `except Exception` pour pyautogui :
   - agent_chat/autonomous_planner.py
   - core/execution/input_handler.py
   - core/execution/observe_reason_act.py
   (action_executor.py était déjà robuste avec except Exception.)

2. Retiré DISPLAY/XAUTHORITY des 4 .service (rustines) :
   - rpa-streaming.service
   - rpa-vision-v3-{api,worker,dashboard}.service
   Block grounding (RPA_GROUNDING_SOCKET) préservé (initiative
   séparée de partage VRAM, in-flight).

PYAUTOGUI_AVAILABLE=False est désormais attendu côté serveur Linux ;
les chemins aval (action_executor, autonomous_planner) gèrent déjà
ce cas via des branches "actions simulées" / "pyautogui non disponible".

Prépare la roadmap autonome (Léa daemon Linux + VM Windows) qui
tournera headless via systemd au boot, sans dépendre d'aucune
session graphique active.

Tests : 27/27 baseline sprint QW verts.
2026-05-06 17:19:18 +02:00
Dom
87dbe8c5ff fix(stream): get_replay_status non-bloquant + bornage actions serveur
Suite du commit 35b27ae49 (lock async sur /replay/next) qui n'avait
traité que la moitié du problème. Le sprint QW4 (commit f5c33477f)
a recâblé le polling frontend PauseDialog vers /replay/{replay_id} →
get_replay_status, qui gardait un `with _replay_lock:` synchrone.
Conséquence : dès qu'une action serveur (extract_text/extract_table/
t2a_decision) tient le lock, l'event loop FastAPI gèle entièrement
(heartbeats Windows, polls replay/next, get_replay_status, tout).

Reproduit aujourd'hui en pré-démo : un replay urgences a fait
extract_text → la queue suivante a tenu le lock → polling VWB sur
get_replay_status a bloqué le MainThread asyncio → 23 minutes de
gel total (py-spy a confirmé MainThread sur api_stream.py:4117).

Modifications :

1. get_replay_status : acquire timeboxé 0.5s via run_in_executor
   (même pattern que /replay/next ligne 2815). Si le lock est tenu,
   retour immédiat {status: "busy"} → le frontend retentera dans 1s.
   Aucun cas où ce poll bloque l'event loop.

2. Actions serveur lignes 2994/3000/3006 : enveloppées dans
   asyncio.wait_for(timeout=180). Borne dure pour qu'un hang
   d'EasyOCR / Ollama / I/O ne tienne plus jamais le lock
   indéfiniment. TimeoutError est rattrapée par l'except Exception
   existant → queue.pop(0) → on continue.

Tests : 27/27 baseline sprint QW verts.
2026-05-06 17:19:05 +02:00
Dom
0a02a6ec9c feat(qw4): bench rigoureux LLM safety_checks → gemma4:latest par défaut
Some checks failed
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Bench 5 modèles × 5 scénarios × cold+warm sur RTX 5070 :
- gemma4:latest : warm 2.9s, JSON 92%, détection 46% → gagnant
- qwen2.5vl:7b : warm 6.6s, détection 23% (trop lent)
- qwen2.5vl:3b : warm 2.0s, détection 8% (vérifie pour vérifier)
- medgemma:4b : warm 0.5s, détection 0% (refuse de signaler) → mauvais
  défaut initial, corrigé
- qwen3-vl:8b : 0% JSON valide (ignore format=json Ollama) → écarté

Modifications safety_checks_provider.py :
- RPA_SAFETY_CHECKS_LLM_MODEL défaut: medgemma:4b → gemma4:latest
- RPA_SAFETY_CHECKS_LLM_TIMEOUT_S défaut: 5 → 7 (warm 2.9s + marge)

Doc complète : docs/BENCH_SAFETY_CHECKS_2026-05-06.md
Script : tools/bench_safety_checks_models.py (reproductible, ~10-15 min)

Limite assumée : 46% de détection. À présenter en démo comme aide médecin,
pas certification. Amélioration V2 = prompt plus dirigé sur champs à vérifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:23:09 +02:00
Dom
83be93e121 chore(qw): cleanup post-review (préfixes BUS, événements monitor, import io)
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
- safety_checks_provider : tous les logger.warning d'échec LLM préfixés
  [BUS] lea:safety_checks_llm_failed avec une raison spécifique
  (exception, http_status, timeout, network, json_decode).
- monitor_router : émission [BUS] lea:monitor_invalid_index si l'index
  explicite passé dans l'action est hors limites de monitors_geometry,
  et [BUS] lea:monitor_unavailable si focus actif demandé mais introuvable.
  Ces deux events permettent au bus de tracer chaque fallback de la cascade
  de routage QW1.
- safety_checks_provider : import io supprimé (inutilisé).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:08:22 +02:00
Dom
f5c33477f0 fix(qw4): câblage polling frontend → streaming pour PauseDialog
Avant ce fix, le frontend VWB ne savait pas qu'un replay Agent V1 (Windows)
était en pause supervisée : le seul polling (App.tsx) interrogeait
/execute/status (exécution locale Linux) et n'avait jamais l'info
safety_checks / pause_message du replay distant.

Côté backend (dag_execute.py) :
- ajout du proxy GET /api/v3/replay/state/<replay_id> qui forward vers
  /api/v1/traces/stream/replay/<id> avec Bearer token.

Côté frontend :
- ExecutionControls : nouvelle prop onWindowsReplayStarted, appelée avec
  le replay_id retourné par /api/v3/execute-windows.
- App.tsx : nouveau state streamingReplayId + useEffect qui poll
  /api/v3/replay/state/<id> toutes les secondes et fusionne status,
  pause_message, pause_reason, safety_checks dans appState.execution.
  Le PauseDialog existant s'affiche donc automatiquement quand
  status = paused_need_help.

Le polling s'arrête quand le replay est completed/error/cancelled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:06:20 +02:00
Dom
b1a3aa16f1 fix(qw1): enrichir heartbeat Windows avec monitor_index + monitors_geometry
Avant ce fix, le _heartbeat_loop côté Agent V1 deploy Windows
n'enrichissait pas son payload, donc QW1 multi-écran ne s'activait sur Windows
que via les events window_capture (déclenchés par les clics), pas en continu.

La source agent_v0/agent_v1/main.py portait déjà l'enrichissement (commit 2d71e2a24)
mais le snapshot deploy/windows_client/agent_v1/main.py n'avait pas été synchronisé.

Désormais chaque heartbeat porte monitor_index + monitors_geometry, le serveur
peut donc résoudre l'écran cible en permanence, même sans clic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:02:11 +02:00
Dom
0bcfddbbc4 docs(qw): plan de smoke tests manuels pour validation 2026-05-06
Some checks failed
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Plan exécutable seul par Dom : 9 sections (préflight, QW1 mono/multi-écran,
QW2 boucle, QW4 backward/déclaratif/medical_critical, bus events, kill-switches,
rollback) avec checklist OK/KO et procédures d'urgence en pleine démo.

Validation pour démo GHT (1ère sem mai 2026).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:01:21 +02:00
Dom
aa47172f0f docs(qw): synthèse de livraison QW suite mai 2026
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Doc condensée des 3 quick wins livrés (QW1 multi-écrans, QW2 LoopDetector,
QW4 safety_checks hybrides) avec :
- procédures kill-switch et rollback
- table des env vars
- smoke tests manuels à effectuer avant démo GHT
- statut composant par composant

Pointe vers spec et plan d'exécution complets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:48:26 +02:00
Dom
65da557310 feat(qw4): hook safety_checks_provider + extension /replay/resume avec acquittements
Some checks failed
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
replay_state enrichi de safety_checks, checks_acknowledged, pause_reason,
pause_payload (audit trail).

Branche supervisée pause_for_human :
- appel build_pause_payload() avant bascule paused_need_help
- log [BUS] lea:safety_checks_generated (count, sources)
- fallback safe sur exception (pause sans checks plutôt que crash)
- déclenchement si safety_level/safety_checks déclarés OU execution_mode != autonomous
- sinon comportement legacy (skip silencieux)

POST /replay/resume :
- accepte body { acknowledged_check_ids: [...] }
- vérifie tous les checks required acquittés, sinon 400 required_checks_missing
- stocke checks_acknowledged comme audit trail
- nettoie safety_checks/pause_payload après reprise

Proxy VWB /api/v3/replay/resume → streaming /replay/{id}/resume (forward bearer
token + acknowledged_check_ids).

Backward 100% : workflows sans safety_checks → resume sans acquittement requis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:45:22 +02:00
Dom
af13cd80ff 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>
2026-05-05 23:33:04 +02:00
Dom
7c6945171e feat(qw4): SafetyChecksProvider hybride déclaratif + LLM contextuel
build_pause_payload(action, state, last_screenshot) → PausePayload
- Toujours inclure les checks déclaratifs (workflow.parameters.safety_checks)
- Si safety_level=medical_critical ET RPA_SAFETY_CHECKS_LLM_ENABLED=1 :
    appel LLM (medgemma:4b par défaut) en format=json strict, timeout 5s,
    max 3 checks ajoutés (configurables via env vars)
- Tous les chemins d'erreur (timeout, HTTP, JSON parse, exception) loggent
  et retournent [] (fallback safe : déclaratifs seuls)

Tests : 7 cas (déclaratif seul, hybride OK, timeout, LLM invalide,
kill-switch, max_checks, déclaratif vide).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:29:38 +02:00
Dom
ca0b436a61 feat(qw2): hook LoopDetector dans api_stream + extension replay_state
Some checks failed
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 17s
tests / Tests sécurité (critique) (push) Has been skipped
replay_state enrichi de _screenshot_history (5 dernières images PIL) et
_action_history (5 dernières signatures action).

report_action_result :
- met à jour les deux anneaux après chaque action
- évalue le LoopDetector (singleton lazy avec _clip_embedder serveur)
- si detected → bascule paused_need_help avec pause_reason="loop_detected"
  et bus event lea:loop_detected (signal + evidence)

Tous les chemins d'erreur (embedder absent, OOM, exception) loggent et
laissent le replay continuer — aucun blocage par la couche détection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:25:04 +02:00
Dom
fc01afa59c fix(qw1): bus event lea:monitor_routed + cablage offset côté executor Agent V1
Cleanup post-review QW1 :
- Émission bus lea:monitor_routed dans /replay/next (idx, source, replay_id, action_id, offset, wh)
  via logger.info "[BUS] lea:monitor_routed ..." (le serveur streaming n'a pas
  de SocketIO local, agent_chat émet déjà lea:* sur 5004 ; ici on logge en INFO
  bien lisible, prêt pour un parser/pont futur)
- Executor Agent V1 (deploy/windows_client) lit action.monitor_resolution.{offset_x, offset_y, idx}
  et applique l'offset aux coords absolues du clic/type/scroll/popup quand idx >= 0
- composite_fallback (idx=-1) : pas d'offset appliqué (backward compat mono-écran)
- Log INFO "QW1 monitor cible idx=N source=X offset=(dx,dy) — appliqué aux coords"
  émis une fois par action quand un offset non nul s'applique

Tests : baseline 95 passed (e2e + phase0_integration + stream_processor + monitor_router + grounding_offset)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:16:06 +02:00
Dom
2a51a844b9 feat(qw2): LoopDetector composite (screen_static + action_repeat + retry)
Module isolé, 3 signaux indépendants :
- screen_static : CLIP similarity > 0.99 sur N captures consécutives
- action_repeat : N actions identiques (type+coords)
- retry_threshold : retried_actions >= seuil

Premier signal positif → LoopVerdict.detected=True (caller responsable de
la bascule en paused_need_help).

Configurable env vars : RPA_LOOP_DETECTOR_ENABLED (kill-switch),
RPA_LOOP_SCREEN_STATIC_N/THRESHOLD, RPA_LOOP_ACTION_REPEAT_N,
RPA_LOOP_RETRY_THRESHOLD.

Tests : 8 cas (chaque signal isolé, kill-switch, embedder absent, exception).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:09:43 +02:00
Dom
2d71e2a249 feat(qw1): enrichissement Agent V1 (monitor_index + monitors_geometry) + hook serveur
Some checks failed
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Côté client Agent V1 :
- helpers _get_monitors_geometry() / _get_active_monitor_index() via screeninfo
  (fallback gracieux [] / None si screeninfo absent)
- _enrich_with_monitor_info() ajouté aux payloads dict de capture_dual,
  capture_active_window, et heartbeat_event poussé par main.py
- screeninfo>=0.8 ajouté aux requirements (source + deploy Windows)
- Deploy capturer.py reçoit l'enrichissement de manière additive (pas de
  copie verbatim qui aurait introduit BLUR_SENSITIVE absent côté deploy)

Côté serveur :
- import resolve_target_monitor depuis monitor_router (créé en QW1.1)
- /replay/next : enrichissement action.monitor_resolution avant envoi
  au client (idx, offset_x/y, w, h, source de la décision)
- live_session_manager.add_event : propagation monitor_index +
  monitors_geometry depuis window_capture ET depuis le payload event
  brut (cas heartbeat enrichi sans window/window_title)

Cascade de résolution (cf monitor_router.py) :
1. action.monitor_index (hérité de la session source)
2. session.last_focused_monitor (focus actif vu en dernier heartbeat)
3. composite_fallback (offset 0,0) — backward compat strict

Backward 100% : si geometry vide, fallback composite identique au
comportement actuel mss.monitors[0].

Tests : baseline 89/89 préservée, monitor_router 4/4 OK (total 93/93).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:05:44 +02:00
Dom
fae95c5366 feat(qw1): capture par monitor + propagation offsets dans grounding cascade
_capture_screen() accepte un monitor_idx optionnel (None = composite legacy).
Index logique 0..N-1 mappé sur mss.monitors[idx+1] (mss[0] = composite).

Les 3 niveaux de grounding (OCR, UI-TARS, VLM) propagent l'offset retourné
par la capture pour traduire les coordonnées locales monitor en coordonnées
absolues écran (correct pour pyautogui.click).

find_element_on_screen() accepte monitor_idx et le forwarde aux 3 niveaux.

Backward 100% : monitor_idx=None partout → comportement strictement actuel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:55:04 +02:00
Dom
6582a69d31 feat(qw1): MonitorRouter — résolution de l'écran cible pour le replay
Module isolé qui choisit l'écran cible avec stratégie en cascade :
1. action.monitor_index (session source) → cible explicite
2. session.last_focused_monitor → fallback focus actif
3. composite (offset 0,0) → backward compat (comportement actuel)

Backward 100% : actions sans monitor_index → fallback composite identique
au comportement mss.monitors[0] actuel.

Tests : 4 cas (cible OK, fallback focus, fallback composite, index invalide).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:50:22 +02:00
Dom
5543e25f9d docs(qw): plan d'implémentation QW suite mai 2026 (~30 tasks bite-sized TDD)
Some checks failed
tests / Lint (ruff + black) (push) Successful in 18s
tests / Tests unitaires (sans GPU) (push) Failing after 17s
tests / Tests sécurité (critique) (push) Has been skipped
Plan d'exécution détaillé pour le sprint QW1+QW2+QW4 :
- Section 0 (preflight) : backup branche+tag Gitea, baseline E2E, smoke démo
- Section 1 (QW1 multi-écrans) : tests + monitor_router + input_handler + Agent V1
- Section 2 (QW2 LoopDetector) : tests + module + hooks api_stream/replay_engine
- Section 3 (QW4 safety_checks) : tests + provider + endpoint + frontend VWB
- Section 4 (docs) : QW_SUITE_MAI.md + maj MEMORY

Chaque task = 4-7 steps de 2-5 min, code complet par step (modules nouveaux),
diffs ciblés (modifs ciblées), commands exactes avec output attendu.

Discipline TDD légère : test rouge → implem → test vert → re-run baseline → commit.

Référence spec : docs/superpowers/specs/2026-05-05-qw-suite-mai-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:34:13 +02:00
Dom
2a07d8084b docs(qw): spec design QW suite mai 2026 (multi-écrans + LoopDetector + safety_checks hybrides)
Spec issu d'un brainstorming structuré (7 questions clarifiantes,
décisions tranchées) inspiré par l'exploration comparative de 5 frameworks
computer-use (Simular Agent-S, browser-use, OpenAI CUA sample, Coasty
open-cu, Showlab OOTB).

3 quick wins ciblés :
- QW1 multi-écrans : capture/grounding par monitor_index avec fallbacks
- QW2 LoopDetector composite : screen_static (CLIP) + action_repeat + retry
- QW4 safety_checks hybrides : déclaratif workflow + LLM contextuel
  (medgemma:4b, timeout 5s, fallback safe, kill-switch env)

Contraintes inviolables : 100% vision, 100% local Ollama, backward compat.
Plan livraison : QW1+QW2 avant démo GHT, QW4 enchaîné dès validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:23:10 +02:00
Dom
35b27ae492 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>
2026-05-02 00:32:57 +02:00
Dom
b584bbabc3 fix(stream): robustesse proxy VWB→streaming + ciblage textuel pour démo UHCD
dag_execute.py /execute-windows :
- Bearer token sur appels VWB→streaming (machines, replay/raw).
  Sans cela : 401 Unauthorized et le workflow ne démarre pas.
- Auto-injection session_id='agent_demo_user' si absent.
  Sans cela : /replay/raw bascule sur l'auto-détection sess_* et lève
  "Aucune session Agent V1 active" après tout restart du streaming server.
- Propagation by_text dans target_spec pour ciblage textuel
  (résolution hybrid_text_direct côté executor) — utile quand
  deux numéros se ressemblent visuellement (ex 25003284 vs 2500341).

t2a_decision.py : prompt enrichi avec decision_court (UHCD / Forfait
Urgences) + 3 critères PMSI (preuve_critereN + critereN_valide booléen)
pour piloter case-à-cocher dans l'arbre décisionnel. num_predict=1500,
num_ctx=16384.

resolve_engine.py : un drift trop grand bascule sur les coords
enregistrées (fallback_recorded_coords, resolved=True) au lieu de
rejeter la résolution. Permet au replay de continuer en cas de scroll
plutôt que de s'arrêter net.

workflows.db : by_text='25003284' sur le step de sélection patient
du workflow Urgence (démo GHT Sud 95).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:52:22 +02:00
Dom
8817f527e7 feat(deploy): service systemd pour la maquette Easily Assure (démo GHT)
Sert le statique de docs/clients/ght_sud_95/mockup_easily_assure/
sur le port 8765 (auto-restart, démarre au boot). Proxifié en
HTTPS via NPM sur urgence.labs.laurinebazin.design avec Basic Auth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:52:27 +02:00
Dom
964856ab30 feat(workflow): variables runtime + extract_text serveur + t2a_decision LLM
Pipeline streaming étendu pour supporter des actions exécutées entièrement
côté serveur (jamais transmises à l'Agent V1) qui produisent des variables
réutilisables dans les steps suivants via templating {{var}} ou {{var.field}}.

== Variables d'exécution ==
- replay_state["variables"] : Dict[str, Any] initialisé vide à la création
- _resolve_runtime_vars() : résout {{var}} et {{var.field}} récursivement
  dans str/dict/list. Variables absentes laissées intactes.
- /replay/next applique la résolution sur l'action AVANT toute interception
  ou envoi à l'Agent V1.

== Boucle d'exécution serveur ==
- _SERVER_SIDE_ACTION_TYPES = {"extract_text", "t2a_decision"}
- /replay/next pop+execute en boucle ces actions jusqu'à trouver une action
  visuelle (à transmettre Agent V1) ou un pause_for_human (qui bloque).
- Latence acceptable : t2a_decision = 5-10s côté serveur, l'Agent V1 attend
  la réponse HTTP.

== Action extract_text ==
- Handler côté serveur réutilisant le dernier heartbeat (max 5s d'âge)
- core/llm/ocr_extractor.py : EasyOCR fr+en singleton + extract_text_from_image
- Stockage dans replay_state["variables"][output_var]
- Robuste : pas de heartbeat → variable = "" + log warning, pipeline continue

== Action t2a_decision ==
- core/llm/t2a_decision.py : refactor de demo_app.py query_model en module
  importable. Prompt expert DIM T2A/PMSI, qwen2.5:7b par défaut (100% bench).
- Handler côté serveur appelle analyze_dpi(input_template_resolved)
- Stockage du JSON décision dans replay_state["variables"][output_var]
- Erreurs (Ollama down, parse) → variable = INDETERMINE + _error, pipeline continue

== VWB UI ==
- types.ts : nouveau type 't2a_decision' (icône 🧠 catégorie logic)
- extract_text refondu : needsAnchor=false, paramètre output_var (au lieu de
  variable_name legacy — bridge accepte les deux pour compat)
- Bridge VWB→core : passthrough des deux types + paramètres préservés

== Tests ==
- tests/integration/test_t2a_extract.py : 25 tests verts
  - templating runtime (8 tests)
  - handler extract_text (3 tests, OCR mocké)
  - handler t2a_decision (3 tests, analyze_dpi mocké)
  - edge → action normalisée (2 tests)
  - bridge VWB → core (5 tests)
  - workflow chain extract→t2a→pause→clic (1 test)

Total branche : 82/82 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:47:31 +02:00
Dom
a67d896104 fix(vwb): bibliothèque de capture restait vide après 'Capturer'
Cause racine : le useEffect d'ajout à la bibliothèque écoutait la prop
'capture' venant du parent. Le path 'agent Windows distant' (doSmartCapture
quand l'agent V1 répond) faisait setCurrentCapture(state local) mais ne
déclenchait jamais la prop parente — donc useEffect [capture] ne tirait pas,
donc addCaptureToLibrary jamais appelé. La capture s'affichait, mais rien
n'était persisté côté backend.

Fix :
- Factorisation de l'ajout dans un useCallback addToLibrary(cap)
- Appel explicite après setCurrentCapture dans doSmartCapture
- Le path fallback local (via prop capture) garde le useEffect [capture]
  qui appelle aussi addToLibrary

Erreurs d'upload (réseau, backend down) avalées silencieusement avec
console.warn — la capture locale reste utilisable même si le backend
de bibliothèque est indisponible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:13:56 +02:00
Dom
90c1d8036f ux(vwb): timer capture — default 5s, label dynamique, log diagnostic
Bug terrain : le bouton 'Timer' déclenchait toujours une capture immédiate
même après sélection d'un délai dans le menu déroulant. Le retour utilisateur
'le bouton ne change pas' a confirmé qu'il n'y avait aucun feedback visuel
sur le délai sélectionné, donc impossible de diagnostiquer.

Changements :
- timerSeconds default 5s (préférence Dom) au lieu de 0 (Immediat)
- Label dynamique du bouton :
    countdown actif → '5…' '4…' etc.
    délai 0 → 'Timer' (capture immédiate)
    délai > 0 → 'Capturer dans 5s'
- Select préfixé par 'Délai :' pour clarifier
- Conversion explicite String(timerSeconds) sur value du select pour éviter
  toute ambiguïté number/string
- console.log temporaire au changement de select pour faciliter le diagnostic
  si le bug persiste (à retirer après validation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:20:16 +02:00
Dom
6261002039 ux(vwb): tooltip enrichi sur les outils de la palette
Le tooltip natif HTML montrait juste le label ('Clic'). Maintenant il affiche :
- Le label
- La description complète (existait déjà dans types.ts mais non exposée)
- L'indication 'ancre requise' si applicable
- La liste des paramètres configurables

Le badge 🎯 a aussi son propre tooltip explicatif.

Aide à la prise en main du VWB pour la construction de workflows démo
(retour terrain Dom : 'il y a des outils dont je ne sais pas à quoi ils servent').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:42:55 +02:00
Dom
0e6e61f2b1 feat(workflow): action 'pause_for_human' — pause supervisée scriptée dans VWB
Nouvelle action native VWB qui force le replay à basculer en paused_need_help
avec un message custom. Quand Léa atteint cette étape, elle ne tente pas
d'exécuter — elle pose immédiatement le state, ce qui déclenche la bulle
interactive ChatWindow (J3.5) avec boutons Continuer / Annuler.

Asset démo majeur GHT Sud 95 : permet de scénariser le moment "Léa doute"
au bon endroit dans le workflow, sans dépendre d'un échec aléatoire.

Chaîne complète :
- VWB UI (types.ts) : nouvelle entrée ACTIONS catégorie 'logic', icône ⏸,
  paramètre 'message' éditable (textarea).
- Bridge VWB → core (learned_workflow_bridge.py) : passthrough du type +
  préservation du message dans parameters.
- Pipeline replay (replay_engine.py) : type ajouté à _ALLOWED_ACTION_TYPES,
  conversion edge → action normalisée préserve le message.
- Streaming server (api_stream.py /replay/next) : interception avant envoi
  à l'Agent V1 → bascule state en paused_need_help avec pause_message,
  retourne {action: None, replay_paused: True}.
- L'action n'est jamais transmise à l'Agent V1 — pure logique serveur.

10 nouveaux tests pytest. Total branche : 57/57 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:37:46 +02:00
Dom
41c1250c99 feat(lea): bulles 'Léa exécute' stylisées + templates par event
J3.4 — distinction visuelle entre :
- Bulles chat normales (fond bleu clair, prefixe 💬, taille standard)
- Bulles d'action Léa (fond gris clair, encadré subtil, icône sémantique
  en couleur, libellé court, métadonnées discrètes en pied)
- Bulle paused supervisée (jaune, boutons interactifs — déjà en J3.5)

Templates de libellés volontairement neutres : le contexte métier (UHCD,
peakflow, J12.1, IPP 25003284…) provient des payloads émis par le pipeline
côté serveur, pas de hardcoding dans le client.

Mappage events → bulles :
  lea:action_started   ▶ bleu  "Démarrage : {workflow}"
  lea:action_progress  ⋯ bleu  "{step}" ou "Étape {current}/{total}"
  lea:done             ✓ vert / ✗ rouge selon success
  lea:need_confirm     ?  bleu  "{action.description}"
  lea:step_result      ✓ / ✗ / · selon status
  lea:resumed          → vert  "Reprise"
  lea:resume_acked     (silencieux côté UI)
  lea:abort_acked      (silencieux côté UI)
  événement inconnu    · gris  fallback neutre

18 nouveaux tests pytest (templates + extract_meta).
Total branche : 47/47 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:18:52 +02:00
Dom
2af3bc3b93 feat(lea): bulle paused_need_help interactive — asset démo majeur
Quand Léa bascule en pause supervisée (event 'lea:paused'), affichage d'une
bulle dédiée dans ChatWindow avec encadré orangé, raison de la pause, et deux
boutons Continuer/Annuler. C'est le moment qui incarne la différence RPA classique
vs Léa devant Carvella : Léa SAIT qu'elle ne sait pas et demande de l'aide.

Architecture (canal SocketIO bidirectionnel, pas de nouvel endpoint streaming) :

  ChatWindow ──[lea:replay_resume]──> agent_chat ──POST /resume──> streaming
  ChatWindow ──[lea:replay_abort ]──> agent_chat (running=False local)

Composants ajoutés :
- agent_chat/app.py : handlers 'lea:replay_resume' / 'lea:replay_abort' +
  acks 'lea:resume_acked' / 'lea:abort_acked' pour feedback côté client
- network/feedback_bus.py : méthodes resume_replay() / abort_replay() avec
  helper _safe_emit (silencieux + retourne bool succès)
- ui/chat_window.py : palette PAUSED_*, _add_paused_bubble(),
  _render_paused_bubble(), _close_active_paused_bubble() (auto-fermeture
  sur lea:resumed/done), _on_paused_resume/abort

8 nouveaux tests pytest (4 handlers serveur + 4 méthodes client).
Total branche : 29/29 verts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:08:32 +02:00
Dom
6154423a91 feat(agent_v1): brancher FeedbackBusClient dans ChatWindow tkinter
- Import fail-safe : si python-socketio manquant (ancienne install Pauline),
  _HAS_FEEDBACK_BUS=False, ChatWindow tourne normalement sans bus
- Bus démarré à la fin de _run_tk_loop si LEA_FEEDBACK_BUS=1 dans l'env
- Callback _on_lea_event → _add_lea_message (thread-safe via root.after)
- Cleanup : _bus.stop() ajouté dans _do_destroy avant la destruction tkinter

Formatage des bulles minimal pour J3.3 (texte brut "[event] key=value").
Le style mixte métier+tech viendra en J3.4. La bulle paused interactive J3.5.

Aucun crash si bus indisponible. Aucun changement de comportement si flag off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:19:41 +02:00
Dom
41eba898c0 feat(agent_v1): FeedbackBusClient — client SocketIO pour bus 'lea:*'
Consomme les events 'lea:*' émis par agent_chat (port 5004) et les dispatche
vers un callback fourni par ChatWindow (J3.3 à venir).

Caractéristiques :
- Connexion en thread daemon (non-bloquant pour la mainloop tkinter)
- Reconnect auto illimité (delay 2s → 30s exponentiel)
- Auth Bearer Token via header HTTP au handshake
- Fail-safe : connect échoué, callback qui raise, disconnect qui raise
  → tout silencieusement loggé, ChatWindow continue normalement

13 tests pytest verts (tests/integration/test_feedback_bus_client.py).
Pas de connexion réseau réelle dans les tests (python-socketio mocké).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:43:26 +02:00
Dom
9452e86fd1 deps(agent_v1): python-socketio[client] pour bus feedback Léa
Compatible Flask-SocketIO 5.3.x côté serveur. Ajouté aux deux requirements
client (agent_v1/ et deploy/windows_client/) — le second est utilisé par
l'installeur Pauline (setup_v1.bat).

ATTENTION : redéploiement client requis (PC Windows + VM Linux) avant la démo
GHT Sud 95. La dep ne sert à rien tant que J3.2 (FeedbackBusClient) n'est pas en
place ; aucun impact runtime sur l'agent V1 actuel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:53:40 +02:00
Dom
5e31cdf666 feat(agent_chat): bus feedback Léa 'lea:*' derrière flag LEA_FEEDBACK_BUS
Surface d'observation pour bulles temps réel ChatWindow (J2 démo GHT Sud 95).

- Helper _emit_lea(event, payload): no-op silencieux si flag off
- Helper _emit_dual(legacy, lea, payload): émet event existant + alias 'lea:*'
- Détection paused_need_help dans _poll_replay_progress → lea:paused
- Détection sortie de pause → lea:resumed
- Timeout étendu (120s→600s) pendant pause supervisée
- 12 emits SocketIO existants aliasés (execution_started/progress/completed,
  copilot_step/step_result/complete) — payloads identiques, zéro régression

Flag LEA_FEEDBACK_BUS=0 par défaut. Comportement legacy strictement préservé.
8 tests pytest verts (tests/integration/test_feedback_bus.py).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:48:38 +02:00
Dom
487bcb8618 feat(execution): cascade post-raccourci pilotée par DialogHandler/OCR
Le pHash global 8x8 sur écran 1920x1080 ne détecte pas l'ouverture d'un
dialog modal dans une VM QEMU (un dialog 800x500 couvre ~3 pixels pHash,
distance Hamming typique = 1-2, sous le seuil de 3). Découvert sur Win11/
Notepad : Ctrl+Shift+S ouvrait bien le dialog mais Léa abortait à tort.

_handle_post_shortcut() poll désormais DialogHandler.handle_if_dialog()
toutes les 500ms (EasyOCR + KNOWN_DIALOGS). 8s pour le premier dialog,
3s de stabilité entre dialogs successifs, 60s budget total.

KNOWN_DIALOGS réordonné : popups modaux (confirmer/remplacer/écraser)
prioritaires sur fenêtres parents (enregistrer sous/save as) car l'OCR
full-screen capte les deux simultanément.

DialogHandler bascule sur UITarsGrounder subprocess one-shot (au lieu
du serveur HTTP localhost:8200 qui n'existait plus). InfiGUI worker,
think_arbiter et ui_tars_grounder alignés sur le même contrat.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-26 20:19:39 +02:00
Dom
3d6868f029 docs: cartographie complète d'exécution + fix target_text ORA + worker InfiGUI fichiers
docs/CARTOGRAPHY.md :
- Carte complète des 2 chemins d'exécution (Legacy vs ORA)
- 12 systèmes de grounding identifiés dont 3 morts
- Trace du champ target_text de la capture au clic
- Fonctions existantes non branchées (verify, recovery, ShadowLearningHook)
- Budget VRAM, fichiers critiques, règles de modification

Fix target_text ORA (observe_reason_act.py:217) :
- Détecte les target_text absurdes ("click_anchor")
- Appelle _describe_anchor_image() (VLM) pour décrire le crop
- Même logique que le legacy execute.py:893

Worker InfiGUI via fichiers /tmp :
- Communication par fichiers (pas subprocess pipes, pas HTTP)
- Process indépendant lancé avant le backend
- Résout le crash CUDA dans Flask/FastAPI/uvicorn

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 12:37:43 +02:00
Dom
f73a2a59a9 feat(réflexes): patterns overwrite/dont_save + handler EasyOCR + prints diagnostic
Nouveaux patterns :
- dialog_overwrite : "voulez-vous remplacer/écraser", "fichier existe déjà" → Oui
- dialog_dont_save : "ne pas enregistrer", "quitter sans enregistrer" → Ne pas enregistrer

Handler amélioré (handle_detected_pattern) :
- EasyOCR au lieu de docTR (meilleure lecture des boutons GUI)
- Match par inclusion (pas seulement exact)
- Suppression fallback VLM (Ollama n'a plus de VRAM)
- Prints visibles pour diagnostic

28 patterns au total, testés sur 6 dialogues types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 04:26:32 +02:00
Dom
77faa03ec9 feat(grounding): InfiGUI-G1-3B remplace UI-TARS 7B — 3.5x moins de VRAM
Serveur de grounding (server.py) :
- InfiGUI-G1-3B au lieu de UI-TARS-1.5-7B
- VRAM : 2.25 GB au lieu de 8.4 GB (6.6 GB libres)
- Prompt officiel InfiGUI (system <think> + user point_2d JSON)
- max_new_tokens=512, parsing JSON point_2d
- 4/4 éléments trouvés : Demo 5px, Chrome 98px, Corbeille 15px, Search 66px
- Fallback UI-TARS via env GROUNDING_MODEL=ByteDance-Seed/UI-TARS-1.5-7B

EasyOCR : retour sur GPU (assez de VRAM maintenant) → 192ms au lieu de 2.5s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 04:07:00 +02:00
Dom
343d6fbe95 perf(ocr): EasyOCR remplace docTR dans FastDetector + TitleVerifier
FastDetector : EasyOCR GPU en singleton (~192ms vs 1300ms docTR = 6.8x)
- "Corbeille" lu correctement (docTR lisait "Gorbeille")
- "Google Chrome" en deux mots propres
- Détection complète (RF-DETR + OCR) en 313ms à chaud
- Fallback docTR si EasyOCR non disponible

TitleVerifier : EasyOCR pour le crop titre (fallback docTR)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 03:32:43 +02:00
Dom
cc64439738 feat(grounding): vérification titre OCR post-action (non-bloquante)
TitleVerifier (core/grounding/title_verifier.py) :
- Crop 45px barre de titre → OCR → compare avant/après (~280ms)
- Titres < 3 chars ignorés (bruit OCR sur VM)
- Non-bloquant : échec = warning, pas stop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 03:22:50 +02:00
Dom
90007cc7c1 perf(grounding): réflexe pHash-only + max_new_tokens 64
Réflexe check : déclenché uniquement si pHash change (popup inattendu),
plus d'OCR full screen systématique à chaque step. Gain ~9s/workflow.

Serveur grounding : max_new_tokens 256→64 (la réponse fait ~20 tokens).

Validé : 5+ tests consécutifs 7/7, apprentissage actif
(CR_patient en fast_exact_text 2.2s, Feuille calcul en template 83ms).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 03:07:35 +02:00
Dom
73cea2385e feat(grounding): Phase 6 — Shadow Learning Hook
ShadowLearningHook (core/grounding/shadow_learning_hook.py) :
- Hook optionnel pour le ShadowObserver
- Chaque clic humain observé → FastDetector détecte l'élément sous le clic
- SignatureStore enrichie avec texte, type, position, voisins (conf=1.0)
- Au replay : SmartMatcher utilise la signature apprise → matching < 1ms

Validé : 3 clics simulés → 3 signatures créées avec les bonnes métadonnées.
Module standalone — ne modifie pas le ShadowObserver existant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:00:11 +02:00
Dom
e2046837cf feat(grounding): Phase 5 — intégration pipeline FAST→SMART→THINK dans ORA
_act_click() utilise maintenant le pipeline FAST→SMART→THINK :
- Feature flag RPA_USE_FAST_PIPELINE=1 (activé par défaut)
- RPA_USE_FAST_PIPELINE=0 pour rollback sur l'ancien pipeline
- Si le nouveau pipeline échoue → fallback automatique template→OCR→static
- Pre-check VLM désactivé (le pipeline valide visuellement)
- Capture unique de l'écran partagée entre tous les layers

Rollback instantané : unset RPA_USE_FAST_PIPELINE
Tests : 37 passed, 0 régression

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:57:56 +02:00
Dom
b30d4b6656 feat(grounding): Phase 4 — Pipeline orchestré FAST→SMART→THINK
FastSmartThinkPipeline (core/grounding/fast_pipeline.py) :
- Cascade : FAST detect (120ms) → SMART match (<1ms) → THINK VLM si doute (3s)
- Seuils : ≥0.90 action directe, 0.60-0.90 VLM confirme, <0.60 VLM cherche
- Apprentissage automatique : SignatureStore enrichie à chaque succès
- Ancien pipeline en fallback (safety net)
- Singleton via get_instance()

Validé sur 5 éléments :
- 1ère exécution : 5/5 OK via smart_think_confirmed (24.5s total)
- 2ème exécution : 4/5 en FAST direct, 1/5 en THINK (10.5s total)
- L'apprentissage réduit le temps de 20x par élément connu

Module standalone — aucun impact sur le système existant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:54:40 +02:00
Dom
e4a48e78bf feat(grounding): Phase 3 — ThinkArbiter + SignatureStore
ThinkArbiter (core/grounding/think_arbiter.py) :
- Client HTTP vers le serveur UI-TARS (port 8200)
- Appelé uniquement si SmartMatcher score < 0.60
- Vérifie la disponibilité du serveur avant appel
- Validé : Demo trouvé à (1479, 183) en 3.6s

SignatureStore (core/grounding/element_signature.py) :
- Stockage SQLite des signatures d'éléments UI apprises
- record_success() enrichit la signature (texte, type, position, voisins)
- record_failure() incrémente le compteur d'échecs
- lookup() avec fallback (contexte exact → toutes variantes)
- Validé : 3 succès → conf_moy=0.917, voisins enrichis

Modules standalone — aucun impact sur le système existant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:44:12 +02:00
Dom
ea36bba5cc feat(grounding): Phase 1-2 pipeline FAST→SMART — détection + matching
Phase 1 — FastDetector (core/grounding/fast_detector.py) :
- Détection RF-DETR de tous les éléments UI (~120ms à chaud)
- Enrichissement OCR (texte, voisins, position relative)
- Cache pHash (même écran → résultat instantané)
- 23 éléments détectés sur le benchmark, positions correctes

Phase 2 — SmartMatcher (core/grounding/smart_matcher.py) :
- Matching déterministe : texte exact (score 0.95) puis fuzzy (0.70+)
- Matching probabiliste : type, position, voisins contextuels
- Score combiné pondéré → seuil de confiance
- 5/5 éléments trouvés en < 1ms, 0 faux positif
- "Gorbeille" matche "Corbeille" par fuzzy (score 0.678)

Structures (core/grounding/fast_types.py) :
- DetectedUIElement, ScreenSnapshot, MatchCandidate, LocateResult
- Compatible GroundingResult via to_grounding_result()

Modules standalone — aucun impact sur le système existant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:37:14 +02:00
Dom
9da589c8c2 feat(grounding): pipeline centralisé + serveur UI-TARS transformers + nettoyage code mort
Architecture grounding complète :
- core/grounding/server.py : serveur FastAPI (port 8200) avec UI-TARS-1.5-7B en 4-bit NF4
  Process séparé avec son propre contexte CUDA (résout le crash Flask/CUDA)
- core/grounding/pipeline.py : orchestrateur cascade template→OCR→UI-TARS→static
- core/grounding/template_matcher.py : TemplateMatcher centralisé (remplace 5 copies)
- core/grounding/ui_tars_grounder.py : client HTTP vers le serveur de grounding
- core/grounding/target.py : GroundingTarget + GroundingResult

ORA modifié :
- _act_click() : capture unique de l'écran envoyée au serveur de grounding
- Pre-check VLM skippé pour ui_tars (redondant, et Ollama n'a plus de VRAM)
- verify_level='none' par défaut (vérification titre OCR prévue en Phase 2)
- Détection réponses négatives UI-TARS ("I don't see it" → fallback OCR)

Nettoyage :
- 9 fichiers morts archivés dans _archive/ (~6300 lignes supprimées)
- 21 tests ajoutés pour TemplateMatcher

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 17:48:18 +02:00
Dom
16ff396dbf chore: sauvegarde pré-stabilisation — audit 66/66 tests OK
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
security-audit / Bandit (scan statique) (push) Successful in 21s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 1m9s
Audit qualité : 0 bug critique, 5 points dette technique (post-démo).
Boucle ORA fonctionnelle : UI-TARS + pré-vérification + recovery Win+D.
Script test_instruction.sh ajouté.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 09:14:56 +02:00
Dom
e44fd7b328 fix(ORA): double-clic fiable + vérification stricte
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Double-clic : moveTo + 2 clics explicites (pyautogui.doubleClick ne
traverse pas toujours la VM). Délai 80ms entre les clics.

Vérification : un double-clic DOIT produire un changement majeur
(ouverture fichier/dossier). Changement mineur = échec → retry.
Les clics simples et hotkeys gardent la tolérance actuelle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 08:45:40 +02:00
Dom
66815b7a1a fix(ORA): pattern None quand overlay est une fenêtre (pas un dialogue)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
pattern.get() crashait car pattern=None quand l'overlay n'est pas
un dialogue connu. Ajout de guard None.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 08:22:12 +02:00
Dom
c6b695eca8 fix(ORA): Win+D via xdotool key au lieu de pyautogui.hotkey
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
pyautogui.hotkey('super','d') ne traverse pas la VM.
xdotool key super+d avec setxkbmap fr fonctionne.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 08:15:47 +02:00
Dom
99d2083dea fix(ORA): moveTo + pause + click + pause + Win+D (séquence validée par Dom)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 20:06:55 +02:00
Dom
a718086140 fix(ORA): xdotool windowactivate QEMU + key super+d pour focus VM
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 10s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
pyautogui.click cliquait SUR Chrome. xdotool search --name QEMU
trouve la fenêtre VM et la force au premier plan avant Win+D.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 18:08:10 +02:00
Dom
c82979e72b fix(ORA): clic centre écran pour focus VM avant Win+D
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 17:45:05 +02:00
Dom
2185c41cc1 fix(ORA): Win+D au lieu de Alt+Tab pour le recovery overlay
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 13s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Alt+Tab bascule entre fenêtres. Win+D affiche le bureau Windows.
Plus fiable quand l'élément cible est sur le bureau.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 17:19:06 +02:00
Dom
26804eb123 fix(ORA): Alt+Tab au lieu de windowminimize pour le recovery overlay
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
windowminimize minimisait en boucle toutes les fenêtres (VM incluse).
Alt+Tab bascule juste le focus sans rien fermer/minimiser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 17:09:38 +02:00
Dom
d71d5df4a8 fix(ORA): overlay = minimiser la fenêtre devant, pas juste chercher OK
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Quand la pré-vérification dit NO et qu'aucun pattern de dialogue n'est
détecté, c'est une fenêtre quelconque qui masque la cible (Chrome, etc).
xdotool windowminimize pour la dégager.

Classification améliorée : pré-check rejeté → OVERLAY_BLOCKING
(avant c'était ELEMENT_NOT_FOUND → scroll inutile).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 17:03:18 +02:00
Dom
6829ad8e79 feat(ORA): classification erreurs + recovery intelligent
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 13s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
4 types d'erreurs : ELEMENT_NOT_FOUND, OVERLAY_BLOCKING,
WRONG_SCREEN, ACTION_NO_EFFECT.

Recovery spécialisé par type :
- Element introuvable → attente + scroll + retry UI-TARS élargi
- Overlay bloquant → détection pattern + fermeture auto + retry
- Mauvais écran → description VLM + Alt+Tab + recherche taskbar
- Pas d'effet → double-clic + délai + coordonnées décalées

Intégré dans run_workflow() : classification → recovery → re-vérif.
Échec total → pause supervisée (pas de stop brutal).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 16:44:31 +02:00
Dom
8903f35433 feat(ORA): vérification pré-action — VLM confirme avant chaque clic
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Avant de cliquer, crop 200x100 autour de la position cible envoyé
au VLM (qwen2.5vl:3b) : "Is this UI element 'CR_patient_demo'? YES/NO"

Si NO → abandon du clic, évite les clics erronés.
Si erreur VLM → laisse passer (pas bloquant).
Skippé pour le template matching (confiance pixel suffisante).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 16:22:37 +02:00
Dom
4ab2c15e5c fix(ORA): logger.info→print pour que les logs apparaissent dans nohup
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Le logging Python ne traverse pas le nohup de Flask. Tous les autres
modules (execute.py, intelligent_executor.py) utilisent print().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 16:16:25 +02:00
Dom
eba6fea779 refactor(ORA): UI-TARS en PREMIER pour les clics
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 15s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Ordre : UI-TARS (3s, 94%) → Template (80ms) → OCR (1s)

UI-TARS dit "click on CR_patient_demo" et trouve les coordonnées
comme un humain. Le template matching échoue sur les icônes Windows
(micro-différences visuelles → score 0.38 au lieu de 0.95).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 15:59:45 +02:00
Dom
f04398d5a7 fix: VLM décrit TOUJOURS l'ancre à la capture, pas seulement si OCR échoue
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
L'OCR seul donnait du bruit (\"- C\", \"emo\"). Le VLM (qwen2.5vl:3b)
est maintenant appelé systématiquement pour décrire l'ancre en 5 mots
(\"folder icon named Demo\", \"search bar with magnifier icon\").

Le target_text utilise l'OCR si lisible, sinon la description VLM.
La description VLM est toujours stockée dans ocr_description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 15:30:19 +02:00
Dom
4ce9c47f45 fix(ORA): logs stdout + vérification pHash tolérante pour clics
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Logs : forcer le handler stdout pour que les logs ORA apparaissent
dans nohup (logger.info n'écrivait nulle part).

Vérification : un clic avec confiance >= 0.7 est accepté même si
l'écran ne change pas (pHash same). Un clic sur un champ de saisie
ne modifie quasi pas l'écran mais est légitime.
Changement mineur toujours accepté (plus de condition confiance > 0.9).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 15:04:13 +02:00
Dom
9dfcdb5fb0 fix: ajouter 'verified' dans la liste des modes du toggle
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 19s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 14:48:41 +02:00
Dom
3efe15d2c7 feat(vwb): ajout mode 'Vérifié' dans le sélecteur d'exécution
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 14:36:06 +02:00
Dom
9d87ed64c5 fix: corrections audit qualité — stop/pause ORA + nettoyage debug
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
CRITIQUE : ajout should_continue callback dans ORALoop pour supporter
les boutons Stop/Pause du frontend en mode verified et instruction.

HAUTE : suppression sys.stdout.write de debug, logger.warning→debug
dans _grounding_ocr.

BASSE : suppression import mort 'field' dans observe_reason_act.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 11:17:20 +02:00
Dom
00134963e5 test: 16 tests unitaires pour la boucle ORA
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 9s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Tests ORALoop init, Decision, reason_workflow_step (click, type,
hotkey, wait, passthrough), verify (none, wait, done), run_workflow
(empty, too_many), run_instruction (méthodes existent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 10:43:28 +02:00
Dom
0ec5e2a25b feat: instructions en langage naturel via boucle ORA
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 11s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
reason_instruction() : le VLM regarde l'écran, décide la prochaine
action atomique (click/type/hotkey/scroll/done), retourne un Decision
avec expected_after pour la vérification.

run_instruction() : boucle ORA complète pour instructions texte.
CognitiveContext mis à jour à chaque étape (objectif, historique,
faits appris, confiance).

POST /api/v3/execute/instruction : endpoint API pour lancer une
instruction en langage naturel. Thread daemon, polling du résultat
via GET /api/v3/execute/instruction/result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 09:09:53 +02:00
Dom
0c5fffe951 feat: boucle ORA (observe→raisonne→agit) avec vérification post-action
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Nouveau module core/execution/observe_reason_act.py (794 lignes) :
- ORALoop : boucle unifiée pour workflow VWB et instructions
- observe() : capture écran + pHash + titre fenêtre
- reason_workflow_step() : mappe step VWB → Decision (sans VLM)
- act() : template matching → find_element → pyautogui
- verify() : Level 1 pHash + Level 2 VLM conditionnel
- run_workflow() : boucle complète avec retries et callbacks

Nouveau mode execution_mode='verified' dans execute.py :
- run_workflow_verified() utilise ORALoop
- Modes basic/intelligent/debug inchangés (zéro risque)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 09:02:54 +02:00
Dom
5027ed9a23 chore: sauvegarde workflows.db après 23 tests de fiabilité réussis
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
23/24 tests du workflow Demo PMSI réussis (1 échec = main sur souris).
Template matching en premier (~80ms), CLIP batch en fallback (~4.5s).
Total workflow : ~20s (était 131s il y a 24h).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 20:15:22 +02:00
Dom
6caab2c600 perf: boucle fermée pHash (2s→150ms) + batch CLIP (90 appels→1)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Boucle fermée : time.sleep(2.0) remplacé par _wait_for_screen_change()
qui poll le pHash toutes les 150ms. Sort dès que l'écran change.
4 occurrences remplacées.

Batch CLIP : filtre par distance AVANT le CLIP (90→~20 éléments),
puis embed_image_batch() en un seul appel GPU + np.dot vectorisé.

Estimé : 42s→~20s total workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 19:33:42 +02:00
Dom
552e66dbf6 fix: import io manquant dans template matching
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 19:21:15 +02:00
Dom
de1026ee2e perf: template matching direct en PREMIER (~1-10ms)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
cv2.matchTemplate cherche l'ancre directement dans le screenshot.
Pas de RF-DETR, pas de CLIP, pas de 90 comparaisons.
Seuil 0.75 pour éviter les faux positifs.

Ordre : template (1ms) → CLIP (fallback) → OCR/UI-TARS (dernier recours)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 19:17:08 +02:00
Dom
7b50725bf8 perf: RF-DETR sur GPU (cuda) — était sur CPU = 28s par étape
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
RF-DETR détecte 90+ éléments UI par screenshot. Sur CPU = 28s.
Sur GPU RTX 5070 = devrait être 1-3s.

CLIP auto-GPU déjà en place (vérifie 1.5 Go VRAM libre).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 18:54:19 +02:00
Dom
7feef3b6a9 fix: CLIP en premier, suppression vérification OCR croisée, fix indentation
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 18:36:20 +02:00
Dom
0b06db222d fix: activer la fenêtre cible après minimisation du navigateur VWB
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Après minimisation du navigateur, xdotool active la fenêtre suivante
(VM QEMU, app cible). Avant, le terminal restait au premier plan →
mss capturait le terminal au lieu de la VM.

Cause racine de tous les échecs de matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 18:21:55 +02:00
Dom
74ee0dadee perf: pré-chargement docTR au démarrage + nettoyage debug logs
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
docTR se chargeait au premier appel OCR (~30s). Maintenant pré-chargé
au démarrage du backend → premier clic rapide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 17:25:35 +02:00
Dom
0b452f975a fix: pénaliser matchs OCR partiels trop courts (demo dans CR_patient_demo)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:49:22 +02:00
Dom
6ab385d671 fix(grounding): OCR collecte TOUS les matchs + choisit le plus proche de l'ancre
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Avant : OCR retournait le premier match → cliquait sur la barre de titre
("CR_patient_demo" dans le path) au lieu du fichier dans la liste.

Après : collecte tous les matchs, choisit le plus proche de la position
originale de l'ancre (anchor_bbox). Si pas de bbox, prend le plus central.

Élimine les clics sur les barres de titre, breadcrumbs, menus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:40:15 +02:00
Dom
b3eab83a0f fix: variable 'result' non définie quand grounding réussit sans CLIP
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:26:45 +02:00
Dom
27490849a8 refactor: OCR/UI-TARS en PREMIER, CLIP en fallback
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Le grounding par texte (OCR → UI-TARS) est maintenant la méthode
PRINCIPALE. CLIP n'est appelé que si le grounding échoue.

Avant : CLIP (faux positifs confiants) → cascade grounding (rarement atteinte)
Après : OCR 1s → UI-TARS 3s → CLIP (fallback visuel pur)

C'est comme ça que font UI-TARS, Agent-S3 et AppAgent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:40:38 +02:00
Dom
cebbf0809a fix: timeout VLM 15→60s + OCR zone élargie autour de l'ancre
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:05:38 +02:00
Dom
3e227d28ad fix(vwb): image plein écran — calcul dimensions JS explicite (fix définitif)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Cause racine : max-width/max-height CSS ne font pas GRANDIR une image.
Fix : calcul explicite width/height en JS via Math.min(ratio).
min-height:0 sur le conteneur flex.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 12:19:30 +02:00
Dom
8ce63fcba2 fix(vwb): CSS max-height 100% → calc(100vh-70px) — cause racine du timbre poste
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 17s
tests / Tests sécurité (critique) (push) Has been skipped
Le fichier CSS avait max-height:100% sur .fullscreen-content img
qui écrasait le style inline calc(100vh-70px). 100% d'un conteneur
flex sans hauteur explicite = taille naturelle de l'image = minuscule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 12:10:24 +02:00
Dom
4202431421 fix(vwb): image plein écran maxHeight calc(100vh-70px) basé sur viewport
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 17s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:58:58 +02:00
Dom
4923623dd4 fix(vwb): bibliothèque ne s'écrase plus au chargement
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 16s
tests / Tests sécurité (critique) (push) Has been skipped
Le useEffect(saveLibrary) se déclenchait avec library=[] avant que
loadLibraryAsync ait fini → écrasait le fichier serveur avec un
tableau vide. Ajout d'un flag libraryLoaded pour ne sauvegarder
qu'après le chargement initial.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:54:16 +02:00
Dom
84181cc982 feat: analyse OCR+VLM de l'ancre à la capture (pas à l'exécution)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Quand l'utilisateur sélectionne une ancre dans le VWB :
1. OCR docTR extrait le texte du crop → target_text
2. Si texte < 3 chars → VLM qwen2.5vl:3b décrit en 5 mots
3. Stocké en BDD (VisualAnchor.target_text + ocr_description)
4. Injecté automatiquement dans les params à l'exécution

L'exécution sait maintenant QUOI chercher dès le départ :
- CLIP vérifie par OCR que le texte correspond
- Le grounding cascade a un vrai target_text
- Plus besoin de deviner à chaque run

Migration SQLite gracieuse (ALTER TABLE si colonnes absentes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:26:30 +02:00
Dom
7355d315a3 fix: vérification croisée CLIP+OCR + description ancre avant exécution
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Quand CLIP dit "trouvé", on vérifie par OCR que le texte à cette
position correspond au target. Si CLIP clique sur "Ce PC" au lieu
de "CR_patient_demo", l'OCR le rejette → fallback sur la cascade.

Description VLM de l'ancre AVANT le CLIP quand le label est un
type d'action (double_click_anchor → "text file icon CR_patient").
Le target_text enrichi sert à la vérification croisée ET au grounding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:10:01 +02:00
Dom
c50adab3a1 fix: aligner capture monitors[0] partout (cause de la régression)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
La capture VWB utilisait monitors[0] (composite) mais l'exécution
utilisait monitors[1] (premier écran). Images incompatibles → CLIP
retournait 0.00 sur un écran identique.

Tous les fichiers alignés sur monitors[0].

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:52:13 +02:00
Dom
2fbb305f65 fix: remonter seuil CLIP à 0.45 — le 0.20 créait des faux positifs
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Le seuil 0.20 faisait que CLIP cliquait sur Chrome au lieu du dossier
Demo (score 0.25 accepté = faux positif). Le seuil 0.45 rejette les
matchs faibles et la cascade OCR/UI-TARS prend le relais proprement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:39:02 +02:00
Dom
ff581be397 perf: seuil CLIP 0.45→0.20 + cache singleton IntelligentExecutor
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Seuil CLIP abaissé pour les icônes génériques (dossier, fichier)
qui obtenaient 0.25 au lieu de 0.45.

IntelligentExecutor en singleton — CLIP et RF-DETR chargés une
seule fois et réutilisés entre les étapes. Élimine le rechargement
de ~40s par étape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:29:15 +02:00
Dom
203e5cc6c1 fix(grounding): désactiver orchestrateur VRAM pendant exécution + qwen2.5vl:3b pour description
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 16s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
L'orchestrateur VRAM redémarrait Ollama en pleine exécution → timeout.
Désactivé pendant le workflow. L'orchestrateur reste disponible pour
bascule manuelle avant/après.

Description ancre via qwen2.5vl:3b (3 Go) au lieu de 7b — tient en VRAM
sans décharger CLIP ni RF-DETR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:16:27 +02:00
Dom
d1b556b6cd fix(grounding): supprimer SeeClick cassé + log description ancre
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
SeeClick supprimé : modèle HF incompatible (QWenConfig non reconnu),
crashait à chaque exécution et polluait les logs.
Remplacé par UI-TARS via la chaîne de grounding.

Log warning visible quand la description VLM de l'ancre échoue
(pour diagnostiquer les problèmes de VRAM).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:05:29 +02:00
Dom
729cd67743 feat(grounding): description VLM de l'ancre quand le label est vide
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Quand le target_text est vide ou identique au type d'action
(click_anchor, double_click_anchor...), le VLM décrit l'image
de l'ancre en 5 mots ("folder icon named Demo").

Cette description est ensuite passée à UI-TARS pour le grounding
("click on folder icon named Demo") et à l'OCR pour la recherche.

Chaîne complète : VLM décrit → OCR cherche → UI-TARS grounding → VLM raisonne.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:44:19 +02:00
Dom
73ddcdb29d feat: chaîne de grounding 3 niveaux + refonte capture écran
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Grounding en cascade quand CLIP/template échouent :
1. OCR (docTR) → cherche le texte exact sur l'écran (~1s)
2. UI-TARS grounding → "click on X" → coordonnées (~3s, 94% ScreenSpot)
3. VLM reasoning → raisonnement complet + confirmation OCR (~10s)

find_element_on_screen() dans input_handler.py (partagé VWB + Léa).
Câblé dans find_and_click() et execute_action() comme fallback.

Refonte capture écran :
- mss.monitors[0] (composite) pour capturer la VM en plein écran
- FullscreenSelector réécrit : overlay via getBoundingClientRect()
- Bboxes et sélection alignées avec l'image (calcul JS, pas CSS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:31:38 +02:00
Dom
14a9442343 refactor(vwb): refonte complète capture écran — stable définitivement
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
FullscreenSelector réécrit :
- Overlay unique positionné via getBoundingClientRect()
- Recalcul auto au resize
- Coordonnées souris relatives à l'image
- Plus de décalage bboxes/sélection

Capture backend :
- mss.monitors[0] (écran composite) au lieu de pyautogui.screenshot()
- Capture la VM en plein écran correctement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:03:19 +02:00
Dom
5da4581e76 feat(cognition): orchestrateur VRAM + VLM 7b par défaut
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
VRAMOrchestrator : bascule automatique entre modes SHADOW et REPLAY.
- SHADOW : streaming server + agent_chat actifs
- REPLAY : VLM qwen2.5vl:7b chargé, services non-essentiels stoppés

vlm_reason_about_screen() appelle ensure_reasoning_ready() avant
chaque raisonnement — libère la VRAM si nécessaire.

Benchmark : qwen2.5vl:7b en 10s (warm) vs 44s quand VRAM saturée.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 22:13:29 +02:00
Dom
cbe8dc95d2 feat(cognition): timing + écran attendu + auto-apprentissage Shadow + VLM qwen2.5vl
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Mémoire de travail enrichie :
- Timing par étape (durée, moyenne, alerte si lent)
- Écran attendu vs observation réelle
- Contexte VLM étendu

VLM reasoning : default qwen2.5vl:3b (gemma4 ne supporte pas vision)

Auto-apprentissage Shadow :
- stream_processor apprend les dialogues automatiquement
- Clic utilisateur après dialogue → pattern mémorisé
- Sauvegardé dans data/learned_patterns.json

GUI-R1 : 10 patterns additionnels extraits du dataset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:52:45 +02:00
Dom
04a14a56b2 feat(cognition): mémoire de travail — Léa sait où elle en est
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
CognitiveContext : bloc-notes interne réinjecté à chaque décision.
- objective : ce que Léa essaie de faire
- current_step : progression dans le plan
- action_history : les N dernières actions (succès/échec)
- learned_facts : faits appris pendant l'exécution
- confidence : auto-évaluation (baisse sur échec)
- needs_help : demande d'aide à l'humain
- to_prompt_context() : génère le texte pour le VLM

Module standalone, pas encore câblé dans l'executor.
Testé sur scénario de facturation OSIRIS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:41:10 +02:00
Dom
2290f1846b feat(cognition): raisonnement VLM quand les réflexes ne suffisent pas
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
vlm_reason_about_screen() : capture l'écran, envoie au VLM local
(gemma4/Ollama) avec l'objectif et le contexte, retourne une action
en JSON (click/type/wait/nothing + target + reasoning).

Chaîne de décision :
1. Réflexes (UIPatternLibrary) → instantané
2. OCR bouton (docTR) → rapide
3. VLM reasoning (Ollama) → intelligent, ~2-5s

Le VLM intervient UNIQUEMENT quand 1 et 2 échouent — pas de latence
ajoutée quand les réflexes suffisent.

UIPatternLibrary enrichie : charge builtin + GUI-R1 + learned patterns.
save_learned_pattern() persiste les patterns appris par Shadow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:37:03 +02:00
Dom
c57b40ae1d feat: CLIP auto-GPU si >1.5 Go VRAM libre + index FAISS IVF 11.5x plus rapide
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
CLIP embedder : auto-détection GPU avec vérification VRAM disponible.
Si >1.5 Go libre → CUDA, sinon → CPU. Évite les OOM quand Ollama
utilise déjà la VRAM.

FAISS : migration Flat → IVF (116 clusters, nprobe=8).
Benchmark : 0.46ms → 0.04ms par recherche (11.5x).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:27:01 +02:00
Dom
bc21b27da7 fix(dashboard): diagrammes BPMN/DFG grande taille (DPI 150, layout vertical)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Les images générées par PM4Py étaient trop petites et illisibles.
- DPI 150, taille 40x20 pouces, layout vertical (TB)
- La modale plein écran permet le défilement (scroll)
- Fallback sur pm4py.save_vis si le rendu Graphviz échoue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:29:49 +02:00
Dom
6a2248ddcd feat(dashboard): clic plein écran sur les images cartographie
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
Modale fullscreen au clic sur les diagrammes BPMN/DFG.
Fermeture par clic ou Échap. Les images sont illisibles en miniature.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:26:05 +02:00
Dom
82d7b38cff feat(dashboard): page Base de connaissances — métriques FAISS, sessions, patterns
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Nouvelle page /knowledge-base avec :
- Mémoire visuelle : 331 vecteurs FAISS / 13666 embeddings (alerte consolidation)
- Sessions observées : 56 sessions, 6.66 Go, 3 machines
- Réflexes natifs : 16 patterns UI en 6 catégories
- Workflows appris : 29

Onglet 📚 Connaissances ajouté dans toute la navigation.
Tout en français, dark theme, zéro jargon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:41:23 +02:00
Dom
6c7f88c05d refactor: factorisation input_handler partagé + page cartographie processus
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
core/execution/input_handler.py (NOUVEAU) :
- safe_type_text() : setxkbmap fr + xdotool, partagé entre les 2 executors
- check_screen_for_patterns() : détection dialogues UI via OCR
- handle_detected_pattern() : clic bouton par OCR (mot exact, le plus bas)
- post_execution_cleanup() : vérification post-workflow

VWB executor : suppression du code dupliqué, alias vers input_handler
Core executor : pyautogui.write() remplacé par safe_type_text()

Page dashboard "Cartographie des processus" :
- GET /process-mining : vue analyse des flux de travail
- POST /api/process-mining/discover : génère BPMN + indicateurs
- 4 cartes indicateurs, diagramme, points d'attention, variantes
- Dark theme, français, zéro jargon technique
- Onglet ajouté dans la navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:08:37 +02:00
Dom
447fbb2c6e chore: sauvegarde complète avant factorisation executor
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Point de sauvegarde incluant les fichiers non committés des sessions
précédentes (systemd, docs, agents, GPU manager).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:03:44 +02:00
Dom
623be15bfe fix(knowledge): triggers courts en mot entier + cookies trigger enrichi
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 12s
tests / Tests unitaires (sans GPU) (push) Failing after 12s
tests / Tests sécurité (critique) (push) Has been skipped
Les triggers ≤3 chars (ok, no) utilisent maintenant des frontières
de mots (\b) pour éviter les faux positifs (ok dans cookies).
Trigger "utilise des cookies" ajouté pour le pattern cookie_accept.

7/7 patterns validés en test terrain simulé.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:45:58 +02:00
Dom
55d5aebbd2 feat(knowledge): vérification post-workflow — dialogues restants
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 9s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Après la dernière étape, Léa vérifie l'écran et gère les dialogues
restants (jusqu'à 3 vérifications en cascade). Le workflow laisse
l'écran propre à la fin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:53:38 +02:00
Dom
73b731fef8 fix(knowledge): seuil OCR bouton 3→2 chars pour supporter OK et No
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 18s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Le filtre len<3 bloquait les boutons "OK" (2 chars) et "No" (2 chars).
Seuil abaissé à 2 — filtre les lettres isolées mais laisse passer
les boutons courts courants des dialogues Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:09:10 +02:00
Dom
ffd97ae9a5 feat(knowledge): détection et gestion automatique des dialogues UI
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 12s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
UIPatternLibrary câblée dans l'executor et le stream processor.
Pendant un wait_for_anchor, Léa surveille l'écran toutes les secondes :
1. OCR plein écran (docTR)
2. Pattern matching (dialogues Save, OK, Cancel, cookies...)
3. OCR ciblé pour trouver le bouton par son texte réel
4. Clic sur le match le plus bas (bouton, pas titre)

Fix : seuil ratio supprimé (trigger trouvé = match, quelle que soit
la longueur du texte OCR). Matching strict mot exact ≥3 chars
(évite les faux positifs sur lettres isolées). Fallback recherche
partielle pour les lettres soulignées (E_nregistrer).

Plus aucune coordonnée hardcodée — 100% vision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:06:17 +02:00
Dom
d168833609 fix: import Optional/Dict/Any pour _check_screen_for_patterns
Some checks failed
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 9s
security-audit / Scan secrets (grep) (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 10:55:26 +02:00
Dom
23a06a744c feat(knowledge): câblage UIPatternLibrary dans executor + stream processor
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 12s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
VWB Executor :
- _check_screen_for_patterns() : capture écran + OCR + pattern matching
- _handle_detected_pattern() : clic automatique sur dialogues connus
- Vérifie entre chaque étape en mode intelligent/debug
- Si un dialogue bloque (OK, Save, Cancel), Léa le gère seule

Stream Processor :
- Enrichit les ScreenState avec ui_pattern/ui_pattern_action/ui_pattern_target
- Les patterns détectés sont loggés et stockés dans les résultats
- Permet au GraphBuilder de savoir quels écrans sont des dialogues

Phase 2 du plan "connaissance native de l'environnement".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 10:54:19 +02:00
Dom
af4eae28b9 feat(knowledge): base de connaissances UI — réflexes natifs pour Léa
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
UIPatternLibrary : 16 patterns builtin (dialogues, menus, formulaires,
popups, raccourcis) qui donnent à Léa des réflexes immédiats.

Quand Léa reconnaît "Voulez-vous enregistrer ?" elle sait cliquer
sur "Enregistrer" sans apprentissage préalable.

- core/knowledge/ui_patterns.py : bibliothèque avec find_pattern(),
  get_dialog_handler(), add_pattern() pour patterns appris
- Métadonnées GUI-R1 (3K exemples) extraites dans data/ (gitignored)

Phase 1 du plan "connaissance native de l'environnement".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 10:44:45 +02:00
Dom
c198c930a1 fix(vwb): capture plein écran — retirer height:0 + wrapper flex
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 9s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 12s
tests / Tests sécurité (critique) (push) Has been skipped
Le conteneur .fullscreen-content avait height:0 + min-height:0
qui écrasait la hauteur du flex child → image minuscule.
Le wrapper inline-block limitait aussi le dimensionnement.

Fix : overflow:hidden sans height forcée, wrapper en flex 100%.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 10:28:16 +02:00
Dom
e3efef2fe7 fix(vwb): noms workflows lisibles + bibliothèque captures persistante
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
CSS : le dropdown héritait color:white du header → forcé #212121
sur .workflow-dropdown et .dropdown-item .item-name

Bibliothèque : migration localStorage → backend (capture_library.json)
- GET/POST /api/v3/capture/library (max 50 captures)
- loadLibraryAsync() charge depuis backend, fallback localStorage
- saveLibrary() écrit dans les deux (localStorage + backend)
- capture_library.json gitignored

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 00:04:30 +02:00
Dom
95fddeebb3 fix(typing): setxkbmap fr avant xdotool type — fix AZERTY dans VM QEMU
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Le refresh du layout X11 juste avant xdotool type force le bon keymap.
Sans ça, xdotool envoie des keycodes décalés (: → M, / → !, etc.)
dans les VM spice/QEMU.

Solution trouvée via askubuntu.com/questions/914718.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:52:19 +02:00
Dom
71523cebd3 fix(typing): presse-papier en priorité (fonctionne avec spice-vdagent)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Remet xclip+Ctrl+V comme méthode prioritaire. Les spice tools sont
installés dans la VM → le presse-papier est partagé → pas de problème
de mapping clavier. xdotool envoie des événements synthétiques X11
que spice/QEMU traite différemment des vraies frappes (: → M).

Citrix partage aussi le clipboard nativement → même méthode en prod.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:27:54 +02:00
Dom
3aa806a630 fix(typing): hybride xdotool type+key — rapide et compatible AZERTY/VM
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 7s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
xdotool type pour les segments alphanumériques (un seul appel, rapide),
xdotool key avec keysym uniquement pour les caractères spéciaux
(:, /, @, etc.) qui cassent en AZERTY dans les VM.

Évite le subprocess par caractère (trop lent, effet visuel désagréable).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:18:21 +02:00
Dom
588c8f22c1 fix(typing): xdotool key par keysym au lieu de type (fix AZERTY dans VM)
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
xdotool type envoie des scancodes QWERTY — dans une VM AZERTY,
':' devient 'M', '/' devient '!', etc.

Nouvelle approche : xdotool key avec les noms de keysym X11
(colon, slash, period, etc.) qui sont indépendants du layout.
Chaque caractère est envoyé individuellement — plus lent mais
100% fiable en AZERTY/QWERTY, local ou VM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:15:44 +02:00
Dom
3d243d731d fix: xdotool prioritaire sur clipboard (VM/Citrix), cosmétique sidebar
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
safe_type_text() : xdotool type en priorité au lieu du presse-papier.
Le clipboard xclip ne traverse pas les VM (QEMU) ni Citrix/RDP.
xdotool envoie des frappes X11 réelles que les VM capturent.
Délai 20ms entre caractères pour fiabilité.

Cosmétique : couleur texte forcée sur les items workflow du sidebar
(color: var(--text-primary)) — était blanc sur blanc.

Logs diagnostic ajoutés dans execute_workflow_thread et execute_action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 23:11:10 +02:00
Dom
2431a6c9e9 fix(vision): dernier seuil distance hardcodé (150px→500px) + nettoyage commentaires
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped
MAX_TEMPLATE_DISTANCE dans zoned_template_match était encore à 150px.
Tous les seuils de distance sont maintenant alignés à 500px :
- MAX_DISTANCE_PX (CLIP) : 500
- MAX_GLOBAL_DISTANCE (template global) : 500
- MAX_SEECLICK_DISTANCE : 500
- MAX_TEMPLATE_DISTANCE (template zonée) : 500

Commentaires périmés corrigés (plus de références aux anciennes valeurs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 19:52:20 +02:00
Dom
969236da03 fix(vision): distance max 500px pour template global et SeeClick
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Le template matching global trouvait l'icône Chrome à 0.99 de confiance
mais la rejetait car elle avait bougé de >150px. Même problème pour
SeeClick (>200px). Aligné tous les seuils de distance à 500px
pour supporter les workflows VWB cross-résolution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 19:48:26 +02:00
Dom
f30461b88c fix(vision): seuils grounding assouplis pour VWB cross-résolution
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
MAX_DISTANCE_PX 120→500 (ancre peut être loin si résolution différente)
MIN_CLIP_SCORE 0.55→0.50 (tolérance basique suffisante)
MIN_COMBINED_SCORE 0.5→0.45 (accepter les matchs raisonnables)

L'icône Chrome à 81% de confiance était rejetée à cause de la distance.
Les workflows VWB manuels capturent sur un écran et s'exécutent
potentiellement sur un autre — la tolérance de distance doit être large.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 17:09:08 +02:00
Dom
f34eca20f9 fix(vwb): double accolades JSX dans CapturePanel et CaptureLibrary
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Corrige les src={{b64ImgSrc(...)}} → src={b64ImgSrc(...)} causés par
le replace_all sur les template literals. Corrige aussi l'appel
b64ImgSrc dans du code JS pur (pas de {} autour).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 16:49:58 +02:00
Dom
309dfd5287 feat: process mining BPMN, détection changement écran pHash, OCR docTR
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Process Mining (core/analytics/process_mining_bridge.py) :
- Bridge PM4Py : conversion sessions Shadow → event log → BPMN XML + PNG
- KPIs automatiques : durée, variantes, goulots, distribution par app
- Support sessions JSONL brutes et workflows core JSON
- 42 tests (dont 1 sur données réelles)

Détection changement d'écran (core/analytics/screen_change_detector.py) :
- pHash (imagehash) : ~16ms par screenshot, seuils SAME/MINOR/MAJOR
- 8 tests sur screenshots réels

OCR docTR dans execute_extract_text :
- docTR par défaut pour lecture simple (rapide, CPU)
- Ollama VLM en fallback ou sur demande explicite (mode "vlm"/"ai")
- Dual-mode adaptatif selon extraction_mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 13:07:56 +02:00
Dom
f5a672d7b9 fix(vwb): capture plein écran + auto-détection MIME PNG/JPEG des ancres
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 12s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
- CSS fullscreen-content : height:0 + min-height:0 pour forcer flex fill
- Image fullscreen : max-height calc(100vh - 60px) + object-fit contain
- Fonction b64ImgSrc() détecte automatiquement PNG vs JPEG depuis le base64
- Corrige l'affichage des thumbnails compressés JPEG dans la bibliothèque
- Appliqué dans CapturePanel + CaptureLibrary (toutes les occurrences)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 10:55:51 +02:00
Dom
1acea85fa6 feat(vwb): câblage 19 blocs, OCR réel, screenshots ancres, configs déploiement
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Dispatch execute_action élargi de 12 à 19 blocs opérationnels :
- 4 blocs souris (hover, drag_drop, scroll, focus) avec pyautogui
- extract_text via Ollama VLM (remplace stub hardcodé)
- 5 blocs ai_* redirigés vers execute_ai_analyze avec prompts adaptés
- screenshot_evidence (capture + sauvegarde PNG)
- verify_element_exists (détection visuelle CLIP)

Import workflows Léa enrichi :
- Bridge extrait anchor_image_base64 des edges
- Import crée VisualAnchor en DB + fichiers thumbnail sur disque
- PropertiesPanel affiche automatiquement les screenshots

Frontend :
- visual_condition et loop_visual masqués (hidden: true)
- Filtre dans ToolPalette pour exclure les blocs cachés

Déploiement :
- 2 configs agent (TIM Pauline + Dev Windows) avec machine_id unique
- 2 workflows démo dans la BDD (batch factures + extraction IA)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 09:40:28 +02:00
Dom
4f61741420 feat: journée 17 avril — tests E2E validés, dashboard fleet+audit, VWB bridge, cleaner C2
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Pipeline E2E complet validé :
  Capture VM → streaming → serveur → cleaner → replay → audit trail
  Mode apprentissage supervisé fonctionne (Léa échoue → humain → reprise)

Dashboard :
  - Cleanup 14→10 onglets (RCE supprimée)
  - Fleet : enregistrer/révoquer agents, tokens, ZIP pré-configuré téléchargeable
  - Audit trail MVP (/audit) : filtres, tableau, export CSV, conformité AI Act/RGPD
  - Formulaire Fleet simplifié (nom + email, machine_id auto)

VWB bridge Léa→VWB :
  - Compound décomposés en N steps (saisie + raccourci visibles)
  - Layout serpentin 3 colonnes (plus colonne verticale)
  - Badge OS 🪟/🐧, filtre OS retiré (admin Linux voit Windows)
  - Fix import SQLite readonly

Cleaner intelligent :
  - Descriptions lisibles (UIA/C2) + détection doublons
  - Logique C2 : UIElement identifié = jamais parasite
  - Patterns parasites resserrés
  - Message Léa : "Je n'y arrive pas, montrez-moi comment faire"

Config agent (INC-1 à INC-7) :
  - SERVER_URL + SERVER_BASE unifiés
  - RPA_OLLAMA_HOST séparé
  - allow_redirects=False sur POST
  - Middleware réécriture URL serveur

CI Gitea : fix token + Flask-SocketIO + ruff propre
Fleet endpoints : /agents/enroll|uninstall|fleet + agent_registry SQLite
Backup : script quotidien workflows.db + audit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:46:40 +02:00
Dom
2fa864b5c7 chore(ops): script de backup quotidien workflows.db + audit
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 9s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Critique avant POC Anouste — trou identifié par le challenge du 16
avril. Sans backup, une perte de workflows.db = perte directe du
travail client (workflows, historique d'exécutions, ancres visuelles).

Script scripts/backup_vwb_and_audit.sh :
- Copie workflows.db via `sqlite3 .backup` (snapshot cohérent, même
  si le backend Flask tient la BDD ouverte) → ~/backups/vwb/
- Copie data/audit/*.jsonl → ~/backups/audit/audit_YYYY-MM-DD/
- Rétention automatique 30 jours (override via RETENTION_DAYS env)
- Destination override : BACKUP_ROOT=/chemin env var
- Log horodaté : ~/backups/backup.log

Installation (non automatique — à la main, cf. consigne) :
  crontab -e
  0 2 * * * /home/dom/ai/rpa_vision_v3/scripts/backup_vwb_and_audit.sh

Procédure de restore documentée dans ~/backups/README.md (créé hors
repo, volontairement).

Testé : 458752 octets restaurés à partir de workflows.db actuel
(3 workflows, 115 exécutions, 18 steps, intégrité OK).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:43:31 +02:00
Dom
10739c33fa feat(vwb): nom par défaut explicite pour workflows importés de Léa (B2)
Avant : tous les workflows importés s'appelaient « Unnamed Workflow »
→ la liste devenait illisible dès qu'il y en avait plusieurs.

Après : génération d'un nom explicite par _derive_default_name :
  1. Premier `template.window.title_pattern` utile dans les nodes
     (filtrage de "Unknown" / "unknown_window"), avec extraction de
     l'app derrière le séparateur Windows « – » / « - »
     (ex: « Sans titre – Bloc-notes » → « Bloc-notes »).
  2. Premier `template.window.process_name` non-null
     (ex: « explorer.exe »).
  3. Fallback : 8 premiers caractères du workflow_id, après
     nettoyage des préfixes techniques ("workflow_sess_", ...).

Le nom final inclut toujours la date de l'import :
    « Léa Bloc-notes — 2026-04-16 08:41 »
    « Léa explorer.exe — 2026-04-16 08:41 »
    « Léa 20260404 — 2026-04-16 08:41 » (fallback)

Ne se déclenche que si le nom entrant est vide,
« Unnamed Workflow » ou « Workflow importé » (insensible à la
casse). Le paramètre `name` explicite de la requête reste
prioritaire. L'utilisateur peut renommer via le bouton éditer.

Pas de modification du schema workflow (champ `name` existant).

Tests manuels sur données réelles :
- notepad_enriched.json (tous nodes "Unknown") → fallback id OK
- Bloc-notes, Explorateur et Recherche (2) → « Léa Rechercher »
- workflow construit avec title 'Sans titre – Bloc-notes'
  → « Léa Bloc-notes » OK

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:42:11 +02:00
Dom
39bea1b042 fix(vwb): bibliothèque de captures persistée en localStorage (B1)
Avant : CaptureLibrary.tsx utilisait sessionStorage (purgé à la
fermeture d'onglet), et CapturePanel.tsx maintenait une liste
concurrente sous une clé différente (captureLibrary vs
captureLibrary_v2) → deux vues désynchronisées qui s'effacent
toutes les deux dès qu'on ferme le navigateur.

Après :
- Nouveau service captureLibraryStorage.ts (load/save/compress)
  comme point unique d'accès.
- Stockage en localStorage (persiste entre onglets et sessions).
- Clé unifiée 'captureLibrary_v2'.
- Migration automatique de sessionStorage → localStorage et de
  l'ancienne clé 'captureLibrary' → nouvelle, lors du premier load.
- Thumbnails compressés JPEG qualité 80% et redimensionnés à
  320×240 max avant stockage pour rester sous le quota navigateur
  (5–10 MB selon navigateur).
- Gestion QuotaExceededError dans saveLibrary : élague les items
  les plus anciens jusqu'à ce que ça passe (5 tentatives).
- Les deux composants consomment le même helper : fin de la
  divergence de format (sessionId/favorite).

Diagnostic (bug reproduit par lecture du code, pas besoin de
navigateur) :
- CaptureLibrary.tsx:28,42,62 → sessionStorage/captureLibrary_v2
- CapturePanel.tsx:53,61       → sessionStorage/captureLibrary
→ Deux sources, toutes deux éphémères.

Vérif : `npx tsc --noEmit` passe (EXITCODE=0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:40:01 +02:00
Dom
26b4e6d8ce chore(vwb): supprime la BDD fantôme vwb_v3.db (B3)
Fichier SQLite vide (toutes tables à 0 lignes), tracé en git mais
jamais peuplé. La vraie source de vérité est `workflows.db`
(DATABASE_URL dans backend/.env → 3 workflows, 115 exécutions,
920 steps).

Risque éliminé : si `.env` n'était pas chargé (ex : systemd mal
configuré), SQLAlchemy retombait sur le fallback
`sqlite:///vwb_v3.db` et l'app créait/utilisait une BDD
complètement vide à côté de la vraie. Foot-gun classique.

Correctif :
- Fallback de app.py aligné sur workflows.db.
- Fichier vwb_v3.db supprimé du repo.

workflows.db reste seule source de vérité.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:38:00 +02:00
Dom
4fb84b1090 chore(vwb): hygiène (B4+B6+B7)
- B4 : supprime le double logging dans backend/app.py.
  app.py est importé 2 fois (une fois comme __main__ via `python app.py`,
  une fois comme module `app` via `from app import socketio` dans
  api/websocket_handlers.py). Le RotatingFileHandler était donc ajouté
  2× au root logger → chaque ligne loguée dupliquée. Fix : garde
  idempotente qui vérifie si un handler vers vwb.log existe déjà.
- B6 : supprime les fichiers .pid résiduels (.backend.pid,
  .frontend.pid, .frontend_v4.pid) et les ajoute au .gitignore
  (avec *.lock, *.orig, *.bak).
- B7 : ajoute launch.sh (wrapper → run_v4.sh par défaut, legacy
  → run.sh), clarifie en tête de run.sh et run_v4.sh la distinction
  frontend/ (legacy v3) vs frontend_v4/ (actif), et rectifie le
  README.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:37:12 +02:00
2661 changed files with 1037659 additions and 2633 deletions

View File

@@ -46,6 +46,14 @@ LOGS_PATH=logs
UPLOADS_PATH=data/training/uploads
SESSIONS_PATH=data/training/sessions
# ============================================================================
# Feedback Bus (Léa parle pendant exécution)
# ============================================================================
# Bus SocketIO unifié 'lea:*' (action_started, action_done, need_confirm, paused).
# Désactivé par défaut. Mettre à 1 pour activer les bulles temps réel dans ChatWindow.
# Si la connexion bus échoue, l'exécution continue normalement (fail-safe).
LEA_FEEDBACK_BUS=0
# ============================================================================
# FAISS
# ============================================================================

6
.gitignore vendored
View File

@@ -95,6 +95,7 @@ archives/
# === Données runtime (sessions, learning, buffer, config local) ===
data/
**/capture_library.json
.hypothesis/
.deps_installed
# Buffers SQLite locaux (streamer, cache)
@@ -110,3 +111,8 @@ data/
*.db-journal
*.db-wal
*.db-shm
# Verbatims clients (sensibles, à valider avant push)
docs/clients/
.qw-baseline.log

View File

@@ -0,0 +1,8 @@
{
"hash": "cccc2566",
"configHash": "0c083961",
"lockfileHash": "e3b0c442",
"browserHash": "764a8433",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

106
CLAUDE.md Normal file
View File

@@ -0,0 +1,106 @@
# CLAUDE.md — rpa_vision_v3
Ce fichier prime sur le CLAUDE.md racine (`~/ai/CLAUDE.md`) pour tout travail dans ce projet.
## Rôle de Claude Code sur ce projet
Exécutant supervisé, pas architecte. Mission : garantir la **cohérence** de chaque modification avec la vision globale du projet et le **contrat "100% vision"** (résolution UI par la vue, pas par les sélecteurs DOM/API). Quand tu touches un fichier, vérifie que tu ne casses rien ailleurs.
Tu n'es pas en autonomie. Dom valide avant chaque étape. Tu proposes, il décide.
## Priorité absolue
**La démo Urgence_aiva_demo doit fonctionner.** Workflow 22+ steps sur Easily Assure, patiente MOREL Catherine, audience mixte DG/DSI/médecins/DIM/TIM. Tout arbitrage technique se tranche par : "est-ce que ça rapproche ou éloigne de la démo qui tourne ?"
## Méthode obligatoire — non négociable
- **Chirurgie itérative supervisée** : une modification, un test (≤ 2 min), validation explicite de Dom avant la suivante.
- **Pas de batch** : jamais plusieurs changements groupés sans validation intermédiaire.
- **Rustine interdite** : tu corriges la cause, pas le symptôme. Si tu ne comprends pas la cause, tu le dis et tu arrêtes.
- **Lire la doc avant d'agir** : code existant, `docs/`, specs. Pas de proposition basée sur des suppositions.
- **Un commit = une intention** : message explicite, daté.
- **Diff review systématique** sur tout code de production avant commit.
## Anti-patterns à proscrire
- Réponses longues. Si Dom dit "trop long" ou "déjà vu", tu raccourcis sans débattre.
- Propositions structurelles avant d'avoir compris l'intention de Dom.
- Re-proposer ce qui est déjà en place dans le code.
- Raisonner sur un composant trouvé via grep **sans vérifier qu'il est effectivement appelé au runtime**. Le projet contient beaucoup de code écrit mais non wired.
- Présenter la première solution qui marche. Toujours explorer 2-3 approches, présenter la meilleure avec justification.
## Architecture runtime réelle (à valider/raffiner avec Dom)
```
[VWB frontend React :3002]
↓ (HTTP)
[VWB backend Flask + SQLite]
↓ (envoi step par step)
[agent_v1 — Linux]
↓ (SSH vers Windows)
[Léa — chatbot exécutant — PC Windows]
[Easily Assure — interface cible]
```
**Ollama** : sert le ou les modèles utilisés pour la résolution VLM, l'extraction texte, et la décision t2a. Sert aussi de **proxy vers cloud** pour certains appels.
**Cascade de résolution UI** (à confirmer composant par composant au runtime) :
1. OCR (docTR ou EasyOCR selon module)
2. cv2 template matching
3. YOLO v4 grounding
4. VLM grounding
**UI-DETR-1** : utilisé par VWB **au recording** pour overlays numérotés (équivalent OmniParser). `crop_hash` volontairement non persisté.
**Asymétrie connue, sujet ouvert post-démo** : VWB direct utilise UI-DETR-1 au runtime, le replay sur Léa ne l'utilise pas (cascade OCR/template/VLM seulement). Ne pas tenter de "fixer" cette asymétrie maintenant.
## ⚠️ Champs de mines — code orphelin
`core/` contient ~40 sous-modules. **Beaucoup ne sont pas wired au runtime actif.** Avant de raisonner sur un composant trouvé dans `core/` (coaching, healing, federation, learning, cognition, etc.) :
1. Vérifier qu'il est importé par un point d'entrée actif.
2. Vérifier qu'il est effectivement appelé en runtime (traces, logs).
3. Si doute, demander à Dom.
**Cas spécifique agent_v1** : suspicion de code orphelin à rebrancher. Si tu trouves un appel codé mais non exécuté en runtime (ex. appel Ollama de commentaire d'action présent dans le code mais jamais déclenché), c'est prioritaire à signaler.
## Debug — où regarder en premier
- `logs/` (racine projet) — logs runtime généraux
- `logs/audit/` — traces d'exécution
- `logs/healing/` — si concerne le healing
- `data/runner_captures/` — captures d'exécution
- `visual_workflow_builder/logs/` — logs VWB
- `server/logs/` — logs serveur
**Vérifier qu'un appel Ollama se déclenche vraiment au runtime** : ne pas se fier à la présence de l'appel dans le code. Tracer effectivement (log d'entrée de fonction, requête vue côté Ollama `:11434`).
## Inspirations externes
Voir `docs/INSPIRATION_FRAMEWORKS_2026-05-10.md` pour les patterns convergents (OpenAdapt, Skyvern, OmniParser : Policy/Grounding, Safety Gate, Abstraction Ladder, Planner-Actor-Validator). Le projet est techniquement plus mature que sa documentation ne le suggère — s'inspirer des bons patterns sans complexe.
## Recherche d'information
Ta connaissance interne est datée. Pour tout sujet technique évoluant vite (modèles VLM, frameworks RPA visuels, librairies de grounding, versions d'outils), **chercher sur internet d'abord**. Privilégier les sources de moins de 6 mois.
## Stack
- Python 3.10-3.12, venv `venv_v3/`
- Backend VWB : Flask + SQLite
- Frontend VWB : React (port 3002), dashboard :5001, API :8000
- LLM local : Ollama `:11434`
- GUI legacy : PyQt5
- Tests : pytest avec marqueurs (unit/integration/slow/smoke)
- Langue : français (code, commentaires, logs, GUI)
## Commandes utiles
```bash
cd ~/ai/rpa_vision_v3 && source venv_v3/bin/activate
./run.sh --full # Écosystème complet
./run.sh --gui # GUI PyQt5 seule
./run.sh --test # Tests complets
make test-fast # Tests rapides
make check # Validation imports + tests rapides
```

View File

@@ -185,6 +185,7 @@ Quelques tests legacy sont connus comme cassés — voir la mémoire projet et
- [`docs/STATUS.md`](docs/STATUS.md) — état réel par module
- [`docs/DEV_SETUP.md`](docs/DEV_SETUP.md) — tâches d'administration (worktrees, build)
- [`docs/EXECUTION_LOOP_FLAGS.md`](docs/EXECUTION_LOOP_FLAGS.md) — flags C1 vision-aware (`enable_ui_detection`, `enable_ocr`, `analyze_timeout_ms`, `window_info_provider`)
- [`docs/VISION_RPA_INTELLIGENT.md`](docs/VISION_RPA_INTELLIGENT.md) — cahier des charges
- [`docs/PLAN_ACTEUR_V1.md`](docs/PLAN_ACTEUR_V1.md) — architecture 3 niveaux (Macro / Méso / Micro)
- [`docs/CONFORMITE_AI_ACT.md`](docs/CONFORMITE_AI_ACT.md) — journalisation, floutage, rétention

View File

@@ -133,6 +133,28 @@ def _streaming_headers() -> dict:
headers["Authorization"] = f"Bearer {_STREAMING_API_TOKEN}"
return headers
# ============================================================
# Feedback Bus — events 'lea:*' temps réel vers ChatWindow
# ============================================================
LEA_FEEDBACK_BUS = os.environ.get("LEA_FEEDBACK_BUS", "0").lower() in ("1", "true", "yes", "on")
def _emit_lea(event: str, payload: Dict[str, Any]) -> None:
"""Émet 'lea:{event}' sur le bus SocketIO. No-op silencieux si flag off ou erreur."""
if not LEA_FEEDBACK_BUS:
return
try:
socketio.emit(f"lea:{event}", payload)
except Exception:
logger.debug("_emit_lea silenced", exc_info=True)
def _emit_dual(legacy_event: str, lea_event: str, payload: Dict[str, Any], **kwargs) -> None:
"""Émet l'event legacy (compat dashboard) ET l'alias lea:* (ChatWindow tkinter)."""
socketio.emit(legacy_event, payload, **kwargs)
_emit_lea(lea_event, payload)
execution_status = {
"running": False,
"workflow": None,
@@ -623,7 +645,7 @@ def api_execute():
}
# Notifier via WebSocket
socketio.emit('execution_started', {
_emit_dual('execution_started', 'action_started', {
"workflow": match.workflow_name,
"params": all_params
})
@@ -1181,28 +1203,28 @@ def _execute_gesture(gesture):
)
if resp.status_code == 200:
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": gesture.name,
"success": True,
"message": f"Geste '{gesture.name}' ({'+'.join(gesture.keys)}) envoyé",
})
else:
error = resp.text[:200]
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": gesture.name,
"success": False,
"message": f"Erreur: {error}",
})
except http_requests.ConnectionError:
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": gesture.name,
"success": False,
"message": "Serveur de streaming non disponible (port 5005).",
})
except Exception as e:
logger.error(f"Gesture execution error: {e}")
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": gesture.name,
"success": False,
"message": f"Erreur: {str(e)}",
@@ -1661,6 +1683,52 @@ def handle_copilot_abort():
})
# =============================================================================
# Bulle paused_need_help — handlers SocketIO depuis ChatWindow (J3.5)
# =============================================================================
@socketio.on('lea:replay_resume')
def handle_lea_replay_resume(data):
"""Bouton Continuer : relayer le resume vers le streaming server."""
replay_id = (data or {}).get("replay_id")
if not replay_id:
_emit_lea("resume_acked", {"status": "error", "detail": "replay_id manquant"})
return
try:
resp = http_requests.post(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/{replay_id}/resume",
headers=_streaming_headers(),
timeout=5,
)
if resp.ok:
logger.info(f"Replay {replay_id} resume relayé OK")
_emit_lea("resume_acked", {"replay_id": replay_id, "status": "ok"})
else:
detail = resp.text[:200]
logger.warning(f"Resume échoué (HTTP {resp.status_code}): {detail}")
_emit_lea("resume_acked", {
"replay_id": replay_id, "status": "error",
"http_status": resp.status_code, "detail": detail,
})
except Exception as e:
logger.warning(f"Resume relay error: {e}")
_emit_lea("resume_acked", {
"replay_id": replay_id, "status": "error", "detail": str(e),
})
@socketio.on('lea:replay_abort')
def handle_lea_replay_abort(data):
"""Bouton Annuler : arrêter le polling local. Le replay côté streaming sera
cleaned up naturellement au prochain replay (cf api_stream._replay_states stale)."""
global execution_status
replay_id = (data or {}).get("replay_id")
execution_status["running"] = False
execution_status["message"] = "Annulé par l'utilisateur"
logger.info(f"Replay {replay_id or '?'} abort par l'utilisateur (paused bubble)")
_emit_lea("abort_acked", {"replay_id": replay_id, "status": "ok"})
# =============================================================================
# Exécution de workflow
# =============================================================================
@@ -1730,14 +1798,20 @@ def _poll_replay_progress(replay_id: str, workflow_name: str, total_actions: int
"""Suivre la progression d'un replay distant via polling."""
import time
max_wait = 120 # 2 minutes max
max_wait_running = 120 # 2 min en exécution active
max_wait_paused = 600 # 10 min en pause supervisée (humain peut prendre son temps)
poll_interval = 2.0
elapsed = 0
was_paused = False
while elapsed < max_wait and execution_status.get("running"):
while execution_status.get("running"):
time.sleep(poll_interval)
elapsed += poll_interval
cap = max_wait_paused if was_paused else max_wait_running
if elapsed >= cap:
break
try:
resp = http_requests.get(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/{replay_id}",
@@ -1753,7 +1827,26 @@ def _poll_replay_progress(replay_id: str, workflow_name: str, total_actions: int
failed = data.get("failed_actions", 0)
progress = int(10 + (completed / max(total_actions, 1)) * 80)
socketio.emit('execution_progress', {
if status == "paused_need_help" and not was_paused:
_emit_lea("paused", {
"workflow": workflow_name,
"replay_id": replay_id,
"completed": completed,
"total": total_actions,
"failed_action": data.get("failed_action"),
"reason": data.get("error") or "Action incertaine",
})
was_paused = True
elapsed = 0
elif was_paused and status != "paused_need_help":
_emit_lea("resumed", {
"workflow": workflow_name,
"replay_id": replay_id,
"status_after": status,
})
was_paused = False
_emit_dual('execution_progress', 'action_progress', {
"progress": progress,
"step": f"Action {completed}/{total_actions} exécutée",
"current": completed,
@@ -1922,7 +2015,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
actions = _build_actions_from_workflow(match, params)
if not actions:
socketio.emit('copilot_complete', {
_emit_dual('copilot_complete', 'done', {
"workflow": workflow_name,
"status": "error",
"message": "Aucune action exécutable dans ce workflow.",
@@ -1959,7 +2052,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
break
copilot_state["status"] = "waiting_approval"
socketio.emit('copilot_step', {
_emit_dual('copilot_step', 'need_confirm', {
"workflow": workflow_name,
"step_index": idx,
"total": total,
@@ -1982,7 +2075,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
if waited >= max_wait:
copilot_state["status"] = "aborted"
socketio.emit('copilot_complete', {
_emit_dual('copilot_complete', 'done', {
"workflow": workflow_name,
"status": "timeout",
"message": f"Timeout : pas de réponse après {max_wait}s.",
@@ -1999,7 +2092,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
elif decision == "skipped":
copilot_state["skipped"] += 1
logger.info(f"Copilot skip étape {idx + 1}/{total}")
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "skipped",
@@ -2034,7 +2127,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
if action_success:
copilot_state["completed"] += 1
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "completed",
@@ -2042,7 +2135,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
})
else:
copilot_state["failed"] += 1
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "failed",
@@ -2051,7 +2144,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
else:
error = resp.text[:200]
copilot_state["failed"] += 1
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "failed",
@@ -2060,7 +2153,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
except http_requests.ConnectionError:
copilot_state["failed"] += 1
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "failed",
@@ -2070,7 +2163,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
except Exception as e:
copilot_state["failed"] += 1
logger.error(f"Copilot action error: {e}")
socketio.emit('copilot_step_result', {
_emit_dual('copilot_step_result', 'step_result', {
"step_index": idx,
"total": total,
"status": "failed",
@@ -2098,7 +2191,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
f"Copilot terminé : {completed} réussies, "
f"{skipped} passées, {failed} échouées sur {total} étapes."
)
socketio.emit('copilot_complete', {
_emit_dual('copilot_complete', 'done', {
"workflow": workflow_name,
"status": "completed" if success else "partial",
"message": message,
@@ -2175,7 +2268,7 @@ def execute_workflow(match, params):
execution_status["progress"] = 10
execution_status["message"] = f"Envoyé à l'Agent V1 ({target_session})"
socketio.emit('execution_progress', {
_emit_dual('execution_progress', 'action_progress', {
"progress": 10,
"step": f"Replay envoyé à l'Agent V1 — {total_actions} actions en attente",
"current": 0,
@@ -2523,7 +2616,7 @@ def update_progress(progress: int, message: str, current: int, total: int):
execution_status["progress"] = progress
execution_status["message"] = message
socketio.emit('execution_progress', {
_emit_dual('execution_progress', 'action_progress', {
"progress": progress,
"step": message,
"current": current,
@@ -2543,13 +2636,83 @@ def finish_execution(workflow_name: str, success: bool, message: str):
if command_history:
command_history[-1]["status"] = "completed" if success else "failed"
socketio.emit('execution_completed', {
_emit_dual('execution_completed', 'done', {
"workflow": workflow_name,
"success": success,
"message": message
})
# =============================================================================
# Orchestration démo GHT Sud 95 — "traite N dossiers"
# =============================================================================
# Délégué à agent_chat.urgences_orchestrator (gemma3:1b NLP + thread orchestrateur).
# Routes :
# POST /api/urgences/parse — test parsing intent (debug)
# POST /api/urgences/start — démarrer une orchestration
# GET /api/urgences/status/<id>— état d'une orchestration
# GET /api/urgences/list — toutes les orchestrations en mémoire
try:
from agent_chat.urgences_orchestrator import (
parse_lea_command,
start_orchestration,
get_orchestration,
list_orchestrations,
)
_URGENCES_AVAILABLE = True
except Exception as _e_urg:
logger.warning("Module urgences_orchestrator indisponible : %s", _e_urg)
_URGENCES_AVAILABLE = False
@app.route('/api/urgences/parse', methods=['POST'])
def urgences_parse():
if not _URGENCES_AVAILABLE:
return jsonify({"error": "module urgences_orchestrator indisponible"}), 503
payload = request.get_json(silent=True) or {}
text = (payload.get("text") or "").strip()
if not text:
return jsonify({"error": "champ 'text' manquant"}), 400
intent = parse_lea_command(text)
return jsonify(intent)
@app.route('/api/urgences/start', methods=['POST'])
def urgences_start():
if not _URGENCES_AVAILABLE:
return jsonify({"error": "module urgences_orchestrator indisponible"}), 503
payload = request.get_json(silent=True) or {}
text = (payload.get("text") or "").strip()
session_id = payload.get("session_id") or ""
machine_id = payload.get("machine_id") or None
if not text:
return jsonify({"error": "champ 'text' manquant"}), 400
intent = parse_lea_command(text)
if intent.get("action") != "process_patients":
return jsonify({"intent": intent, "started": False,
"reply": "Je n'ai pas compris la commande. Exemples : 'traite-moi 3 dossiers', 'code les 5 premiers'."})
state = start_orchestration(intent, session_id=session_id, machine_id=machine_id)
return jsonify({"intent": intent, "started": True, "orchestration": state.to_dict()})
@app.route('/api/urgences/status/<orch_id>')
def urgences_status(orch_id):
if not _URGENCES_AVAILABLE:
return jsonify({"error": "module urgences_orchestrator indisponible"}), 503
state = get_orchestration(orch_id)
if not state:
return jsonify({"error": f"orchestration {orch_id} introuvable"}), 404
return jsonify(state.to_dict())
@app.route('/api/urgences/list')
def urgences_list():
if not _URGENCES_AVAILABLE:
return jsonify({"error": "module urgences_orchestrator indisponible"}), 503
return jsonify({"orchestrations": list_orchestrations()})
# =============================================================================
# Main
# =============================================================================

View File

@@ -49,7 +49,10 @@ try:
from PIL import Image as PILImage
import pyautogui
PYAUTOGUI_AVAILABLE = True
except ImportError:
except Exception:
# pyautogui peut lever Xlib.error.DisplayConnectionError (pas un ImportError)
# quand X n'est pas accessible — typique d'un service systemd headless côté
# serveur. Le serveur n'a pas besoin de pyautogui (utilisé côté client agent).
PYAUTOGUI_AVAILABLE = False
PILImage = None
pyautogui = None
@@ -147,8 +150,10 @@ class AutonomousPlanner:
"""Initialise le client VLM pour analyse intelligente."""
if VLM_AVAILABLE and OllamaClient:
try:
self._vlm_client = OllamaClient(model="qwen2.5vl:7b")
logger.info("VLM client initialized (qwen2.5vl:7b)")
from core.detection.vlm_config import get_vlm_model
_planner_vlm = get_vlm_model()
self._vlm_client = OllamaClient(model=_planner_vlm)
logger.info("VLM client initialized (%s)", _planner_vlm)
except Exception as e:
logger.warning(f"Could not initialize VLM client: {e}")
self._vlm_client = None

View File

@@ -0,0 +1,518 @@
"""Orchestrateur démo GHT Sud 95 — pilotage du scénario "traite N dossiers".
Reçoit une commande naturelle de Léa (chat) et orchestre :
1. Parsing intent via gemma3:1b (mini-LLM local, ~400 ms)
2. Setup Chrome (Win+R → URL maquette → Enter) via /replay/raw
3. extract_table sur la liste des patients (regex IPP, limit=N)
4. Boucle : pour chaque IPP, lance le workflow "Urgence_unit" via /replay
avec `variables={"patient_id": ipp}` pour la résolution `{{patient_id}}`
5. Synthèse finale postée dans le chat
L'orchestration tourne dans un thread daemon. L'état est stocké en mémoire,
poll-able via /api/urgences/status/<orch_id>.
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Chargement explicite de .env.local du repo (le service systemd peut ne pas
# voir cet env file). Cherche dans le parent de agent_chat/.
def _load_env_local() -> None:
env_path = Path(__file__).resolve().parent.parent / ".env.local"
if not env_path.is_file():
return
try:
for line in env_path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
os.environ.setdefault(k, v)
except Exception as e:
logger.warning("Erreur chargement .env.local: %s", e)
_load_env_local()
# ─────────────────────────────────────────────────────────────────────
# Config
# ─────────────────────────────────────────────────────────────────────
STREAM_BASE = os.environ.get("RPA_STREAM_BASE", "http://localhost:5005")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/generate")
NLP_MODEL = os.environ.get("LEA_NLP_MODEL", "gemma3:1b")
RPA_API_TOKEN = os.environ.get("RPA_API_TOKEN", "")
URGENCE_WORKFLOW_ID = os.environ.get("LEA_URGENCE_WORKFLOW_ID", "wf_urgence_unit")
# URL LAN locale (sans Basic Auth ni HTTPS) pour éviter le prompt Windows Hello
# de Chrome (lecteur d'empreintes digitales) qui bloque le replay automatique.
# L'URL publique HTTPS reste disponible (https://urgence.labs.laurinebazin.design)
# pour usage humain, mais n'est PAS utilisée par Léa pendant la démo.
MAQUETTE_URL = os.environ.get("LEA_MAQUETTE_URL", "http://192.168.1.40:8765/index.html")
# Session de replay stable de l'agent V1. L'agent polle /replay/next sur
# `agent_<user_id>` indépendamment des sessions d'enregistrement (sess_*).
# user_id default côté agent V1 = "demo_user" (cf. agent_v1/main.py:62).
AGENT_SESSION_ID = os.environ.get("LEA_AGENT_SESSION_ID", "agent_demo_user")
# machine_id de l'agent V1 cible. DOIT matcher self.machine_id côté agent V1
# (sinon /replay/next ne distribue pas la queue à cette machine — le serveur
# isole les machines pour éviter le vol cross-machine d'actions).
# Valeur par défaut = hostname du PC Windows de démo GHT.
AGENT_MACHINE_ID = os.environ.get("LEA_AGENT_MACHINE_ID", "DESKTOP-58D5CAC_windows")
# Pattern IPP : 8 chiffres, premier groupe "25" (cohort 2025), reste libre
IPP_PATTERN = r"^25\d{6}$"
# ─────────────────────────────────────────────────────────────────────
# NLP : parsing de commande naturelle via gemma3:1b
# ─────────────────────────────────────────────────────────────────────
NLP_PROMPT = """Tu es un parseur d'intentions pour Léa, assistant RPA médical.
Réponds UNIQUEMENT en JSON valide, sans texte avant/après, selon ce schéma :
{"action": "process_patients" | "stop" | "unknown", "count": <int|null>, "order": "first" | "last" | "all" | "specific" | null, "ipp": "<string>" | null}
Règles :
- "traite N dossiers" / "code N dossiers" / "fais les N premiers" → action=process_patients, count=N, order="first"
- "traite tous les dossiers" → action=process_patients, count=null, order="all"
- "traite le dossier 25003364" → action=process_patients, count=1, order="specific", ipp="25003364"
- "stop" / "arrête" / "annule" → action=stop
- Question ("comment", "pourquoi") → action=unknown
- Si tu ne comprends pas → action=unknown"""
def parse_lea_command(text: str, model: str = NLP_MODEL, timeout: int = 8) -> Dict[str, Any]:
"""Parse une commande naturelle en intent structuré via gemma3:1b.
Fallback regex si Ollama est indisponible — pour ne pas bloquer la démo.
Returns : dict {action, count, order, ipp} ou {action: "unknown"}.
"""
payload = {
"model": model,
"prompt": NLP_PROMPT + "\n\nUtilisateur : " + text + "\n\nJSON :",
"stream": False,
"format": "json",
"options": {"temperature": 0.0, "num_predict": 120, "num_ctx": 1024},
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(OLLAMA_URL, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
raw = (body.get("response") or "").strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
intent = json.loads(raw)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
logger.warning("parse_lea_command: gemma3:1b indisponible (%s), fallback regex", e)
return _parse_fallback_regex(text)
# Post-processing : gemma3:1b a tendance à remplir tous les champs même
# quand non pertinent. On nettoie :
# - ipp ne doit être conservé que si présent LITTÉRALEMENT dans le texte source
# (sinon le LLM hallucine un IPP plausible)
if intent.get("ipp") and str(intent["ipp"]) not in text:
intent["ipp"] = None
# Si le LLM a forcé order=specific sans vrai IPP, on bascule en first
if intent.get("order") == "specific":
intent["order"] = "first"
# - ipp ne doit être conservé que si order="specific" ET format IPP valide
if intent.get("ipp") and intent.get("order") != "specific":
intent["ipp"] = None
if intent.get("ipp") and not re.match(r"^\d{8,10}$", str(intent["ipp"])):
intent["ipp"] = None
# - si count est défini ET order="all", l'humain demande "N dossiers" et
# non "tous les dossiers" : on bascule en "first" (cohérence sémantique)
if intent.get("count") and intent.get("order") == "all":
intent["order"] = "first"
return intent
def _parse_fallback_regex(text: str) -> Dict[str, Any]:
"""Fallback regex robuste si LLM HS — couvre les phrasings classiques."""
t = text.lower()
if any(w in t for w in ("stop", "arrête", "annule", "annuler")):
return {"action": "stop", "count": None, "order": None, "ipp": None}
# IPP spécifique : "traite le dossier 25003364"
m = re.search(r"\b(25\d{6})\b", text)
if m and any(w in t for w in ("traite", "code", "analyse")):
return {"action": "process_patients", "count": 1, "order": "specific", "ipp": m.group(1)}
if any(w in t for w in ("tous", "toutes")) and any(w in t for w in ("traite", "code")):
return {"action": "process_patients", "count": None, "order": "all", "ipp": None}
# Quantifié : "traite 3 dossiers"
m = re.search(r"(\d+)\s*(?:premiers?\s*)?(?:dossiers?|cas|patients?)", t)
if m and any(w in t for w in ("traite", "code", "fais", "analyse")):
return {"action": "process_patients", "count": int(m.group(1)), "order": "first", "ipp": None}
return {"action": "unknown", "count": None, "order": None, "ipp": None}
# ─────────────────────────────────────────────────────────────────────
# Helpers HTTP vers le streaming server (port 5005)
# ─────────────────────────────────────────────────────────────────────
def _stream_headers() -> Dict[str, str]:
h = {"Content-Type": "application/json"}
if RPA_API_TOKEN:
h["Authorization"] = f"Bearer {RPA_API_TOKEN}"
return h
def _post(path: str, body: dict, timeout: int = 30) -> dict:
req = urllib.request.Request(
STREAM_BASE + path,
data=json.dumps(body).encode("utf-8"),
headers=_stream_headers(),
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _get(path: str, timeout: int = 10) -> dict:
req = urllib.request.Request(
STREAM_BASE + path,
headers=_stream_headers(),
method="GET",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
# ─────────────────────────────────────────────────────────────────────
# Orchestration : état + thread d'exécution
# ─────────────────────────────────────────────────────────────────────
@dataclass
class DossierResult:
ipp: str
decision: Optional[str] = None # "REQUALIFICATION_HOSPITALISATION" | "FORFAIT_URGENCE"
decision_court: Optional[str] = None # "UHCD" | "Forfait Urgences"
confiance: Optional[str] = None
duree_passage_heures: Optional[float] = None
concordance: Optional[bool] = None
error: Optional[str] = None
@dataclass
class OrchestrationState:
orch_id: str
status: str = "starting" # starting | running | done | error | cancelled
progress: int = 0 # 0 → count
count: int = 0
current_step: str = "" # "setup_chrome" | "extract_table" | "process_dossier_X" | "synthese"
intent: Dict[str, Any] = field(default_factory=dict)
patients: List[str] = field(default_factory=list)
results: List[DossierResult] = field(default_factory=list)
synthese: Optional[str] = None
error: Optional[str] = None
started_at: float = field(default_factory=time.time)
finished_at: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
return {
"orch_id": self.orch_id,
"status": self.status,
"progress": self.progress,
"count": self.count,
"current_step": self.current_step,
"intent": self.intent,
"patients": self.patients,
"results": [r.__dict__ for r in self.results],
"synthese": self.synthese,
"error": self.error,
"elapsed_s": round((self.finished_at or time.time()) - self.started_at, 1),
}
# Registry global des orchestrations en cours (thread-safe via lock)
_ORCH_REGISTRY: Dict[str, OrchestrationState] = {}
_ORCH_LOCK = threading.Lock()
def get_orchestration(orch_id: str) -> Optional[OrchestrationState]:
with _ORCH_LOCK:
return _ORCH_REGISTRY.get(orch_id)
def list_orchestrations() -> List[Dict[str, Any]]:
with _ORCH_LOCK:
return [s.to_dict() for s in _ORCH_REGISTRY.values()]
def start_orchestration(
intent: Dict[str, Any],
session_id: str = "",
machine_id: Optional[str] = None,
) -> OrchestrationState:
"""Lance une orchestration en thread daemon. Retourne l'état initial.
Args:
intent: dict {action, count, order, ipp} (sortie de parse_lea_command)
session_id: session de replay (default: agent_demo_user, le canal stable
sur lequel l'agent V1 polle /replay/next)
machine_id: machine cible (optionnel, pour multi-machines futurs)
"""
if not session_id:
session_id = AGENT_SESSION_ID
if not machine_id:
machine_id = AGENT_MACHINE_ID
orch_id = "orch_" + uuid.uuid4().hex[:10]
count = intent.get("count") or 3 # default 3 si "tous" ou "first" sans nombre
state = OrchestrationState(
orch_id=orch_id,
status="starting",
count=count,
intent=intent,
)
with _ORCH_LOCK:
_ORCH_REGISTRY[orch_id] = state
th = threading.Thread(
target=_run_orchestration,
args=(state, session_id, machine_id),
daemon=True,
name=f"orch-{orch_id}",
)
th.start()
return state
def _run_orchestration(state: OrchestrationState, session_id: str, machine_id: Optional[str]) -> None:
"""Boucle d'orchestration exécutée dans un thread.
Phases :
1. Setup Chrome (raw actions Win+R)
2. extract_table sur liste patients
3. Boucle workflow Urgence_unit
4. Synthèse
"""
try:
state.status = "running"
intent = state.intent
# Cas "specific" : court-circuiter, juste 1 IPP
if intent.get("order") == "specific" and intent.get("ipp"):
state.patients = [intent["ipp"]]
state.count = 1
state.current_step = "process_dossier"
_process_dossiers(state, session_id, machine_id)
else:
# 1. Setup Chrome → URL maquette
state.current_step = "setup_chrome"
_setup_chrome(session_id, machine_id)
# 2. Lire la liste des IPP via extract_table
state.current_step = "extract_table"
patients = _extract_patient_list(session_id, machine_id, limit=state.count)
state.patients = patients
if not patients:
raise RuntimeError("extract_table n'a trouvé aucun IPP — vérifier que Chrome est sur index.html")
# 3. Pour chaque IPP : lancer workflow Urgence_unit
_process_dossiers(state, session_id, machine_id)
# 4. Synthèse
state.current_step = "synthese"
state.synthese = _build_synthese(state)
state.status = "done"
except Exception as e:
logger.exception("Orchestration %s : erreur fatale", state.orch_id)
state.status = "error"
state.error = str(e)
finally:
state.finished_at = time.time()
# ─────────────────────────────────────────────────────────────────────
# Phases de l'orchestration
# ─────────────────────────────────────────────────────────────────────
def _setup_chrome(session_id: str, machine_id: Optional[str]) -> None:
"""Composer "ouvrir Chrome sur l'URL maquette" via le catalogue de réflexes.
Léa ne fait PAS un workflow appris pour cette étape : c'est une composition
de primitives natives (réflexes du catalogue) + une saisie texte.
Séquence :
1. réflexe `sys_run` (Win+R) ← gesture_catalog
2. type "chrome.exe <URL>" ← saisie atomique
3. réflexe `nav_enter` (Entrée) ← gesture_catalog
"""
from agent_chat.gesture_catalog import get_gesture_catalog
catalog = get_gesture_catalog()
show_desktop = catalog.get_by_id("win_minimize_all") # Win+D — minimise tout (Léa incl.)
sys_run = catalog.get_by_id("sys_run")
nav_enter = catalog.get_by_id("nav_enter")
if sys_run is None or nav_enter is None or show_desktop is None:
raise RuntimeError("Réflexes catalogue manquants : win_minimize_all / sys_run / nav_enter")
actions = [
show_desktop.to_replay_action(), # réflexe Win+D — Léa se réduit complètement
{
"action_id": f"setup_wait_desktop_{uuid.uuid4().hex[:6]}",
"type": "wait",
"duration_ms": 400,
"intention": "Attendre que le bureau soit affiché",
},
sys_run.to_replay_action(), # réflexe Win+R
{
"action_id": f"setup_wait_{uuid.uuid4().hex[:6]}",
"type": "wait",
"duration_ms": 800,
"intention": "Attendre que la boîte Exécuter soit prête",
},
{
"action_id": f"setup_typeurl_{uuid.uuid4().hex[:6]}",
"type": "type",
"text": f"chrome.exe {MAQUETTE_URL}",
"intention": "Taper la commande Chrome + URL maquette",
},
nav_enter.to_replay_action(), # réflexe Entrée
{
"action_id": f"setup_wait_load_{uuid.uuid4().hex[:6]}",
"type": "wait",
"duration_ms": 3500,
"intention": "Attendre le chargement de la maquette",
},
]
payload = {
"actions": actions,
"session_id": session_id,
"task_description": "Setup démo GHT — composition réflexes (sys_run + type + nav_enter)",
}
if machine_id:
payload["machine_id"] = machine_id
resp = _post("/api/v1/traces/stream/replay/raw", payload, timeout=20)
replay_id = resp.get("replay_id")
if not replay_id:
raise RuntimeError(f"setup_chrome : pas de replay_id ({resp})")
# Setup Chrome ≈ 13s observé (Win+D + Win+R + type URL + Enter + wait 3500ms),
# mais le PC peut être chargé → 60s donne de la marge.
_wait_replay_done(replay_id, timeout_s=60)
def _extract_patient_list(session_id: str, machine_id: Optional[str], limit: int) -> List[str]:
"""Lance une action extract_table seule pour lire la liste des IPP."""
actions = [
{
"action_id": f"extract_table_{uuid.uuid4().hex[:6]}",
"type": "extract_table",
"parameters": {
"output_var": "patients_list",
"pattern": IPP_PATTERN,
"limit": limit,
},
"intention": "Lire la liste des IPP visible à l'écran",
},
]
payload = {
"actions": actions,
"session_id": session_id,
"task_description": "Extraction liste patients GHT",
}
if machine_id:
payload["machine_id"] = machine_id
resp = _post("/api/v1/traces/stream/replay/raw", payload, timeout=15)
replay_id = resp.get("replay_id")
if not replay_id:
raise RuntimeError(f"extract_table : pas de replay_id ({resp})")
final = _wait_replay_done(replay_id, timeout_s=20)
return list(final.get("variables", {}).get("patients_list") or [])
def _process_dossiers(state: OrchestrationState, session_id: str, machine_id: Optional[str]) -> None:
"""Boucle : pour chaque IPP, lance le workflow Urgence_unit."""
for i, ipp in enumerate(state.patients):
state.current_step = f"process_dossier_{i+1}_of_{len(state.patients)}"
result = DossierResult(ipp=ipp)
try:
payload = {
"workflow_id": URGENCE_WORKFLOW_ID,
"session_id": session_id,
"variables": {"patient_id": ipp},
}
if machine_id:
payload["machine_id"] = machine_id
resp = _post("/api/v1/traces/stream/replay", payload, timeout=20)
replay_id = resp.get("replay_id")
if not replay_id:
raise RuntimeError(f"replay_id manquant ({resp})")
final = _wait_replay_done(replay_id, timeout_s=180)
t2a = final.get("variables", {}).get("t2a_result") or {}
result.decision = t2a.get("decision")
result.decision_court = t2a.get("decision_court")
result.confiance = t2a.get("confiance")
result.duree_passage_heures = t2a.get("duree_passage_heures")
result.concordance = t2a.get("concordance")
except Exception as e:
result.error = str(e)
logger.warning("Dossier %s : erreur %s", ipp, e)
state.results.append(result)
state.progress = i + 1
def _wait_replay_done(replay_id: str, timeout_s: int = 60, poll_s: float = 1.0) -> Dict[str, Any]:
"""Poll /replay/<id> jusqu'à status terminal."""
deadline = time.time() + timeout_s
last = {}
while time.time() < deadline:
try:
last = _get(f"/api/v1/traces/stream/replay/{replay_id}", timeout=5)
except Exception as e:
logger.warning("poll replay %s : %s", replay_id, e)
status = last.get("status", "")
if status in ("done", "completed", "finished", "error", "cancelled", "paused_need_help"):
return last
time.sleep(poll_s)
raise TimeoutError(f"replay {replay_id} non terminé après {timeout_s}s (status={last.get('status')})")
# ─────────────────────────────────────────────────────────────────────
# Synthèse finale
# ─────────────────────────────────────────────────────────────────────
def _build_synthese(state: OrchestrationState) -> str:
"""Construit le message de synthèse posté dans le chat à la fin."""
n = len(state.results)
if n == 0:
return "Aucun dossier traité."
n_uhcd = sum(1 for r in state.results if r.decision == "REQUALIFICATION_HOSPITALISATION")
n_forfait = sum(1 for r in state.results if r.decision == "FORFAIT_URGENCE")
n_concord = sum(1 for r in state.results if r.concordance is True)
lines = [f"✅ Terminé. {n} dossier(s) traité(s) : {n_forfait} forfait(s) urgences, {n_uhcd} UHCD."]
if any(r.concordance is not None for r in state.results):
lines.append(f"Concordance vérité-terrain : {n_concord}/{n}.")
lines.append("")
for r in state.results:
if r.error:
lines.append(f"{r.ipp} : ❌ erreur — {r.error}")
continue
decision_label = r.decision_court or r.decision or ""
conf = f"confiance {r.confiance}" if r.confiance else ""
duree = f"{r.duree_passage_heures:.1f}h" if r.duree_passage_heures else ""
concord_mark = ""
if r.concordance is True:
concord_mark = ""
elif r.concordance is False:
concord_mark = " ⚠ écart vérité-terrain"
details = ", ".join(x for x in (conf, duree) if x)
lines.append(f"{r.ipp} : {decision_label}{concord_mark}" + (f" ({details})" if details else ""))
return "\n".join(lines)

View File

@@ -40,10 +40,18 @@ MACHINE_ID = os.environ.get(
BASE_DIR = Path(__file__).resolve().parent
# Endpoint du serveur Streaming (port 5005)
# SERVER_URL contient TOUJOURS /api/v1 à la fin (convention unifiée).
SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1")
# Base sans /api/v1 — pour les routes à la racine (/health)
SERVER_BASE = SERVER_URL.rsplit("/api/v1", 1)[0]
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream"
# Host Ollama — SÉPARÉ du serveur RPA.
# Ollama tourne en local sur la machine serveur, jamais exposé via le reverse proxy.
# Défaut : localhost (exécution locale ou accès LAN direct).
OLLAMA_HOST = os.getenv("RPA_OLLAMA_HOST", "localhost")
# Token d'authentification API (doit correspondre au token du serveur)
# Configurable via variable d'environnement RPA_API_TOKEN
API_TOKEN = os.environ.get("RPA_API_TOKEN", "")

View File

@@ -94,6 +94,11 @@ class ActionExecutorV1:
# pause supervisée au serveur (`paused_need_help`).
# Cf. core/system_dialog_guard.py
self._system_dialog_pause: Optional[Dict[str, Any]] = None
# Référence à la ChatWindow Léa V1 (Tkinter) pour afficher les bulles
# paused interactives quand le serveur signale une pause supervisée.
# Câblée depuis main.py après instanciation des deux objets.
# Si None (mode headless / tests), fallback sur self.notifier.
self._chat_window_ref = None
# Log de la resolution physique pour le diagnostic DPI
self._log_screen_info()
@@ -477,9 +482,15 @@ class ActionExecutorV1:
},
headers=headers,
timeout=10,
allow_redirects=False,
)
if resp.ok:
if resp.status_code in (301, 302, 307, 308):
logger.warning(
f"Redirection {resp.status_code} sur POST {url}"
f"verifiez RPA_SERVER_URL (https:// si redirect)"
)
elif resp.ok:
data = resp.json()
state = data.get("screen_state", "ok")
if state != "ok":
@@ -703,7 +714,11 @@ class ActionExecutorV1:
f"attendu '{expected_title}' → mode apprentissage"
)
try:
self.notifier.replay_wrong_window(current_title, expected_title)
self.notifier.replay_learning_mode(
raison="wrong_window",
target_description=expected_title,
window_title=current_title,
)
except Exception:
pass
@@ -935,9 +950,10 @@ class ActionExecutorV1:
# et ne trouve toujours pas. L'humain doit montrer.
print(f" [POLICY] Retry échoué → mode apprentissage")
try:
self.notifier.replay_target_not_found(
target_desc,
target_spec.get("window_title", ""),
self.notifier.replay_learning_mode(
raison="retry_failed",
target_description=target_desc,
window_title=target_spec.get("window_title", ""),
)
except Exception:
pass
@@ -993,9 +1009,10 @@ class ActionExecutorV1:
# passe en mode capture et enregistre ce que
# l'humain fait (mini-workflow de correction).
try:
self.notifier.replay_target_not_found(
target_desc,
target_spec.get("window_title", ""),
self.notifier.replay_learning_mode(
raison="supervise",
target_description=target_desc,
window_title=target_spec.get("window_title", ""),
)
except Exception:
pass
@@ -1131,8 +1148,9 @@ class ActionExecutorV1:
if raw_keys:
self._replay_raw_keys(raw_keys)
else:
# Fallback copier-coller (anciens enregistrements sans raw_keys)
self._type_text(text)
# Fallback (anciens enregistrements ou templating runtime sans raw_keys)
# paste=True (opt-in via action.paste) → clipboard + Ctrl+V (non-Citrix)
self._type_text(text, paste=bool(action.get("paste", False)))
print(f" [TYPE] Termine.")
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
@@ -1221,7 +1239,9 @@ class ActionExecutorV1:
f"je demande de l'aide"
)
try:
self.notifier.replay_no_screen_change(action_type)
self.notifier.replay_learning_mode(
raison="no_screen_change",
)
except Exception:
pass
@@ -1268,6 +1288,13 @@ class ActionExecutorV1:
pass
else:
print(f" [OK] Changement d'ecran detecte apres {action_type}")
# Stabilisation page avant capture screenshot_after :
# _wait_for_screen_change retourne dès le 1er tick de diff
# (200ms), or sur une transition de page (fade, render API)
# le DOM n'est pas encore stable → screenshot ambigu →
# verify_action serveur déclenche retry fantôme. Cf. démo
# GHT 2026-05-11.
time.sleep(0.5)
else:
# Pour type/wait/scroll, petit delai pour laisser l'ecran se stabiliser
time.sleep(0.5)
@@ -1276,8 +1303,11 @@ class ActionExecutorV1:
result["screenshot"] = self._capture_screenshot_b64()
except Exception as e:
result["error"] = str(e)
logger.error(f"Echec replay action {action_id} ({action_type}) : {e}")
result["error"] = f"{type(e).__name__}: {e}"
logger.error(
f"Echec replay action {action_id} ({action_type}) : {e}",
exc_info=True,
)
return result
@@ -1377,7 +1407,13 @@ class ActionExecutorV1:
try:
print(f" [SERVER-RESOLVE] Appel serveur {server_url}...")
resp = _requests.post(url, json=payload, headers=headers, timeout=30)
resp = _requests.post(url, json=payload, headers=headers, timeout=30, allow_redirects=False)
if resp.status_code in (301, 302, 307, 308):
logger.warning(
f"Redirection {resp.status_code} sur POST {url}"
f"verifiez RPA_SERVER_URL (https:// si redirect)"
)
return None
if not resp.ok:
logger.warning(f"Server resolve HTTP {resp.status_code}")
return None
@@ -1521,7 +1557,7 @@ class ActionExecutorV1:
if not vlm_description:
return None
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
prompt = (
@@ -1657,7 +1693,7 @@ Example: x_pct=0.50, y_pct=0.30"""
if anchor_b64:
images.append(anchor_b64)
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
# Prefill pour les modèles thinking (qwen3) — évite le mode réflexion >180s
@@ -1758,7 +1794,15 @@ Example: x_pct=0.50, y_pct=0.30"""
replay_next_url,
params={"session_id": session_id, "machine_id": machine_id},
headers=self._auth_headers(),
timeout=5,
# 8 mai 2026 — démo GHT : 5s → 30s. Le serveur peut exécuter
# extract_text (5-7s) PUIS dispatcher l'action suivante dans
# la même réponse HTTP. À 5s, le client coupait avant la
# réponse, le serveur stockait l'action dans _retry_pending
# mais aucun watchdog ne la republiait → action perdue
# silencieusement. Cf. docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md.
# Le serveur a sa propre borne 180s + retour server_busy à 4.5s,
# 30s côté client est sûr.
timeout=30,
)
if not resp.ok:
logger.debug(f"Poll replay echoue : HTTP {resp.status_code}")
@@ -1776,6 +1820,60 @@ Example: x_pct=0.50, y_pct=0.30"""
self._last_conn_error_logged = False
data = resp.json()
# Plan B (8 mai 2026 — démo GHT) : si le serveur signale une pause
# supervisée, afficher le pause_message dans la ChatWindow Léa V1
# (Tkinter, déjà ouverte sur Windows) sous forme de bulle interactive
# avec boutons Continuer / Annuler. Permet à l'utilisateur Windows de
# voir physiquement ce que Léa attend (pause_for_human ou échec
# résolution). Fallback notifier.notify si la ChatWindow n'est pas
# câblée (mode headless / tests).
if data.get("replay_paused"):
pause_msg = data.get("pause_message") or "Léa a besoin de votre aide"
replay_id = data.get("replay_id") or ""
pause_key = (replay_id, pause_msg)
if getattr(self, "_last_pause_msg_shown", None) != pause_key:
self._last_pause_msg_shown = pause_key
completed = data.get("current_action_index", 0)
total = data.get("total_actions", "?")
payload = {
"replay_id": replay_id,
"workflow": "Replay en cours",
"reason": pause_msg,
"completed": completed,
"total": total,
}
# UX fix 8 mai 2026 (démo GHT) : un seul affichage. La
# bulle ChatWindow EST l'affichage canonique (force show +
# topmost + bell sonore). Plus de toast paused_toast en
# double — Dom ne voulait qu'un seul popup. Si la ChatWindow
# n'est pas câblée (mode headless / tests), on retombe sur
# le toast Tkinter custom comme dernier recours visuel.
chat_window = getattr(self, "_chat_window_ref", None)
if chat_window is not None:
try:
# _add_paused_bubble est thread-safe (utilise root.after)
# et force l'affichage + topmost + bell sonore.
chat_window._add_paused_bubble(payload)
except Exception:
logger.debug(
"chat_window._add_paused_bubble pause silenced",
exc_info=True,
)
else:
# Fallback : pas de ChatWindow câblée → toast Tkinter
# autonome (headless / tests). C'est le SEUL chemin qui
# déclenche encore show_paused_toast.
try:
from ..ui.paused_toast import show_paused_toast
show_paused_toast(
title="Léa a besoin de votre aide",
message=pause_msg[:300],
)
except Exception:
logger.debug("paused_toast fallback silenced", exc_info=True)
return False
action = data.get("action")
if action is None:
return False
@@ -1861,8 +1959,14 @@ Example: x_pct=0.50, y_pct=0.30"""
json=report,
headers=self._auth_headers(),
timeout=10,
allow_redirects=False,
)
if resp2.ok:
if resp2.status_code in (301, 302, 307, 308):
logger.warning(
f"Redirection {resp2.status_code} sur POST {replay_result_url}"
f"verifiez RPA_SERVER_URL (https:// si redirect)"
)
elif resp2.ok:
server_resp = resp2.json()
msg = (
f"Resultat rapporte : replay_status={server_resp.get('replay_status')}, "
@@ -2128,7 +2232,7 @@ Example: x_pct=0.50, y_pct=0.30"""
"""
import requests as _requests
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
prompt = (
@@ -2271,7 +2375,7 @@ Example: x_pct=0.50, y_pct=0.30"""
best_match = None
best_val = 0.0
threshold = 0.50 # Seuil équilibré
threshold = 0.75 # Démo GHT 8 mai — éviter faux positifs (placeholders italiques, tabs voisins). En dessous, mieux vaut tomber en mode apprentissage humain qu'un clic au pif.
# Essayer plusieurs tailles de police pour couvrir différentes résolutions
for font_size in [14, 16, 18, 20, 22, 24, 12, 26, 28, 10]:
@@ -2445,20 +2549,48 @@ Example: x_pct=0.50, y_pct=0.30"""
# Helpers
# =========================================================================
def _type_text(self, text: str):
"""Saisir du texte caractère par caractère (anti-détection robot).
def _type_text(self, text: str, paste: bool = False):
"""Saisir du texte caractère par caractère (anti-détection robot)
OU via clipboard + Ctrl+V si paste=True (opt-in workflow non-Citrix).
Chaque caractère est tapé individuellement avec un délai aléatoire
pour simuler une frappe humaine. Les caractères spéciaux AZERTY
(@ # € etc.) utilisent les bons VK codes via KeyCode.from_char().
Mode char-by-char (défaut) :
Chaque caractère tapé individuellement avec délai aléatoire
(anti-détection robot, requis pour Citrix).
Caractères spéciaux AZERTY via KeyCode.from_char().
Pas de copier-coller (détectable par les systèmes anti-robot Citrix).
Mode paste (opt-in via action.paste=True dans le workflow) :
Texte poussé dans le clipboard Windows (CF_UNICODETEXT) puis Ctrl+V.
Gain massif sur textes longs (~100ms total vs ~80ms/char).
Réservé aux workflows hors-Citrix (maquette Easily web OK, Citrix bloque).
Fallback char-by-char automatique si exception clipboard.
"""
import random
if not text:
return
if paste:
try:
import win32clipboard
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT)
win32clipboard.CloseClipboard()
# 16 mai 2026 : 0.05s → 0.5s pour laisser NoMachine sync
# le clipboard Windows → VM Ubuntu avant le Ctrl+V (sinon
# le paste arrive avant que le clipboard guest soit à jour).
time.sleep(0.5)
self.keyboard.press(Key.ctrl_l)
self.keyboard.press(KeyCode.from_char('v'))
self.keyboard.release(KeyCode.from_char('v'))
self.keyboard.release(Key.ctrl_l)
time.sleep(0.1)
logger.debug(f"Texte saisi via clipboard paste ({len(text)} chars)")
return
except Exception as e:
logger.warning(f"Paste clipboard échoué ({e}) → fallback char-by-char")
# Chemin par défaut (ou fallback paste) : char-by-char
for char in text:
try:
# Taper le caractère via from_char (respecte le layout clavier)
@@ -2575,8 +2707,8 @@ Example: x_pct=0.50, y_pct=0.30"""
f"inactivité={INACTIVITY_TIMEOUT}s, hotkey=Ctrl+Shift+L)"
)
print(
f" [APPRENTISSAGE] Montre-moi comment faire.\n"
f" Quand tu as fini → Ctrl+Shift+L\n"
f" [APPRENTISSAGE] Je n'y arrive pas, montrez-moi comment faire.\n"
f" Quand vous avez fini → Ctrl+Shift+L\n"
f" (ou j'attends {INACTIVITY_TIMEOUT}s sans action)"
)

View File

@@ -17,6 +17,7 @@ import threading
from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
STREAMING_ENDPOINT,
)
from .core.captor import EventCaptorV1
from .core.executor import ActionExecutorV1
@@ -86,22 +87,23 @@ class AgentV1:
self._state.set_on_stop(self.stop_session)
# Client serveur pour le chat et les workflows
# Plus de RPA_SERVER_HOST : le LeaServerClient derive tout de SERVER_URL
self._server_client = None
if LeaServerClient is not None:
# Forcer le token API pour éviter les 401
# (le token est set par start.bat dans l'environnement)
from .config import API_TOKEN as _token
server_host = os.getenv("RPA_SERVER_HOST", "localhost")
self._server_client = LeaServerClient(server_host=server_host)
self._server_client = LeaServerClient()
if _token and not self._server_client._api_token:
self._server_client._api_token = _token
logger.info("Token API forcé dans LeaServerClient")
# Fenetre de chat Lea (tkinter natif)
# Le host est derive de SERVER_URL (plus de RPA_SERVER_HOST)
server_host = (
self._server_client.server_host
if self._server_client is not None
else os.getenv("RPA_SERVER_HOST", "localhost")
else "localhost"
)
self._chat_window = ChatWindow(
server_client=self._server_client,
@@ -114,6 +116,14 @@ class AgentV1:
# Executeur pour le replay (doit exister avant le poll)
self._executor = ActionExecutorV1()
# Wiring ChatWindow → Executor pour Plan B (pause_message → bulle interactive)
# Permet à l'executor d'afficher une bulle paused dans la fenêtre Léa V1
# quand le serveur signale replay_paused=True via /replay/next.
try:
self._executor._chat_window_ref = self._chat_window
except Exception:
logger.debug("Wiring chat_window→executor échoué (non bloquant)", exc_info=True)
# Boucles permanentes (pas besoin de session active)
self.running = True
self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background"))
@@ -363,11 +373,11 @@ class AgentV1:
continue
self._last_bg_hash = img_hash
# Envoyer au streaming server (avec token auth)
# Envoyer au streaming server (via STREAMING_ENDPOINT unifié)
headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
with open(full_path, 'rb') as f:
req.post(
f"{SERVER_URL}/traces/stream/image",
f"{STREAMING_ENDPOINT}/image",
params={
"session_id": bg_session,
"shot_id": f"heartbeat_{int(time.time())}",
@@ -376,6 +386,7 @@ class AgentV1:
headers=headers,
files={"file": ("screenshot.png", f, "image/png")},
timeout=10,
allow_redirects=False,
)
except Exception as e:
logger.debug(f"[HEARTBEAT] Erreur: {e}")
@@ -445,6 +456,12 @@ class AgentV1:
window_title = self.vision.get_active_window_title()
if window_title:
heartbeat_event["active_window_title"] = window_title
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
try:
from .vision.capturer import _enrich_with_monitor_info
_enrich_with_monitor_info(heartbeat_event)
except Exception:
pass
self.streamer.push_event(heartbeat_event)
except Exception as e:
logger.error(f"Heartbeat error: {e}")

View File

@@ -0,0 +1,149 @@
# agent_v1/network/feedback_bus.py
"""Client SocketIO pour le bus feedback Léa.
Consomme les events 'lea:*' émis par agent_chat (port 5004) et les dispatche
vers ChatWindow pour affichage en bulles temps réel.
Events écoutés :
lea:action_started — début d'un workflow ou d'une action
lea:action_progress — progression dans le workflow
lea:done — fin d'un workflow ou d'un copilot
lea:need_confirm — étape copilot en attente de validation
lea:step_result — résultat d'une étape copilot
lea:paused — basculement en paused_need_help (asset démo)
lea:resumed — sortie de pause supervisée
Fail-safe : toute erreur de connexion ou de dispatch est silencieusement
loggée. Le ChatWindow continue de fonctionner même si le bus est mort
(comportement strictement identique au pré-J3).
Usage :
bus = FeedbackBusClient(
server_url="http://localhost:5004",
token=os.environ.get("RPA_API_TOKEN", ""),
on_event=lambda event, payload: print(event, payload),
)
bus.start() # connexion en arrière-plan, non-bloquant
# ... ChatWindow tourne ...
bus.stop()
"""
import logging
import threading
from typing import Callable, Optional
import socketio
logger = logging.getLogger(__name__)
LEA_EVENTS = (
'lea:action_started',
'lea:action_progress',
'lea:done',
'lea:need_confirm',
'lea:step_result',
'lea:paused',
'lea:resumed',
)
EventCallback = Callable[[str, dict], None]
class FeedbackBusClient:
"""Client SocketIO non-bloquant pour le bus 'lea:*'."""
def __init__(
self,
server_url: str,
token: Optional[str] = None,
on_event: Optional[EventCallback] = None,
):
self._url = server_url.rstrip('/')
self._token = token or None
self._on_event: EventCallback = on_event or (lambda e, p: None)
self._sio = socketio.Client(
reconnection=True,
reconnection_attempts=0, # 0 = illimité
reconnection_delay=2,
reconnection_delay_max=30,
logger=False,
engineio_logger=False,
)
self._thread: Optional[threading.Thread] = None
self._register_handlers()
def _register_handlers(self) -> None:
@self._sio.event
def connect():
logger.info("FeedbackBus connecté à %s", self._url)
@self._sio.event
def disconnect():
logger.info("FeedbackBus déconnecté")
for ev in LEA_EVENTS:
self._sio.on(ev, lambda data, e=ev: self._dispatch(e, data))
def _dispatch(self, event: str, payload: Optional[dict]) -> None:
try:
self._on_event(event, payload or {})
except Exception:
logger.debug("FeedbackBus dispatch silenced", exc_info=True)
def start(self) -> None:
"""Démarrer la connexion en arrière-plan (idempotent, non-bloquant)."""
if self._thread is not None and self._thread.is_alive():
return
self._thread = threading.Thread(
target=self._run, daemon=True, name="LeaFeedbackBus",
)
self._thread.start()
def _run(self) -> None:
headers = {}
if self._token:
headers['Authorization'] = f'Bearer {self._token}'
try:
self._sio.connect(self._url, headers=headers, wait=True)
self._sio.wait()
except Exception as e:
logger.warning(
"FeedbackBus connect échoué (%s) — ChatWindow continue normalement", e,
)
def stop(self) -> None:
"""Arrêter proprement la connexion (idempotent, fail-safe)."""
try:
if self._sio.connected:
self._sio.disconnect()
except Exception:
logger.debug("FeedbackBus stop silenced", exc_info=True)
@property
def connected(self) -> bool:
return bool(self._sio.connected)
# ------------------------------------------------------------------
# Actions utilisateur depuis la bulle paused_need_help (J3.5)
# ------------------------------------------------------------------
def resume_replay(self, replay_id: str) -> bool:
"""Bouton Continuer : émet 'lea:replay_resume' vers agent_chat.
Retourne True si l'event a pu être émis, False sinon (déconnecté/erreur).
"""
return self._safe_emit("lea:replay_resume", {"replay_id": replay_id})
def abort_replay(self, replay_id: str) -> bool:
"""Bouton Annuler : émet 'lea:replay_abort' vers agent_chat."""
return self._safe_emit("lea:replay_abort", {"replay_id": replay_id})
def _safe_emit(self, event: str, payload: dict) -> bool:
try:
if not self._sio.connected:
return False
self._sio.emit(event, payload)
return True
except Exception:
logger.debug("FeedbackBus _safe_emit silenced", exc_info=True)
return False

View File

@@ -544,6 +544,28 @@ class TraceStreamer:
except OSError as e:
logger.debug(f"Purge échouée : {path}{e}")
# =========================================================================
# Protection redirect POST→GET (INC-7)
# =========================================================================
@staticmethod
def _check_redirect(resp, url: str):
"""Detecter et logger une redirection sur un POST.
La lib requests transforme un POST en GET sur 301/302 (RFC 7231).
Avec allow_redirects=False, on recoit le 301/302 directement.
On log un WARNING explicite pour que l'admin corrige l'URL.
"""
if resp.status_code in (301, 302, 307, 308):
location = resp.headers.get("Location", "?")
logger.warning(
f"Redirection {resp.status_code} detectee sur POST {url} "
f"{location}. Verifiez que RPA_SERVER_URL utilise "
f"https:// si le serveur redirige."
)
return True
return False
# =========================================================================
# Envois HTTP
# =========================================================================
@@ -551,15 +573,20 @@ class TraceStreamer:
def _register_session(self):
"""Enregistrer la session auprès du serveur (avec identifiant machine)."""
try:
url = f"{STREAMING_ENDPOINT}/register"
resp = requests.post(
f"{STREAMING_ENDPOINT}/register",
url,
params={
"session_id": self.session_id,
"machine_id": self.machine_id,
},
headers=self._auth_headers(),
timeout=3,
allow_redirects=False,
)
if self._check_redirect(resp, url):
logger.warning("Enregistrement session échoué (redirect)")
return
if resp.ok:
logger.info(
f"Session {self.session_id} enregistrée sur le serveur "
@@ -579,15 +606,18 @@ class TraceStreamer:
C'est la dernière chance de sauver les données de la session.
"""
try:
url = f"{STREAMING_ENDPOINT}/finalize"
resp = requests.post(
f"{STREAMING_ENDPOINT}/finalize",
url,
params={
"session_id": self.session_id,
"machine_id": self.machine_id,
},
headers=self._auth_headers(),
timeout=30, # Le build workflow peut prendre du temps
allow_redirects=False,
)
self._check_redirect(resp, url)
if resp.ok:
result = resp.json()
logger.info(f"Session finalisée: {result}")
@@ -601,6 +631,7 @@ class TraceStreamer:
if not self._server_available:
return False
try:
url = f"{STREAMING_ENDPOINT}/event"
payload = {
"session_id": self.session_id,
"timestamp": time.time(),
@@ -608,11 +639,14 @@ class TraceStreamer:
"machine_id": self.machine_id,
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/event",
url,
json=payload,
headers=self._auth_headers(),
timeout=2,
allow_redirects=False,
)
if self._check_redirect(resp, url):
return False
return resp.ok
except Exception as e:
logger.debug(f"Streaming Event échoué: {e}")
@@ -645,18 +679,22 @@ class TraceStreamer:
"machine_id": self.machine_id,
}
url = f"{STREAMING_ENDPOINT}/image"
if jpeg_buf is not None:
# Envoi du JPEG compressé (BytesIO, pas de fuite possible)
files = {
"file": (f"{shot_id}{suffix}", jpeg_buf, content_type)
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/image",
url,
files=files,
params=params,
headers=self._auth_headers(),
timeout=5,
allow_redirects=False,
)
if self._check_redirect(resp, url):
return ImageSendResult.FAILED
if resp.ok:
self._purge_local_image(path)
return ImageSendResult.OK
@@ -668,12 +706,15 @@ class TraceStreamer:
"file": (f"{shot_id}.png", f, "image/png")
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/image",
url,
files=files,
params=params,
headers=self._auth_headers(),
timeout=5,
allow_redirects=False,
)
if self._check_redirect(resp, url):
return ImageSendResult.FAILED
if resp.ok:
self._purge_local_image(path)
return ImageSendResult.OK

View File

@@ -3,7 +3,9 @@ mss>=9.0.1 # Capture d'écran haute performance
pynput>=1.7.7 # Clavier/Souris Cross-plateforme
Pillow>=10.0.0 # Crops et processing image
requests>=2.31.0 # Streaming réseau
python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur)
psutil>=5.9.0 # Monitoring CPU/RAM
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets
pystray>=0.19.5 # Icône Tray UI
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)
pywebview>=5.0 # Fenêtre de chat Léa intégrée (Edge WebView2 sur Windows)

View File

View File

@@ -0,0 +1,88 @@
# agent_v1/tools/test_lea_pause_flow.py
"""Smoke test : simuler un lea:paused localement et vérifier la bulle ChatWindow.
À lancer SUR WINDOWS (PC démo) :
cd C:/rpa_vision
.venv\\Scripts\\python.exe -m agent_v1.tools.test_lea_pause_flow
Ce script ouvre une ChatWindow, simule l'arrivée d'un payload paused_need_help
avec un message LONG (350+ chars pour tester le scroll interne), puis attend
les clics utilisateur sur Continuer/Annuler. Le test vérifie qu'il y a UN SEUL
affichage (la bulle chat), pas de toast supplémentaire.
Exit code 0 si succès. Logs dans la console.
"""
import logging
import os
import sys
import time
# Configurer le logging avant tout import du package
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s',
)
logger = logging.getLogger("test_lea_pause_flow")
# Forcer le bus feedback (pour que les boutons puissent émettre vers
# rpa-agent-chat — port 5004). Si on ne veut PAS du bus, mettre LEA_FEEDBACK_BUS=0.
os.environ.setdefault("LEA_FEEDBACK_BUS", "1")
os.environ.setdefault("RPA_API_TOKEN", "") # à remplir si serveur exige Bearer
def main() -> int:
try:
from agent_v1.ui.chat_window import ChatWindow
except ImportError as e:
print(f"[TEST] Import ChatWindow KO : {e}")
return 2
print("[TEST] Création ChatWindow...")
cw = ChatWindow(
server_client=None,
on_start_callback=None,
server_host=os.environ.get("RPA_SERVER_HOST", "192.168.1.40"),
chat_port=5004,
)
# Attendre que le tk loop soit prêt
time.sleep(2.0)
cw.show()
time.sleep(0.5)
print("[TEST] Simulation lea:paused avec long message (350 chars)...")
long_msg = (
"Je n'arrive pas à trouver le champ « Numéro de dossier patient » "
"sur l'écran courant. J'ai essayé 3 stratégies de grounding visuel "
"(template matching, OCR, VLM) sans succès. Pouvez-vous me montrer "
"l'emplacement exact du champ, ou cliquer dessus à ma place ? "
"Quand vous avez fini, cliquez sur Continuer pour que je reprenne."
)
payload = {
"replay_id": "test_replay_pause_flow_001",
"workflow": "Démo UHCD",
"reason": long_msg,
"completed": 5,
"total": 12,
}
cw._add_paused_bubble(payload)
print(f"[TEST] Bulle envoyée. Message len={len(long_msg)} chars.")
print("[TEST] Vérifiez visuellement :")
print(" 1) UN SEUL popup (la bulle chat dans la fenêtre Léa)")
print(" 2) Le message long s'affiche en intégralité (scroll interne si besoin)")
print(" 3) Boutons Continuer / Annuler visibles")
print(" 4) Cliquez sur Annuler → bulle fermée + feedback '✗ Annulé'")
print("[TEST] La fenêtre reste ouverte 30s. Ctrl+C pour quitter avant.")
try:
for i in range(30):
time.sleep(1.0)
except KeyboardInterrupt:
print("[TEST] Interruption clavier.")
print("[TEST] Test terminé. Vérifier visuellement les 4 points ci-dessus.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,87 @@
# agent_v1/tools/test_lea_toast.py
"""
Test visuel rapide du toast Léa (démo GHT 8 mai 2026).
Lance trois scénarios de toast successifs pour valider l'affichage Windows :
1. Toast simple « pause supervisée »
2. Toast avec message long (vérifier wraplength)
3. Toast type BLOCAGE (= ce que voit l'utilisateur quand Léa est perdue)
Usage Windows :
C:\\rpa_vision\\.venv\\Scripts\\python.exe C:\\rpa_vision\\agent_v1\\tools\\test_lea_toast.py
Le script s'attend à voir trois toasts successifs en haut-droite de l'écran
principal, espacés de ~6 s, fond bleu Léa, autodismiss après 15 s ou clic.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
def _bootstrap_path() -> None:
"""Autoriser l'exécution directe sans -m : ajouter C:\\rpa_vision au sys.path."""
here = Path(__file__).resolve()
# On remonte : tools -> agent_v1 -> rpa_vision (parent du package agent_v1)
rpa_root = here.parent.parent.parent
if str(rpa_root) not in sys.path:
sys.path.insert(0, str(rpa_root))
def main() -> int:
_bootstrap_path()
# Import après ajout du path (les deux variantes fonctionnent)
try:
from agent_v1.ui.paused_toast import show_paused_toast
except Exception as e: # pragma: no cover (debug only)
print(f"[TEST] ERREUR import agent_v1.ui.paused_toast : {e}")
return 1
scenarios = [
(
"Toast 1/3 : pause simple",
"Léa a besoin de votre aide",
"Test 1/3 — Pause supervisée. Cliquez sur 'Continuer' dans la chat.",
),
(
"Toast 2/3 : message long",
"Léa — j'attends votre validation",
(
"Test 2/3 — J'ai trouvé 11 dossiers correspondant à vos critères "
"(UHCD, Forfait 1, PE2). Je vais traiter le dossier de M. DUPONT "
"Jean en premier. Pouvez-vous valider que c'est le bon ordre "
"avant que je continue ?"
),
),
(
"Toast 3/3 : blocage cible non trouvée",
"Léa — je ne vois pas l'élément",
(
"Test 3/3 — Je n'arrive pas à trouver « Examens cliniques » à "
"l'écran. Pouvez-vous me montrer où cliquer ?"
),
),
]
for label, title, message in scenarios:
print(f"[TEST] {label}")
ok = show_paused_toast(title=title, message=message)
print(f" show_paused_toast() = {ok}")
if not ok:
print(f" ECHEC : {label}")
# Espacer pour que Dom voit chaque toast distinctement
# (rate limit interne = 3s pour message identique, mais ici les
# messages diffèrent, le rate limit ne s'applique pas)
time.sleep(6)
print("[TEST] Attente 12s supplémentaires pour laisser le dernier toast vivre...")
time.sleep(12)
print("[TEST] OK — fin du test. Si vous avez vu 3 toasts bleus en haut-droite,")
print(" le mécanisme Léa pause est validé.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,53 @@
# agent_v1/ui/_test_paused_toast.py
"""
Test isolé du toast paused — à exécuter directement sur Windows.
Usage (sur Windows, depuis C:\\rpa_vision\\agent_v1) :
python -m agent_v1.ui._test_paused_toast
OU plus simple :
python C:\\rpa_vision\\agent_v1\\ui\\_test_paused_toast.py
Le toast doit s'afficher en haut à droite de l'écran principal pendant ~15s.
"""
from __future__ import annotations
import sys
import time
def main() -> int:
print("[TEST] Lancement du toast paused...")
try:
# Import flexible : essai relatif puis absolu
try:
from .paused_toast import show_paused_toast
except ImportError:
from paused_toast import show_paused_toast
except Exception as e:
print(f"[TEST] ERREUR import : {e}")
return 1
ok = show_paused_toast(
title="Léa a besoin de votre aide",
message=(
"Test isolé — démo GHT 8 mai 2026.\n"
"Si vous voyez ce toast, le mécanisme de pause supervisée "
"fonctionne correctement."
),
)
print(f"[TEST] show_paused_toast() retour = {ok}")
if not ok:
print("[TEST] ÉCHEC : toast non déclenché.")
return 2
print("[TEST] Toast déclenché. Attente de 18s pour le voir s'afficher puis se fermer...")
time.sleep(18)
print("[TEST] OK — fin du test.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -16,6 +16,15 @@ from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__)
# FeedbackBus : import fail-safe (le ChatWindow doit tourner même si python-socketio
# n'est pas installé sur le poste client, par exemple ancienne installation Pauline)
try:
from ..network.feedback_bus import FeedbackBusClient
_HAS_FEEDBACK_BUS = True
except Exception:
FeedbackBusClient = None # type: ignore
_HAS_FEEDBACK_BUS = False
# ---------------------------------------------------------------------------
# Theme — palette professionnelle claire
# ---------------------------------------------------------------------------
@@ -42,6 +51,25 @@ SCROLLBAR_BG = "#E5E7EB" # Fond scrollbar
SCROLLBAR_FG = "#9CA3AF" # Curseur scrollbar
MSG_BORDER_COLOR = "#D1D5DB" # Bordure subtile des bulles de messages
# Bulle paused_need_help (J3.5) — alerte non bloquante, asset démo majeur
PAUSED_BG = "#FEF3C7" # Jaune pâle
PAUSED_BORDER = "#F59E0B" # Orange ambré
PAUSED_FG = "#92400E" # Brun foncé (lisible sur fond jaune)
PAUSED_BTN_RESUME_BG = "#22C55E" # Vert
PAUSED_BTN_RESUME_HOVER = "#16A34A"
PAUSED_BTN_ABORT_BG = "#9CA3AF" # Gris neutre (pas dramatique)
PAUSED_BTN_ABORT_HOVER = "#6B7280"
# Bulle "Léa exécute" (J3.4) — distincte des bulles chat normales
ACTION_BG = "#F1F5F9" # Gris très clair (différencie d'une réponse chat)
ACTION_BORDER = "#CBD5E1" # Gris pâle
ACTION_FG = "#1E293B" # Gris foncé
ACTION_META_FG = "#94A3B8" # Métadonnées en gris discret
ACTION_ICON_RUN = "#3B82F6" # Bleu (en cours)
ACTION_ICON_OK = "#22C55E" # Vert (succès)
ACTION_ICON_ERR = "#EF4444" # Rouge (échec)
ACTION_ICON_INFO = "#64748B" # Gris (neutre)
# Dimensions — confortables
WIN_WIDTH = 600
WIN_HEIGHT = 800
@@ -62,6 +90,80 @@ FONT_SEND_BTN = ("Segoe UI", 13)
FONT_RESIZE_GRIP = ("Segoe UI", 10)
# ---------------------------------------------------------------------------
# Templates de bulles "Léa exécute" (J3.4)
# Chaque template prend un payload et retourne (icon, icon_color, title).
# Les libellés sont volontairement neutres : le contexte métier vient du
# payload (workflow, action, message), pas de hardcoding.
# ---------------------------------------------------------------------------
def _tpl_action_started(payload: Dict[str, Any]) -> tuple:
wf = payload.get("workflow") or "?"
return ("", ACTION_ICON_RUN, f"Démarrage : {wf}")
def _tpl_action_progress(payload: Dict[str, Any]) -> tuple:
cur = payload.get("current", "?")
tot = payload.get("total", "?")
step = payload.get("step")
title = step if step else f"Étape {cur}/{tot}"
return ("", ACTION_ICON_RUN, str(title))
def _tpl_done(payload: Dict[str, Any]) -> tuple:
success = bool(payload.get("success", True))
msg = payload.get("message") or ("Terminé" if success else "Échec")
if success:
return ("", ACTION_ICON_OK, str(msg))
return ("", ACTION_ICON_ERR, str(msg))
def _tpl_need_confirm(payload: Dict[str, Any]) -> tuple:
action = payload.get("action") or {}
desc = action.get("description") if isinstance(action, dict) else None
title = desc or "Validation requise"
return ("?", ACTION_ICON_RUN, str(title))
def _tpl_step_result(payload: Dict[str, Any]) -> tuple:
status = (payload.get("status") or "").lower()
msg = payload.get("message") or status or "Étape terminée"
if status in ("ok", "success", "approved"):
return ("", ACTION_ICON_OK, str(msg))
if status in ("error", "failed"):
return ("", ACTION_ICON_ERR, str(msg))
return ("·", ACTION_ICON_INFO, str(msg))
def _tpl_resumed(payload: Dict[str, Any]) -> tuple:
return ("", ACTION_ICON_OK, "Reprise")
_ACTION_TEMPLATES = {
"lea:action_started": _tpl_action_started,
"lea:action_progress": _tpl_action_progress,
"lea:done": _tpl_done,
"lea:need_confirm": _tpl_need_confirm,
"lea:step_result": _tpl_step_result,
"lea:resumed": _tpl_resumed,
}
def _extract_meta(payload: Dict[str, Any]) -> str:
"""Métadonnées techniques en pied de bulle (workflow, étape, replay_id court)."""
parts = []
wf = payload.get("workflow")
if wf:
parts.append(str(wf))
cur, tot = payload.get("current"), payload.get("total")
if cur is not None and tot is not None:
parts.append(f"étape {cur}/{tot}")
rid = payload.get("replay_id")
if rid:
parts.append(f"#{str(rid)[-6:]}")
return "".join(parts)
class ChatWindow:
"""Fenetre de chat Lea en tkinter natif.
@@ -91,6 +193,8 @@ class ChatWindow:
self._root = None
self._ready = threading.Event()
self._messages = [] # historique local
self._bus: Optional[Any] = None # FeedbackBusClient (J3.3, peut rester None)
self._active_paused_bubble: Optional[Dict[str, Any]] = None # bulle paused active (J3.5)
# S'abonner aux changements de l'etat partage
if self._shared_state is not None:
@@ -266,6 +370,9 @@ class ChatWindow:
# Signaler que la fenetre est prete
self._ready.set()
# Demarrer le bus feedback Lea (events 'lea:*' temps reel)
self._start_feedback_bus()
# Boucle tkinter
root.mainloop()
@@ -608,6 +715,12 @@ class ChatWindow:
def _do_destroy(self) -> None:
"""Detruit la fenetre (appele dans le thread tkinter)."""
if self._bus is not None:
try:
self._bus.stop()
except Exception:
pass
self._bus = None
if self._root is not None:
try:
self._root.quit()
@@ -617,6 +730,364 @@ class ChatWindow:
self._root = None
self._visible = False
# ======================================================================
# FeedbackBus — bulles temps reel pendant l'execution (J3.3)
# ======================================================================
def _start_feedback_bus(self) -> None:
"""Demarrer la connexion au bus 'lea:*' si flag actif et lib disponible."""
if not _HAS_FEEDBACK_BUS:
logger.debug("FeedbackBus non disponible (python-socketio manquant)")
return
flag = os.environ.get("LEA_FEEDBACK_BUS", "0").lower()
if flag not in ("1", "true", "yes", "on"):
return
try:
url = f"http://{self._server_host}:{self._chat_port}"
token = os.environ.get("RPA_API_TOKEN", "") or None
self._bus = FeedbackBusClient(url, token=token, on_event=self._on_lea_event)
self._bus.start()
logger.info("FeedbackBus demarre : %s", url)
except Exception:
logger.debug("FeedbackBus init silenced", exc_info=True)
self._bus = None
def _on_lea_event(self, event: str, payload: Dict[str, Any]) -> None:
"""Callback bus → bulle Lea. Thread-safe : helpers utilisent root.after."""
payload = payload or {}
# J3.5 : la pause supervisée a sa propre bulle interactive
if event == "lea:paused":
self._add_paused_bubble(payload)
return
if event in ("lea:resumed", "lea:done"):
self._close_active_paused_bubble(reason=event)
# on continue pour afficher la bulle d'action (cf. dispatch ci-dessous)
# Acks bus (resume_acked, abort_acked) : silencieux côté UI
if event in ("lea:resume_acked", "lea:abort_acked"):
return
# J3.4 : bulle "Léa exécute" stylisée (séparée des bulles chat normales)
rendered = _ACTION_TEMPLATES.get(event)
if rendered is None:
# Event inconnu : on affiche en bulle d'action neutre
self._add_action_bubble(
icon="·", icon_color=ACTION_ICON_INFO,
title=event.removeprefix("lea:"),
meta=_extract_meta(payload),
)
return
icon, icon_color, title = rendered(payload)
self._add_action_bubble(
icon=icon, icon_color=icon_color, title=title,
meta=_extract_meta(payload),
)
# ------------------------------------------------------------------
# Bulle "Léa exécute" stylisée (J3.4)
# ------------------------------------------------------------------
def _add_action_bubble(
self, icon: str, icon_color: str, title: str, meta: str = "",
) -> None:
if self._root is None:
return
self._root.after(0, lambda: self._render_action_bubble(icon, icon_color, title, meta))
def _render_action_bubble(
self, icon: str, icon_color: str, title: str, meta: str,
) -> None:
tk = self._tk
if getattr(self, "_msg_frame", None) is None:
return
now = datetime.now().strftime("%H:%M")
container = tk.Frame(self._msg_frame, bg=BG_COLOR)
container.pack(fill=tk.X, padx=MARGIN, pady=3)
inner = tk.Frame(
container, bg=ACTION_BG, padx=10, pady=6,
highlightbackground=ACTION_BORDER, highlightthickness=1,
)
inner.pack(anchor=tk.W, padx=(0, 70), fill=tk.X)
row = tk.Frame(inner, bg=ACTION_BG)
row.pack(fill=tk.X, anchor=tk.W)
tk.Label(
row, text=icon, bg=ACTION_BG, fg=icon_color,
font=("Segoe UI", 13, "bold"), padx=4,
).pack(side=tk.LEFT)
tk.Label(
row, text=title, bg=ACTION_BG, fg=ACTION_FG,
font=FONT_MSG, anchor="w", justify=tk.LEFT,
wraplength=MSG_WRAP_WIDTH - 60,
).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(2, 0))
if meta:
tk.Label(
inner, text=f"{meta}{now}",
bg=ACTION_BG, fg=ACTION_META_FG,
font=FONT_TIMESTAMP, anchor="w",
).pack(fill=tk.X, anchor=tk.W, pady=(2, 0))
# UX fix 8 mai 2026 : auto-scroll après chaque bulle d'action
self._scroll_to_bottom()
# ------------------------------------------------------------------
# Bulle paused_need_help interactive (J3.5)
# ------------------------------------------------------------------
def _add_paused_bubble(self, payload: Dict[str, Any]) -> None:
"""Ajouter une bulle paused interactive (asset démo : Léa demande de l'aide).
IMPORTANT (8 mai 2026, démo GHT) : par défaut la fenêtre démarre cachée
(`root.withdraw()`). Il FAUT la rendre visible et la forcer au premier
plan, sinon Dom ne voit jamais la bulle. On exécute dans le thread
tkinter via `root.after(0, ...)`.
UX fix 8 mai 2026 : un seul affichage (la bulle chat). Plus de toast
en double — on force juste la chat window au premier plan.
"""
if self._root is None:
return
def _show_and_render():
try:
self._do_show()
# Re-pin topmost pour passer devant les apps actives
self._root.attributes("-topmost", True)
self._root.lift()
# Bell sonore léger pour attirer l'attention (Tkinter natif)
try:
self._root.bell()
except Exception:
pass
except Exception:
logger.debug("force-show chat_window silenced", exc_info=True)
# UX fix mai 2026 : repartir d'un chat vide pour focaliser
# l'attention sur la question (clear visuel uniquement,
# self._messages reste intact pour la traçabilité debug).
self._clear_chat_history()
self._render_paused_bubble(payload)
self._root.after(0, _show_and_render)
def _clear_chat_history(self) -> None:
"""Vide la zone d'affichage du chat (widgets enfants de _msg_frame).
Détruit uniquement les widgets visuels — self._messages (liste mémoire)
reste intact pour la traçabilité debug. Cohérent avec _do_remove_typing
qui détruit aussi le widget sans toucher à l'historique.
"""
if getattr(self, "_msg_frame", None) is None:
return
try:
for child in list(self._msg_frame.winfo_children()):
child.destroy()
self._active_paused_bubble = None
if hasattr(self, "_typing_frame"):
self._typing_frame = None
self._scroll_to_bottom()
except Exception:
logger.debug("clear chat history silenced", exc_info=True)
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
tk = self._tk
if getattr(self, "_msg_frame", None) is None:
return
replay_id = str(payload.get("replay_id", "") or "")
workflow = payload.get("workflow", "?")
reason = payload.get("reason") or "Action incertaine — j'ai besoin de votre validation."
completed = payload.get("completed", 0)
total = payload.get("total", "?")
now = datetime.now().strftime("%H:%M")
container = tk.Frame(self._msg_frame, bg=BG_COLOR)
container.pack(fill=tk.X, padx=MARGIN, pady=6)
inner = tk.Frame(
container, bg=PAUSED_BG, padx=14, pady=12,
highlightbackground=PAUSED_BORDER, highlightthickness=2,
)
inner.pack(anchor=tk.W, padx=(0, 50), fill=tk.X)
tk.Label(
inner, text=f"⏸ Pause supervisée • {now}",
bg=PAUSED_BG, fg=PAUSED_FG,
font=("Segoe UI", 12, "bold"), anchor="w",
).pack(fill=tk.X, anchor=tk.W)
# Message scrollable pour les longs reasons (ex: 200+ chars depuis le serveur).
# On utilise un Text en mode read-only avec hauteur calculée selon la longueur.
# Au-delà de 280 chars, scrollbar interne ; sinon Text auto-fitté.
reason_str = str(reason)
# Estimation simple : ~70 chars/ligne avec wraplength
approx_lines = max(2, min(8, (len(reason_str) // 60) + 1))
msg_frame = tk.Frame(inner, bg=PAUSED_BG)
msg_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
reason_text = tk.Text(
msg_frame, bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_MSG, wrap=tk.WORD, bd=0, height=approx_lines,
highlightthickness=0, relief=tk.FLAT, cursor="arrow",
)
reason_text.insert("1.0", reason_str)
reason_text.configure(state="disabled")
reason_text.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Scrollbar interne uniquement si le contenu déborde (long messages)
if len(reason_str) > 280:
reason_scroll = tk.Scrollbar(
msg_frame, orient=tk.VERTICAL,
command=reason_text.yview, width=8,
)
reason_text.configure(yscrollcommand=reason_scroll.set)
reason_scroll.pack(side=tk.RIGHT, fill=tk.Y)
tk.Label(
inner, text=f"{workflow} — étape {completed}/{total}",
bg=PAUSED_BG, fg=TIMESTAMP_FG, font=FONT_TIMESTAMP, anchor="w",
).pack(fill=tk.X, anchor=tk.W, pady=(4, 8))
btn_frame = tk.Frame(inner, bg=PAUSED_BG)
btn_frame.pack(fill=tk.X, anchor=tk.W)
btn_resume = tk.Button(
btn_frame, text="Continuer",
bg=PAUSED_BTN_RESUME_BG, fg="white", font=FONT_QUICK_BTN,
padx=14, pady=4, bd=0, cursor="hand2",
activebackground=PAUSED_BTN_RESUME_HOVER, activeforeground="white",
command=lambda: self._on_paused_resume(replay_id),
)
btn_resume.pack(side=tk.LEFT, padx=(0, 8))
btn_abort = tk.Button(
btn_frame, text="Annuler",
bg=PAUSED_BTN_ABORT_BG, fg="white", font=FONT_QUICK_BTN,
padx=14, pady=4, bd=0, cursor="hand2",
activebackground=PAUSED_BTN_ABORT_HOVER, activeforeground="white",
command=lambda: self._on_paused_abort(replay_id),
)
btn_abort.pack(side=tk.LEFT)
# Zone de feedback (mise à jour après clic, avant l'ack du bus)
feedback_label = tk.Label(
inner, text="", bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_TIMESTAMP, anchor="w",
)
feedback_label.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
self._active_paused_bubble = {
"container": container, "inner": inner,
"btn_resume": btn_resume, "btn_abort": btn_abort,
"feedback_label": feedback_label,
"replay_id": replay_id,
}
# Scroll automatique vers la nouvelle bulle (visible immédiatement)
self._scroll_to_bottom()
def _close_active_paused_bubble(self, reason: str) -> None:
if self._active_paused_bubble is None or self._root is None:
return
self._root.after(0, lambda: self._do_close_paused_bubble(reason))
def _do_close_paused_bubble(self, reason: str) -> None:
bubble = self._active_paused_bubble
if bubble is None:
return
try:
bubble["btn_resume"].config(state="disabled")
bubble["btn_abort"].config(state="disabled")
label_text = {
"lea:resumed": "→ Reprise",
"lea:done": "→ Terminé",
}.get(reason, f"{reason}")
self._tk.Label(
bubble["inner"], text=label_text,
bg=PAUSED_BG, fg=PAUSED_FG, font=FONT_TIMESTAMP, anchor="w",
).pack(fill="x", anchor="w", pady=(6, 0))
except Exception:
logger.debug("close paused bubble silenced", exc_info=True)
self._active_paused_bubble = None
def _on_paused_resume(self, replay_id: str) -> None:
"""Bouton Continuer : émettre lea:replay_resume + feedback immédiat UI.
UX fix 8 mai 2026 : on désactive les 2 boutons et on affiche un message
de feedback dès le clic, sans attendre l'ack serveur. Le bus émet en
arrière-plan ; si la connexion est tombée, on log un warning visible.
"""
if not replay_id:
self._update_paused_feedback("⚠ replay_id manquant — impossible de relancer")
return
emitted = False
if self._bus is not None and self._bus.connected:
emitted = self._bus.resume_replay(replay_id)
# Feedback immédiat : disable boutons + message
self._disable_paused_buttons()
if emitted:
self._update_paused_feedback("→ Reprise demandée…")
logger.info("paused_bubble: lea:replay_resume émis pour %s", replay_id)
else:
self._update_paused_feedback("⚠ Bus indisponible — réessayez dans 5s")
logger.warning("paused_bubble: bus déconnecté, resume non émis")
# UX fix mai 2026 : minimiser la fenêtre vers le systray après 500ms
# (laisse à l'utilisateur le temps de voir "Reprise demandée…").
try:
self._root.after(500, self._do_hide)
except Exception:
logger.debug("auto-hide on resume silenced", exc_info=True)
def _on_paused_abort(self, replay_id: str) -> None:
"""Bouton Annuler : émettre lea:replay_abort + fermeture locale immédiate.
UX fix 8 mai 2026 : on ferme la bulle localement dès le clic (le serveur
n'envoie pas de lea:resumed pour un abort, donc sans cette fermeture
locale la bulle restait coincée — c'était la cause de "Annuler ne
fonctionne pas" rapportée par Dom).
"""
emitted = False
if self._bus is not None and self._bus.connected:
emitted = self._bus.abort_replay(replay_id)
self._disable_paused_buttons()
if emitted:
self._update_paused_feedback("✗ Annulé")
logger.info("paused_bubble: lea:replay_abort émis pour %s", replay_id)
else:
self._update_paused_feedback("✗ Annulé (bus indisponible)")
logger.warning("paused_bubble: bus déconnecté, abort non émis")
# Fermer la bulle en local (l'abort n'a pas de lea:resumed associé)
self._close_active_paused_bubble(reason="abort_local")
# UX fix mai 2026 : minimiser la fenêtre après 500ms (cohérence
# avec _on_paused_resume, demandé explicitement par Dom).
try:
self._root.after(500, self._do_hide)
except Exception:
logger.debug("auto-hide on abort silenced", exc_info=True)
def _disable_paused_buttons(self) -> None:
if not self._active_paused_bubble:
return
try:
self._active_paused_bubble["btn_resume"].config(state="disabled")
self._active_paused_bubble["btn_abort"].config(state="disabled")
except Exception:
logger.debug("disable paused buttons silenced", exc_info=True)
def _update_paused_feedback(self, text: str) -> None:
if not self._active_paused_bubble:
return
label = self._active_paused_bubble.get("feedback_label")
if label is None:
return
try:
label.config(text=text)
except Exception:
logger.debug("update paused feedback silenced", exc_info=True)
# ======================================================================
# Ajout de messages dans la zone de chat
# ======================================================================

View File

@@ -293,6 +293,49 @@ def formatter_ecran_inchange(action_type: str = "") -> MessageUtilisateur:
)
def formatter_mode_apprentissage(
raison: str = "",
description_cible: str = "",
titre_fenetre: Optional[str] = None,
) -> MessageUtilisateur:
"""Message quand Léa passe en mode apprentissage (pause supervisée).
L'utilisateur doit comprendre :
1. Léa est bloquée et a besoin d'aide
2. L'utilisateur doit prendre la main et montrer comment faire
3. Ctrl+Shift+L pour signaler qu'il a fini
Le ton est humble, clair, actionnable. Pas technique.
Exemple :
Léa a besoin d'aide
Je n'y arrive pas, montrez-moi comment faire.
Quand vous avez fini, appuyez sur Ctrl+Shift+L.
"""
cible = _nettoyer_description_cible(description_cible) if description_cible else ""
app = _extraire_nom_application(titre_fenetre or "") if titre_fenetre else ""
# Construire un contexte court si disponible
contexte = ""
if cible and app:
contexte = f"{cible} » dans {app})"
elif cible:
contexte = f"{cible} »)"
corps = (
f"Je n'y arrive pas{contexte}, montrez-moi comment faire. "
f"Quand vous avez fini, appuyez sur Ctrl+Shift+L."
)
return MessageUtilisateur(
niveau=NiveauMessage.BLOCAGE,
titre="Léa a besoin d'aide",
corps=corps,
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
persistent=True,
)
def formatter_connexion_perdue(hote_serveur: str = "") -> MessageUtilisateur:
"""Message quand la connexion avec le serveur est perdue.

View File

@@ -32,6 +32,7 @@ from .messages import (
formatter_etape_workflow,
formatter_fenetre_incorrecte,
formatter_fin_workflow,
formatter_mode_apprentissage,
formatter_ralentissement,
formatter_retry,
)
@@ -138,10 +139,19 @@ class NotificationManager:
Les messages BLOCAGE bypass le rate limit pour garantir que
l'utilisateur voit qu'on a besoin de lui.
UX fix 8 mai 2026 (démo GHT) : la bulle ChatWindow Léa V1 (Tkinter
topmost + bell + force-show) est désormais l'affichage canonique pour
les BLOCAGE de pause supervisée. On NE déclenche PLUS show_paused_toast
depuis ici — Dom rapportait 3 popups en parallèle (toast executor,
toast bubble, toast notifications). Plyer reste actif comme
notification système discrète. Le toast Tkinter custom est conservé
pour les fallbacks sans ChatWindow (cf. executor.Plan B).
"""
bypass = msg.niveau == NiveauMessage.BLOCAGE
# Log aussi pour tracer dans les logs fichiers
self._log_message(msg)
return self.notify(
title=msg.titre,
message=msg.corps,
@@ -273,6 +283,20 @@ class NotificationManager:
msg = formatter_ecran_inchange(action_type)
return self.notify_message(msg)
def replay_learning_mode(
self,
raison: str = "",
target_description: str = "",
window_title: Optional[str] = None,
) -> bool:
"""Notification quand Léa passe en mode apprentissage.
Léa est bloquée et demande à l'utilisateur de montrer comment faire.
Message humble et actionnable pour un utilisateur non technique.
"""
msg = formatter_mode_apprentissage(raison, target_description, window_title)
return self.notify_message(msg)
def replay_retry(self, action_type: str = "", tentative: int = 2) -> bool:
"""Notification quand Léa retente une action."""
msg = formatter_retry(action_type, tentative)

View File

@@ -0,0 +1,290 @@
# agent_v1/ui/paused_toast.py
"""
Toast Tkinter custom pour la pause supervisée (« Léa a besoin de votre aide »).
Démo GHT 8 mai 2026 — Fallback robuste 100 % autonome quand :
- plyer.notification est silencieux sous Windows 11 (Focus Assist, balloon tips
bloqués par la stratégie système),
- la ChatWindow Léa V1 est `withdraw()`-cachée par défaut (Dom ne la voit pas),
- aucune autre UI ne peut garantir que Dom verra physiquement le message.
Stratégie :
- Toplevel topmost overrideredirect en haut à droite de l'écran principal,
- fond bleu Léa, titre + message, auto-close après TOAST_DURATION_S,
- thread-safe : peut être appelé depuis n'importe quel thread (le polling
replay tourne dans un daemon thread, pas le thread principal),
- aucune dépendance externe (juste tkinter stdlib),
- rate limit interne pour éviter le flood (1 toast / 3s minimum).
Si un Tk root existe déjà dans le process (ChatWindow), on attache le Toplevel
à ce root via `root.after(0, ...)` — c'est l'idiome thread-safe officiel de
tkinter. Sinon on crée un Tk() dédié dans un daemon thread.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Couleurs cohérentes avec le thème Léa (cf. chat_window.py)
TOAST_BG = "#2563EB" # Bleu Léa (HEADER_BG)
TOAST_FG = "#FFFFFF"
TOAST_TITLE_BG = "#1E40AF" # Bleu plus foncé pour le bandeau titre
TOAST_BORDER = "#1E3A8A"
TOAST_WIDTH = 380
TOAST_PAD_X = 18
TOAST_PAD_Y = 14
TOAST_DURATION_MS = 15000
TOAST_RATE_LIMIT_S = 3.0
_lock = threading.Lock()
_last_shown_at: float = 0.0
_last_message: str = ""
def _resolve_existing_root() -> Optional[Any]:
"""Tente de récupérer le Tk root déjà créé par la ChatWindow.
On évite tk._default_root (deprecated) et on remonte plutôt via les
threads existants : la ChatWindow garde une référence dans son instance
mais n'expose rien de global. On se rabat donc sur la création d'un Tk
indépendant si on n'a rien — c'est sûr, tkinter supporte plusieurs Tk()
concurrents tant qu'ils sont chacun dans leur propre thread.
"""
try:
import tkinter as tk
# tk._default_root est interne mais c'est le moyen le plus simple
# de partager un mainloop existant. Si ChatWindow tourne, ce sera
# son root.
root = getattr(tk, "_default_root", None)
if root is not None:
# Vérifier qu'il est encore vivant
try:
root.winfo_exists()
return root
except Exception:
return None
return None
except Exception:
return None
def _build_toast(parent: Any, title: str, message: str) -> Any:
"""Construit le Toplevel toast (appelé dans le thread tkinter)."""
import tkinter as tk
top = tk.Toplevel(parent)
top.withdraw() # éviter le flash pendant la construction
top.overrideredirect(True) # pas de barre de titre
top.attributes("-topmost", True)
try:
# Petit boost de visibilité Windows : alpha légèrement transparent
top.attributes("-alpha", 0.97)
except Exception:
pass
# Bordure visuelle (cadre extérieur foncé)
outer = tk.Frame(top, bg=TOAST_BORDER, padx=2, pady=2)
outer.pack(fill="both", expand=True)
# Bandeau titre
title_frame = tk.Frame(outer, bg=TOAST_TITLE_BG)
title_frame.pack(fill="x")
tk.Label(
title_frame,
text=f"{title}",
bg=TOAST_TITLE_BG,
fg=TOAST_FG,
font=("Segoe UI", 12, "bold"),
anchor="w",
padx=10,
pady=8,
).pack(fill="x")
# Corps du message
body_frame = tk.Frame(outer, bg=TOAST_BG)
body_frame.pack(fill="both", expand=True)
tk.Label(
body_frame,
text=message,
bg=TOAST_BG,
fg=TOAST_FG,
font=("Segoe UI", 11),
wraplength=TOAST_WIDTH - 40,
justify="left",
anchor="w",
padx=TOAST_PAD_X,
pady=TOAST_PAD_Y,
).pack(fill="both", expand=True)
# Pied de page : "Cliquez pour fermer"
footer = tk.Label(
outer,
text="Cliquez pour fermer",
bg=TOAST_BG,
fg="#BFDBFE",
font=("Segoe UI", 9, "italic"),
anchor="e",
padx=10,
pady=4,
)
footer.pack(fill="x", side="bottom")
# Position : haut-droite de l'écran principal
top.update_idletasks()
height = top.winfo_reqheight()
screen_w = top.winfo_screenwidth()
x = screen_w - TOAST_WIDTH - 16
y = 16
top.geometry(f"{TOAST_WIDTH}x{height}+{x}+{y}")
# Click anywhere to close
def _close(_=None):
try:
top.destroy()
except Exception:
pass
top.bind("<Button-1>", _close)
for child in (outer, title_frame, body_frame, footer):
try:
child.bind("<Button-1>", _close)
except Exception:
pass
# Afficher + boost focus brut pour passer devant Focus Assist
top.deiconify()
top.lift()
try:
top.focus_force()
except Exception:
pass
# Re-pin topmost après 100 ms (Windows désactive parfois -topmost
# quand le focus est pris par une autre app)
def _repin():
try:
top.attributes("-topmost", True)
top.lift()
except Exception:
pass
try:
top.after(100, _repin)
top.after(500, _repin)
top.after(2000, _repin)
except Exception:
pass
# Auto-close
try:
top.after(TOAST_DURATION_MS, _close)
except Exception:
pass
return top
def _show_in_dedicated_thread(title: str, message: str) -> None:
"""Crée un Tk() indépendant dans un daemon thread.
Utilisé en fallback quand aucun Tk root n'existe. Le thread vit le
temps du toast (~15s) puis se termine proprement.
"""
def _run():
try:
# DPI awareness (Windows haute résolution)
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
import tkinter as tk
root = tk.Tk()
root.withdraw()
try:
dpi = root.winfo_fpixels("1i")
root.tk.call("tk", "scaling", dpi / 72.0)
except Exception:
pass
top = _build_toast(root, title, message)
# Quitter mainloop quand le toast est détruit
def _watch():
try:
if not top.winfo_exists():
root.quit()
return
except Exception:
root.quit()
return
root.after(200, _watch)
root.after(200, _watch)
root.mainloop()
try:
root.destroy()
except Exception:
pass
except Exception:
logger.debug("paused_toast dedicated thread failed", exc_info=True)
t = threading.Thread(target=_run, daemon=True, name="paused-toast-tk")
t.start()
def show_paused_toast(
title: str = "Léa a besoin de votre aide",
message: str = "",
) -> bool:
"""Affiche un toast paused topmost.
Thread-safe, rate-limité, sans dépendance externe. Retourne True si le
toast a été déclenché, False s'il a été ignoré (rate limit ou erreur).
"""
global _last_shown_at, _last_message
if not message:
message = "Action en attente de votre validation."
# Rate limit basique : éviter qu'un poll en boucle ouvre 50 toasts
now = time.monotonic()
with _lock:
same_message = (message == _last_message)
elapsed = now - _last_shown_at
if same_message and elapsed < TOAST_RATE_LIMIT_S:
logger.debug(
"paused_toast rate-limited (%.1fs since last identical)", elapsed
)
return False
_last_shown_at = now
_last_message = message
# Tentative 1 : utiliser le Tk root existant (ChatWindow) via after()
root = _resolve_existing_root()
if root is not None:
try:
root.after(0, lambda: _build_toast(root, title, message))
logger.info("paused_toast scheduled on existing Tk root")
return True
except Exception:
logger.debug("paused_toast existing-root path failed", exc_info=True)
# Tentative 2 : créer un Tk() dans un daemon thread
try:
_show_in_dedicated_thread(title, message)
logger.info("paused_toast scheduled in dedicated thread")
return True
except Exception:
logger.error("paused_toast dedicated-thread path failed", exc_info=True)
return False
__all__ = ["show_paused_toast"]

View File

@@ -2,12 +2,20 @@
"""
Gestionnaire de vision avancé pour Agent V1.
Optimisé pour le streaming fibre avec détection de changement.
Captures disponibles :
- Plein écran (full) : contexte global 1920x1080+
- Crop ciblé (crop) : 80x80 autour du clic (apprentissage VLM)
- Fenêtre active (window) : image isolée de la fenêtre + métadonnées
(titre, rect, coordonnées clic relatives) — cross-platform
"""
import os
import time
import logging
import hashlib
import platform
from typing import Any, Dict, List, Optional
from PIL import Image, ImageFilter, ImageStat
import mss
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
@@ -15,6 +23,69 @@ from .blur_sensitive import blur_sensitive_regions
logger = logging.getLogger(__name__)
# OS courant (détecté une seule fois)
_SYSTEM = platform.system()
# QW1 — détection multi-écrans (fallback gracieux si screeninfo absent)
try:
from screeninfo import get_monitors as _screeninfo_get_monitors
_SCREENINFO_AVAILABLE = True
except ImportError:
_SCREENINFO_AVAILABLE = False
def _get_monitors_geometry() -> List[Dict[str, Any]]:
"""Retourne la liste des monitors physiques avec leurs offsets.
Returns:
List[dict] : [{idx, x, y, w, h, primary}, ...]. Vide si screeninfo
indisponible (le serveur tombera sur fallback composite).
"""
if not _SCREENINFO_AVAILABLE:
return []
try:
monitors = _screeninfo_get_monitors()
return [
{
"idx": i,
"x": int(m.x),
"y": int(m.y),
"w": int(m.width),
"h": int(m.height),
"primary": bool(getattr(m, "is_primary", False)),
}
for i, m in enumerate(monitors)
]
except Exception:
return []
def _get_active_monitor_index() -> Optional[int]:
"""Retourne l'index logique du monitor où se trouve le curseur (focus actif).
Returns:
int ou None si indéterminable.
"""
if not _SCREENINFO_AVAILABLE:
return None
try:
import pyautogui # import paresseux : évite la dépendance dure
cx, cy = pyautogui.position()
for i, m in enumerate(_screeninfo_get_monitors()):
if m.x <= cx < m.x + m.width and m.y <= cy < m.y + m.height:
return i
except Exception:
return None
return None
def _enrich_with_monitor_info(payload: dict) -> dict:
"""Ajoute monitor_index et monitors_geometry au payload (in-place + return)."""
if isinstance(payload, dict):
payload["monitor_index"] = _get_active_monitor_index()
payload["monitors_geometry"] = _get_monitors_geometry()
return payload
class VisionCapturer:
def __init__(self, session_dir: str):
self.session_dir = session_dir
@@ -27,13 +98,16 @@ class VisionCapturer:
"""
Capture l'écran complet.
Si force=False, vérifie d'abord si l'écran a changé.
Enrichit les métadonnées avec le titre de la fenêtre active
(utile pour le contextualisation des heartbeats côté serveur).
"""
try:
with mss.mss() as sct:
monitor = sct.monitors[1]
sct_img = sct.grab(monitor)
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
# Détection de changement (pour Heartbeat)
if not force:
current_hash = self._compute_quick_hash(img)
@@ -52,8 +126,24 @@ class VisionCapturer:
logger.error(f"Erreur Context Capture: {e}")
return ""
def get_active_window_title(self) -> str:
"""Retourne le titre de la fenêtre active (pour enrichir les heartbeats).
Fallback gracieux : retourne une chaîne vide si indisponible.
"""
try:
from ..window_info_crossplatform import get_active_window_info
info = get_active_window_info()
return info.get("title", "")
except Exception:
return ""
def capture_dual(self, x: int, y: int, screenshot_id: str, anonymize=False) -> dict:
"""Capture duale (Full + Crop) systématique (forcée car liée à une action)."""
"""Capture triple (Full + Crop + Fenêtre active) systématique.
La fenêtre active est un AJOUT — en cas d'échec, le full + crop
sont toujours retournés (fallback gracieux).
"""
try:
with mss.mss() as sct:
full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png")
@@ -67,7 +157,7 @@ class VisionCapturer:
left = max(0, x - w // 2)
top = max(0, y - h // 2)
crop_img = img.crop((left, top, left + w, top + h))
if anonymize:
crop_img = crop_img.filter(ImageFilter.GaussianBlur(radius=4))
@@ -82,11 +172,136 @@ class VisionCapturer:
# Mise à jour du hash pour le prochain heartbeat
self.last_img_hash = self._compute_quick_hash(img)
return {"full": full_path, "crop": crop_path}
result = {"full": full_path, "crop": crop_path}
# --- Capture de la fenêtre active ---
# Ajout non-bloquant : enrichit le résultat avec l'image
# de la fenêtre seule + métadonnées (titre, rect, clic relatif)
window_info = self.capture_active_window(x, y, screenshot_id, full_img=img)
if window_info:
result["window_capture"] = window_info
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
_enrich_with_monitor_info(result)
return result
except Exception as e:
logger.error(f"Erreur Dual Capture: {e}")
return {}
def capture_active_window(
self,
x: int,
y: int,
screenshot_id: str,
full_img: Optional[Image.Image] = None,
) -> Optional[Dict[str, Any]]:
"""Capture l'image de la fenêtre active seule + métadonnées.
Stratégie :
1. Obtenir le rectangle de la fenêtre via l'API OS (pywin32 / xdotool / Quartz)
2. Cropper depuis le screenshot plein écran (plus fiable que PrintWindow)
3. Calculer les coordonnées du clic relatives à la fenêtre
Args:
x, y: coordonnées du clic en pixels écran
screenshot_id: identifiant pour le nom de fichier
full_img: screenshot plein écran déjà capturé (optionnel, évite une
double capture si appelé depuis capture_dual)
Returns:
Dict avec window_image, window_title, window_rect, click_in_window,
window_size — ou None si la fenêtre est introuvable.
"""
try:
from ..window_info_crossplatform import get_active_window_rect
rect_info = get_active_window_rect()
if not rect_info:
logger.debug("Fenêtre active introuvable — skip capture fenêtre")
return None
win_rect = rect_info["rect"] # [left, top, right, bottom]
win_left, win_top, win_right, win_bottom = win_rect
win_w, win_h = rect_info["size"] # [width, height]
title = rect_info.get("title", "unknown_window")
app_name = rect_info.get("app_name", "unknown_app")
# Ignorer les fenêtres trop petites (barres de tâches, popups système)
if win_w < 50 or win_h < 50:
logger.debug(f"Fenêtre trop petite ({win_w}x{win_h}) — skip")
return None
# Coordonnées du clic relatives à la fenêtre
click_rel_x = x - win_left
click_rel_y = y - win_top
# Si le clic est en dehors de la fenêtre, on le signale mais on continue
click_inside = (0 <= click_rel_x <= win_w and 0 <= click_rel_y <= win_h)
# --- Crop de la fenêtre depuis le plein écran ---
if full_img is None:
# Pas de screenshot fourni — en capturer un (cas standalone)
try:
with mss.mss() as sct:
monitor = sct.monitors[1]
sct_img = sct.grab(monitor)
full_img = Image.frombytes(
"RGB", sct_img.size, sct_img.bgra, "raw", "BGRX"
)
except Exception as e:
logger.error(f"Erreur capture plein écran pour fenêtre : {e}")
return None
# Borner le crop aux limites de l'image plein écran
img_w, img_h = full_img.size
crop_left = max(0, win_left)
crop_top = max(0, win_top)
crop_right = min(img_w, win_right)
crop_bottom = min(img_h, win_bottom)
if crop_right <= crop_left or crop_bottom <= crop_top:
logger.debug("Fenêtre hors écran — skip capture fenêtre")
return None
window_img = full_img.crop((crop_left, crop_top, crop_right, crop_bottom))
# Floutage conformité AI Act
if BLUR_SENSITIVE:
blur_sensitive_regions(window_img)
# Sauvegarde
window_path = os.path.join(
self.shots_dir, f"{screenshot_id}_window.png"
)
window_img.save(window_path, "PNG", quality=SCREENSHOT_QUALITY)
result = {
"window_image": window_path,
"window_title": title,
"app_name": app_name,
"window_rect": win_rect,
"window_size": [win_w, win_h],
"click_in_window": [click_rel_x, click_rel_y],
"click_inside_window": click_inside,
}
# QW1 — enrichissement multi-écrans (additif)
_enrich_with_monitor_info(result)
logger.debug(
f"Fenêtre capturée : {title} ({win_w}x{win_h}) — "
f"clic relatif ({click_rel_x}, {click_rel_y})"
)
return result
except ImportError as e:
logger.debug(f"Module fenêtre indisponible : {e}")
return None
except Exception as e:
logger.error(f"Erreur capture fenêtre active : {e}")
return None
def _compute_quick_hash(self, img: Image) -> str:
"""Calcule un hash rapide basé sur une vignette réduite pour détecter les changements."""
# On réduit l'image à 64x64 pour comparer les masses de couleurs (très rapide)

View File

@@ -17,7 +17,7 @@ from __future__ import annotations
import platform
import subprocess
from typing import Dict, Optional
from typing import Any, Dict, Optional
def _run_cmd(cmd: list[str]) -> Optional[str]:
@@ -36,11 +36,11 @@ def get_active_window_info() -> Dict[str, str]:
"title": "...",
"app_name": "..."
}
Détecte automatiquement l'OS et utilise la méthode appropriée.
"""
system = platform.system()
if system == "Linux":
return _get_window_info_linux()
elif system == "Windows":
@@ -51,6 +51,32 @@ def get_active_window_info() -> Dict[str, str]:
return {"title": "unknown_window", "app_name": "unknown_app"}
def get_active_window_rect() -> Optional[Dict[str, Any]]:
"""
Renvoie le rectangle de la fenêtre active :
{
"title": "...",
"app_name": "...",
"rect": [left, top, right, bottom],
"position": [left, top],
"size": [width, height],
"hwnd": int # Windows uniquement
}
Retourne None si la fenêtre est introuvable ou minimisée.
Détecte automatiquement l'OS et utilise la méthode appropriée.
"""
system = platform.system()
if system == "Windows":
return _get_window_rect_windows()
elif system == "Linux":
return _get_window_rect_linux()
elif system == "Darwin":
return _get_window_rect_macos()
return None
def _get_window_info_linux() -> Dict[str, str]:
"""
Linux: utilise xdotool (X11)
@@ -178,6 +204,163 @@ def _get_window_info_macos() -> Dict[str, str]:
}
def _get_window_rect_windows() -> Optional[Dict[str, Any]]:
"""
Windows : utilise pywin32 pour obtenir le rectangle de la fenêtre active.
Retourne None si la fenêtre est minimisée (icônifiée) ou si pywin32 manque.
"""
try:
import win32gui
import win32process
import psutil
hwnd = win32gui.GetForegroundWindow()
if not hwnd:
return None
# Ignorer les fenêtres minimisées (pas de contenu visible)
if win32gui.IsIconic(hwnd):
return None
title = win32gui.GetWindowText(hwnd) or "unknown_window"
# Rectangle de la fenêtre (coordonnées écran absolues)
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
width = right - left
height = bottom - top
# Ignorer les fenêtres de taille nulle ou absurde
if width <= 0 or height <= 0:
return None
# Nom du processus
_, pid = win32process.GetWindowThreadProcessId(hwnd)
try:
app_name = psutil.Process(pid).name()
except Exception:
app_name = "unknown_app"
return {
"title": title,
"app_name": app_name,
"rect": [left, top, right, bottom],
"position": [left, top],
"size": [width, height],
"hwnd": hwnd,
}
except ImportError:
return None
except Exception:
return None
def _get_window_rect_linux() -> Optional[Dict[str, Any]]:
"""
Linux (X11) : utilise xdotool + xwininfo pour obtenir le rectangle.
Nécessite : sudo apt-get install xdotool x11-utils
"""
try:
# Identifiant de la fenêtre active
wid = _run_cmd(["xdotool", "getactivewindow"])
if not wid:
return None
title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) or "unknown_window"
pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"])
app_name = "unknown_app"
if pid_str:
app_name = _run_cmd(["ps", "-p", pid_str.strip(), "-o", "comm="]) or "unknown_app"
# Géométrie via xdotool --shell (position + taille)
geom_raw = _run_cmd(["xdotool", "getwindowgeometry", "--shell", wid])
if not geom_raw:
return None
vals: Dict[str, int] = {}
for line in geom_raw.strip().splitlines():
if "=" in line:
k, v = line.split("=", 1)
try:
vals[k.strip()] = int(v.strip())
except ValueError:
pass
if not {"X", "Y", "WIDTH", "HEIGHT"} <= vals.keys():
return None
x, y = vals["X"], vals["Y"]
w, h = vals["WIDTH"], vals["HEIGHT"]
return {
"title": title,
"app_name": app_name,
"rect": [x, y, x + w, y + h],
"position": [x, y],
"size": [w, h],
}
except Exception:
return None
def _get_window_rect_macos() -> Optional[Dict[str, Any]]:
"""
macOS : utilise Quartz (CGWindowListCopyWindowInfo) pour obtenir le rectangle.
Nécessite : pip install pyobjc-framework-Quartz
"""
try:
from AppKit import NSWorkspace
from Quartz import (
CGWindowListCopyWindowInfo,
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
)
active_app = NSWorkspace.sharedWorkspace().activeApplication()
app_name = active_app.get("NSApplicationName", "unknown_app")
window_list = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
)
for window in window_list:
owner_name = window.get("kCGWindowOwnerName", "")
if owner_name != app_name:
continue
bounds = window.get("kCGWindowBounds")
if not bounds:
continue
x = int(bounds.get("X", 0))
y = int(bounds.get("Y", 0))
w = int(bounds.get("Width", 0))
h = int(bounds.get("Height", 0))
if w <= 0 or h <= 0:
continue
title = window.get("kCGWindowName", "unknown_window") or "unknown_window"
return {
"title": title,
"app_name": app_name,
"rect": [x, y, x + w, y + h],
"position": [x, y],
"size": [w, h],
}
except ImportError:
return None
except Exception:
return None
return None
# Test rapide
if __name__ == "__main__":
import time
@@ -185,8 +368,13 @@ if __name__ == "__main__":
print(f"OS détecté: {platform.system()}")
print("\nTest de capture fenêtre active (5 secondes)...")
print("Changez de fenêtre pour tester!\n")
for i in range(5):
info = get_active_window_info()
rect = get_active_window_rect()
print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}")
if rect:
print(f" Rect: {rect['rect']} | Size: {rect['size']}")
else:
print(" Rect: non disponible")
time.sleep(1)

View File

@@ -512,6 +512,21 @@ class ActionExecutorV1:
x_pct = action.get("x_pct", 0.0)
y_pct = action.get("y_pct", 0.0)
# QW1 — Si le serveur a résolu un monitor cible (idx >= 0),
# appliquer son offset aux coords absolues. Pour idx == -1
# (composite_fallback), aucun offset (backward compat).
# Le calcul des coords reste percent * (width/height) du monitor[1]
# côté client (x_pct est exprimé sur l'écran physique principal).
mon_res = action.get("monitor_resolution") or {}
mon_idx = mon_res.get("idx", -1)
mon_offset_x = mon_res.get("offset_x", 0) if mon_idx >= 0 else 0
mon_offset_y = mon_res.get("offset_y", 0) if mon_idx >= 0 else 0
if mon_idx >= 0 and (mon_offset_x or mon_offset_y):
logger.info(
f"[REPLAY] QW1 monitor cible idx={mon_idx} source={mon_res.get('source')} "
f"offset=({mon_offset_x},{mon_offset_y}) — appliqué aux coords"
)
# ── Diagnostic résolution ──
logger.info(
f"[REPLAY] Action {action_id} ({action_type}) — "
@@ -578,8 +593,8 @@ class ActionExecutorV1:
print(f" [OBSERVER] Popup détectée : '{popup_label}' — fermeture")
logger.info(f"Observer : popup '{popup_label}' détectée avant résolution")
if popup_coords:
real_x = int(popup_coords["x_pct"] * width)
real_y = int(popup_coords["y_pct"] * height)
real_x = int(popup_coords["x_pct"] * width) + mon_offset_x
real_y = int(popup_coords["y_pct"] * height) + mon_offset_y
self._click((real_x, real_y), "left")
time.sleep(1.0)
print(f" [OBSERVER] Popup fermée — reprise du flow normal")
@@ -718,8 +733,8 @@ class ActionExecutorV1:
self.notifier.replay_target_not_found(target_desc)
return result
real_x = int(x_pct * width)
real_y = int(y_pct * height)
real_x = int(x_pct * width) + mon_offset_x
real_y = int(y_pct * height) + mon_offset_y
button = action.get("button", "left")
mode = "VISUAL" if result.get("visual_resolved") else "COORD"
print(
@@ -781,8 +796,8 @@ class ActionExecutorV1:
print(f" [TYPE] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
# Cliquer sur le champ avant de taper (si coordonnees disponibles)
if x_pct > 0 and y_pct > 0:
real_x = int(x_pct * width)
real_y = int(y_pct * height)
real_x = int(x_pct * width) + mon_offset_x
real_y = int(y_pct * height) + mon_offset_y
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
self._click((real_x, real_y), "left")
time.sleep(0.3)
@@ -808,8 +823,8 @@ class ActionExecutorV1:
logger.info(f"Replay key_combo : {keys} (raw_keys={'oui' if raw_keys else 'non'})")
elif action_type == "scroll":
real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width)
real_y = int(y_pct * height) if y_pct > 0 else int(0.5 * height)
real_x = (int(x_pct * width) if x_pct > 0 else int(0.5 * width)) + mon_offset_x
real_y = (int(y_pct * height) if y_pct > 0 else int(0.5 * height)) + mon_offset_y
delta = action.get("delta", -3)
print(f" [SCROLL] delta={delta} a ({real_x}, {real_y})")
self.mouse.position = (real_x, real_y)
@@ -1386,6 +1401,16 @@ Example: x_pct=0.50, y_pct=0.30"""
data = resp.json()
action = data.get("action")
if action is None:
# pause_for_human : afficher le message de décision à l'utilisateur
if data.get("replay_paused") and data.get("pause_message"):
msg = data["pause_message"]
print(f"[PAUSE] {msg}")
logger.info(f"Replay en pause — message : {msg}")
self.notifier.notify(
title="Léa — Validation requise",
message=msg[:250],
timeout=30,
)
return False
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:

View File

@@ -319,7 +319,22 @@ class AgentV1:
if img_hash != self._last_heartbeat_hash:
self._last_heartbeat_hash = img_hash
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id})
heartbeat_event = {
"type": "heartbeat",
"image": full_path,
"timestamp": time.time(),
"machine_id": self.machine_id,
}
# QW1 — enrichissement multi-écrans (monitor_index + monitors_geometry)
# Additif, fallback gracieux : sans cet enrichissement, le serveur
# ne reçoit l'info qu'au moment des clics, donc QW1 ne s'active
# pas en continu sur poste Windows multi-écrans.
try:
from .vision.capturer import _enrich_with_monitor_info
_enrich_with_monitor_info(heartbeat_event)
except Exception as e:
logger.debug("QW1 enrichissement heartbeat échoué: %s", e)
self.streamer.push_event(heartbeat_event)
except Exception as e:
logger.error(f"Heartbeat error: {e}")
time.sleep(5)

View File

@@ -8,12 +8,73 @@ import os
import time
import logging
import hashlib
from typing import Any, Dict, List, Optional
from PIL import Image, ImageFilter, ImageStat
import mss
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY
logger = logging.getLogger(__name__)
# QW1 — détection multi-écrans (fallback gracieux si screeninfo absent)
try:
from screeninfo import get_monitors as _screeninfo_get_monitors
_SCREENINFO_AVAILABLE = True
except ImportError:
_SCREENINFO_AVAILABLE = False
def _get_monitors_geometry() -> List[Dict[str, Any]]:
"""Retourne la liste des monitors physiques avec leurs offsets.
Returns:
List[dict] : [{idx, x, y, w, h, primary}, ...]. Vide si screeninfo
indisponible (le serveur tombera sur fallback composite).
"""
if not _SCREENINFO_AVAILABLE:
return []
try:
monitors = _screeninfo_get_monitors()
return [
{
"idx": i,
"x": int(m.x),
"y": int(m.y),
"w": int(m.width),
"h": int(m.height),
"primary": bool(getattr(m, "is_primary", False)),
}
for i, m in enumerate(monitors)
]
except Exception:
return []
def _get_active_monitor_index() -> Optional[int]:
"""Retourne l'index logique du monitor où se trouve le curseur (focus actif).
Returns:
int ou None si indéterminable.
"""
if not _SCREENINFO_AVAILABLE:
return None
try:
import pyautogui # import paresseux : évite la dépendance dure
cx, cy = pyautogui.position()
for i, m in enumerate(_screeninfo_get_monitors()):
if m.x <= cx < m.x + m.width and m.y <= cy < m.y + m.height:
return i
except Exception:
return None
return None
def _enrich_with_monitor_info(payload: dict) -> dict:
"""Ajoute monitor_index et monitors_geometry au payload (in-place + return)."""
if isinstance(payload, dict):
payload["monitor_index"] = _get_active_monitor_index()
payload["monitors_geometry"] = _get_monitors_geometry()
return payload
class VisionCapturer:
def __init__(self, session_dir: str):
self.session_dir = session_dir
@@ -72,7 +133,12 @@ class VisionCapturer:
# Mise à jour du hash pour le prochain heartbeat
self.last_img_hash = self._compute_quick_hash(img)
return {"full": full_path, "crop": crop_path}
result = {"full": full_path, "crop": crop_path}
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
_enrich_with_monitor_info(result)
return result
except Exception as e:
logger.error(f"Erreur Dual Capture: {e}")
return {}

View File

@@ -3,7 +3,9 @@ mss>=9.0.1 # Capture d'écran haute performance
pynput>=1.7.7 # Clavier/Souris Cross-plateforme
Pillow>=10.0.0 # Crops et processing image
requests>=2.31.0 # Streaming réseau
python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur)
psutil>=5.9.0 # Monitoring CPU/RAM
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets
pystray>=0.19.5 # Icône Tray UI
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)

View File

@@ -21,36 +21,33 @@ from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger("lea_ui.server_client")
def _get_server_host() -> str:
"""Recuperer l'adresse du serveur Linux.
def _get_server_url() -> str:
"""Recuperer l'URL du serveur RPA (avec /api/v1).
Ordre de resolution :
1. Variable d'environnement RPA_SERVER_HOST
2. Fichier de config agent_config.json (cle "server_host")
3. Fallback localhost
1. Import depuis agent_v1.config (source de verite unique)
2. Variable d'environnement RPA_SERVER_URL
3. Fallback http://localhost:5005/api/v1
"""
# 1. Variable d'environnement
host = os.environ.get("RPA_SERVER_HOST", "").strip()
if host:
return host
# 1. Import depuis config.py (source de verite)
try:
from agent_v1.config import SERVER_URL
return SERVER_URL
except ImportError:
pass
# 2. Fichier de config
config_paths = [
os.path.join(os.path.dirname(__file__), "..", "agent_config.json"),
os.path.join(os.path.dirname(__file__), "..", "..", "agent_config.json"),
]
for config_path in config_paths:
try:
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
host = cfg.get("server_host", "").strip()
if host:
return host
except (OSError, json.JSONDecodeError):
continue
# 2. Variable d'environnement directe
url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
if url:
return url
# 3. Fallback
return "localhost"
return "http://localhost:5005/api/v1"
def _get_server_base(server_url: str) -> str:
"""Extraire la base URL (sans /api/v1) pour les routes racine (/health)."""
return server_url.rsplit("/api/v1", 1)[0]
class LeaServerClient:
@@ -67,19 +64,22 @@ class LeaServerClient:
chat_port: int = 5004,
stream_port: int = 5005,
) -> None:
self._host = server_host or _get_server_host()
# URL unifiée : SERVER_URL contient TOUJOURS /api/v1 (convention INC-1).
# _stream_url = URL avec /api/v1 (pour les routes API)
# _stream_base = URL sans /api/v1 (pour /health uniquement)
self._stream_url = _get_server_url()
self._stream_base = _get_server_base(self._stream_url)
# Extraire le host depuis l'URL pour le chat et pour l'affichage
try:
from urllib.parse import urlparse
parsed = urlparse(self._stream_base)
self._host = parsed.hostname or "localhost"
except Exception:
self._host = server_host or "localhost"
self._chat_port = chat_port
self._stream_port = stream_port
# En prod, la base URL passe par le reverse proxy HTTPS
# (ex. https://lea.labs.laurinebazin.design). Si RPA_SERVER_URL est
# definie on l'utilise telle quelle, sinon on reconstruit http://host:port.
server_url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
if server_url:
self._stream_base = server_url
else:
self._stream_base = f"http://{self._host}:{self._stream_port}"
self._chat_base = f"http://{self._host}:{self._chat_port}"
# Etat de connexion
@@ -103,8 +103,8 @@ class LeaServerClient:
self._api_token = os.environ.get("RPA_API_TOKEN", "")
logger.info(
"LeaServerClient initialise : chat=%s, stream=%s",
self._chat_base, self._stream_base,
"LeaServerClient initialise : chat=%s, stream_url=%s, stream_base=%s",
self._chat_base, self._stream_url, self._stream_base,
)
# ---------------------------------------------------------------------------
@@ -154,7 +154,11 @@ class LeaServerClient:
# ---------------------------------------------------------------------------
def check_connection(self) -> bool:
"""Tester la connexion au serveur streaming (port 5005)."""
"""Tester la connexion au serveur streaming (port 5005).
Le health check utilise _stream_base (sans /api/v1) car la route
/health est a la racine du serveur FastAPI, pas sous /api/v1.
"""
try:
import requests
resp = requests.get(
@@ -227,7 +231,7 @@ class LeaServerClient:
import requests
headers = self._auth_headers()
resp = requests.get(
f"{self._stream_base}/api/v1/traces/stream/workflows",
f"{self._stream_url}/traces/stream/workflows",
headers=headers,
timeout=10,
)
@@ -284,7 +288,7 @@ class LeaServerClient:
while self._polling:
try:
resp = req_lib.get(
f"{self._stream_base}/api/v1/traces/stream/replay/next",
f"{self._stream_url}/traces/stream/replay/next",
params={"session_id": self._poll_session_id},
headers=self._auth_headers(),
timeout=5,
@@ -318,7 +322,7 @@ class LeaServerClient:
try:
import requests
resp = requests.get(
f"{self._stream_base}/api/v1/traces/stream/replays",
f"{self._stream_url}/traces/stream/replays",
headers=self._auth_headers(),
timeout=5,
)
@@ -346,7 +350,7 @@ class LeaServerClient:
try:
import requests
requests.post(
f"{self._stream_base}/api/v1/traces/stream/replay/result",
f"{self._stream_url}/traces/stream/replay/result",
json={
"session_id": session_id,
"action_id": action_id,

View File

@@ -9,6 +9,7 @@ Inclut les endpoints de replay pour renvoyer des ordres d'exécution à l'Agent
"""
import atexit
import contextlib
import json
import logging
import os
@@ -33,6 +34,8 @@ from .audit_trail import AuditTrail, AuditEntry
from .agent_registry import AgentRegistry, AgentAlreadyEnrolledError
from .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot
from .worker_stream import StreamWorker
from .monitor_router import resolve_target_monitor # QW1 — résolution écran cible
from .loop_detector import LoopDetector # QW2 — détection de boucle pendant replay
from .execution_plan_runner import (
execution_plan_to_actions,
inject_plan_into_queue,
@@ -219,6 +222,14 @@ from .replay_engine import (
_is_learned_workflow,
_edge_to_normalized_actions,
_substitute_variables,
_resolve_runtime_vars,
_SERVER_SIDE_ACTION_TYPES,
_handle_extract_text_action,
_handle_extract_table_action,
_handle_t2a_decision_action,
_handle_llm_generate_action,
_handle_concat_text_vars_action,
_handle_paste_and_execute_action,
_expand_compound_steps,
_pre_check_screen_state as _pre_check_screen_state_impl,
_detect_popup_hint as _detect_popup_hint_impl,
@@ -292,6 +303,20 @@ app.add_middleware(
)
@app.middleware("http")
async def url_compat_rewrite(request: Request, call_next):
"""Rétrocompatibilité : réécriture des anciennes URLs sans préfixe /api/v1.
Certains agents clients (Léa V1 gelée) envoient sur /traces/stream/...
au lieu de /api/v1/traces/stream/... Ce middleware redirige silencieusement.
"""
path = request.url.path
if path.startswith("/traces/stream/") and not path.startswith("/api/v1/"):
new_path = "/api/v1" + path
request.scope["path"] = new_path
return await call_next(request)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
"""Ajouter les headers de sécurité sur toutes les réponses."""
@@ -341,6 +366,18 @@ REPLAY_LOCK_FILE = _DATA_DIR / "_replay_active.lock"
processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR))
worker = StreamWorker(live_dir=str(LIVE_SESSIONS_DIR), processor=processor)
# QW2 — LoopDetector singleton lazy (utilise le CLIP embedder du processor)
_loop_detector: Optional["LoopDetector"] = None
def _get_loop_detector() -> "LoopDetector":
"""Singleton lazy — crée le LoopDetector avec le CLIP embedder du processor."""
global _loop_detector
if _loop_detector is None:
embedder = getattr(processor, "_clip_embedder", None)
_loop_detector = LoopDetector(clip_embedder=embedder)
return _loop_detector
# Registre des postes Lea enroles (table enrolled_agents dans rpa_data.db)
# Emplacement configurable via RPA_AGENTS_DB_PATH pour les tests.
_AGENTS_DB_PATH = os.environ.get(
@@ -472,6 +509,33 @@ _pending_lock = threading.Lock()
# Chaque session a une queue d'actions à exécuter et un état de replay
# =========================================================================
_replay_lock = threading.Lock()
# Context manager async pour acquérir _replay_lock sans bloquer l'event loop
# FastAPI. Pattern complémentaire au commit 35b27ae49 (lock async sur
# /replay/next) et 87dbe8c5f (get_replay_status non-bloquant) : tous les
# endpoints `async def` qui faisaient `with _replay_lock:` synchrone gelaient
# l'event loop dès qu'une opération longue tenait le lock dans un autre
# thread. Avec ce helper, l'acquire passe par run_in_executor (l'event loop
# reste libre pour servir les autres requêtes pendant l'attente). Si le lock
# est tenu plus de `timeout` secondes, on retourne 503 plutôt que de geler le
# serveur.
@contextlib.asynccontextmanager
async def _async_replay_lock(timeout: float = 4.5):
import asyncio
loop = asyncio.get_event_loop()
acquired = await loop.run_in_executor(None, _replay_lock.acquire, True, timeout)
if not acquired:
raise HTTPException(
status_code=503,
detail=f"Serveur occupé (lock _replay tenu > {timeout}s) — réessayer",
)
try:
yield
finally:
_replay_lock.release()
# session_id -> liste d'actions en attente (FIFO)
_replay_queues: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
# machine_id -> session_id (mapping pour le replay ciblé par machine)
@@ -493,6 +557,7 @@ class ReplayRequest(BaseModel):
session_id: str
machine_id: Optional[str] = None # Machine cible pour le replay (multi-machine)
params: Optional[Dict[str, Any]] = None
variables: Optional[Dict[str, Any]] = None # Variables runtime initiales (templating {{var}})
class RawReplayRequest(BaseModel):
@@ -501,6 +566,11 @@ class RawReplayRequest(BaseModel):
session_id: str = ""
machine_id: Optional[str] = None # Machine cible (multi-machine)
task_description: str = ""
# Paramètres runtime du replay (lus dans replay_state.params côté pipeline).
# Notamment execution_mode : "autonomous" (défaut, pause_for_human skippée)
# ou "supervised" (pause_for_human bloque jusqu'à validation humaine via
# PauseDialog VWB). Cf. replay_engine.py / api_stream.py:2964.
params: Optional[Dict[str, Any]] = None
class SingleActionRequest(BaseModel):
@@ -747,6 +817,21 @@ async def startup():
_cleanup_thread = threading.Thread(target=_cleanup_loop, daemon=True, name="replay_cleanup")
_cleanup_thread.start()
# Préchargement EasyOCR en arrière-plan : sans ça, le 1er extract_text /
# extract_table déclenche un cold start de ~3-5s qui bloque l'event loop
# FastAPI (constaté 2026-05-05 : streaming server inaccessible 2 min).
# Le thread tourne pendant que le boot continue ; le 1er appel OCR sera rapide.
def _preload_easyocr():
try:
t0 = time.time()
from core.llm.ocr_extractor import _get_reader
_get_reader()
logger.info("[OCR] EasyOCR préchargé (fr+en, CPU) en %.1fs", time.time() - t0)
except Exception as e:
logger.warning("[OCR] Échec préchargement EasyOCR : %s", e)
threading.Thread(target=_preload_easyocr, daemon=True, name="preload_easyocr").start()
logger.info(
"API Streaming démarrée — StreamProcessor, Worker et Cleanup prêts. "
"VLM Worker dans un process séparé (run_worker.py)."
@@ -1933,7 +2018,7 @@ async def start_replay(request: ReplayRequest):
resolved_machine_id = target_machine_id or (session_obj.machine_id if session_obj else "default")
# Injecter les actions dans la queue de la session
with _replay_lock:
async with _async_replay_lock():
_replay_queues[session_id] = list(actions) # Remplacer la queue existante
_replay_states[replay_id] = _create_replay_state(
replay_id=replay_id,
@@ -1944,6 +2029,11 @@ async def start_replay(request: ReplayRequest):
machine_id=resolved_machine_id,
actions=actions,
)
# Pré-injection des variables runtime (templating {{var}} sur by_text,
# text, target_spec.* etc.). Permet à l'orchestrateur d'appeler ce
# workflow avec p.ex. variables={"patient_id": "25003284"} pour boucler.
if request.variables:
_replay_states[replay_id]["variables"].update(request.variables)
# Enregistrer le mapping machine -> session pour le replay ciblé
if resolved_machine_id and resolved_machine_id != "default":
_machine_replay_target[resolved_machine_id] = session_id
@@ -2028,7 +2118,7 @@ async def start_raw_replay(request: RawReplayRequest):
session_obj = processor.session_manager.get_session(session_id)
resolved_machine_id = target_machine_id or (session_obj.machine_id if session_obj else "default")
with _replay_lock:
async with _async_replay_lock():
# ── Nettoyage : annuler les replays bloqués pour cette machine ──
# Un replay en paused_need_help bloque tous les suivants.
# Quand on lance un nouveau replay, les anciens sont obsolètes.
@@ -2055,7 +2145,7 @@ async def start_raw_replay(request: RawReplayRequest):
workflow_id=f"free_task:{task[:50]}",
session_id=session_id,
total_actions=len(actions),
params={},
params=dict(request.params or {}),
machine_id=resolved_machine_id,
actions=actions,
)
@@ -2248,7 +2338,7 @@ async def replay_from_session(
# ── 5. Injecter dans la queue de replay ──
replay_id = f"replay_sess_{uuid.uuid4().hex[:8]}"
with _replay_lock:
async with _async_replay_lock():
_replay_queues[target_session_id] = list(actions)
_replay_states[replay_id] = _create_replay_state(
replay_id=replay_id,
@@ -2339,7 +2429,7 @@ async def enqueue_single_action(request: SingleActionRequest):
action_id = action["action_id"]
with _replay_lock:
async with _async_replay_lock():
_replay_queues[session_id].append(action)
logger.info(
@@ -2505,7 +2595,7 @@ async def launch_replay_from_plan(request: PlanReplayRequest):
or (session_obj.machine_id if session_obj else "default")
)
with _replay_lock:
async with _async_replay_lock():
_replay_queues[target_session_id] = list(validated)
_replay_states[replay_id] = _create_replay_state(
replay_id=replay_id,
@@ -2744,8 +2834,29 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
Si la session de l'agent n'a pas d'actions en attente, cherche dans les
autres queues de la MÊME machine (pas cross-machine).
Acquire timeout : si une action serveur lente (extract_text OCR,
t2a_decision LLM) tient le lock, on retourne immédiatement
{action: None, server_busy: True} avant que le client ne timeout à 5s.
Sans cela, des actions seraient popped serveur puis envoyées sur des
sockets clients déjà fermées par timeout — perdues silencieusement.
L'acquire et les actions serveur lentes sont exécutés via
run_in_executor : sinon l'appel synchrone bloque l'event loop FastAPI
(single-threaded) et même les polls qui devraient recevoir server_busy
sont bloqués jusqu'à libération — ce qui annule l'effet du timeout.
"""
with _replay_lock:
import asyncio
loop = asyncio.get_event_loop()
acquired = await loop.run_in_executor(None, _replay_lock.acquire, True, 4.5)
if not acquired:
return {
"action": None,
"session_id": session_id,
"machine_id": machine_id,
"server_busy": True,
}
try:
# Verifier si le replay est en pause supervisee (target_not_found).
# Dans ce cas, NE PAS envoyer d'action — attendre l'intervention utilisateur.
for state in _replay_states.values():
@@ -2810,6 +2921,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
break
if target_state:
queue = target_queue
owning_replay = target_state
_replay_queues[session_id] = target_queue
del _replay_queues[target_sid]
target_state["session_id"] = session_id
@@ -2826,6 +2938,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
other_queue = _replay_queues.get(other_sid, [])
if other_queue:
queue = other_queue
owning_replay = state
_replay_queues[session_id] = other_queue
del _replay_queues[other_sid]
state["session_id"] = session_id
@@ -2836,8 +2949,192 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
if not queue:
return {"action": None, "session_id": session_id, "machine_id": machine_id}
# Peek à la prochaine action SANS la retirer (pour le pre-check)
action = queue[0]
# ── Boucle de traitement : actions serveur (extract_text, t2a_decision,
# llm_generate)
# exécutées entièrement côté serveur jusqu'à trouver une action visuelle
# à transmettre à l'Agent V1 ou un pause_for_human qui bloque le replay.
action = None
while queue:
action = queue[0]
# Résoudre les variables runtime ({{var}} et {{var.field}})
if owning_replay is not None:
runtime_vars = owning_replay.get("variables") or {}
if runtime_vars:
action = _resolve_runtime_vars(action, runtime_vars)
type_ = action.get("type")
# pause_for_human : pause supervisée si safety_level/safety_checks ou mode supervised,
# sinon no-op en mode autonome (skip).
if type_ == "pause_for_human":
_params = action.get("parameters") or {}
_exec_mode = (
(owning_replay or {}).get("params", {}).get("execution_mode", "autonomous")
if owning_replay else "autonomous"
)
_has_safety_decl = bool(_params.get("safety_level") or _params.get("safety_checks"))
_is_supervised = _exec_mode != "autonomous"
if owning_replay is not None and (_has_safety_decl or _is_supervised):
# QW4 — Construire le payload de pause enrichi (déclaratif + LLM contextuel)
try:
from agent_v0.server_v1.safety_checks_provider import build_pause_payload
last_screenshot_path = owning_replay.get("last_screenshot")
payload = build_pause_payload(action, owning_replay, last_screenshot_path)
owning_replay["safety_checks"] = payload.checks
owning_replay["pause_payload"] = {
"checks": payload.checks,
"pause_reason": payload.pause_reason,
"message": payload.message,
}
if payload.message:
owning_replay["pause_message"] = payload.message
# Bus event d'observabilité (pattern QW1/QW2 = logger.info)
logger.info(
"[BUS] lea:safety_checks_generated replay=%s count=%d sources=%s",
owning_replay.get("replay_id", "?"),
len(payload.checks),
[c["source"] for c in payload.checks],
)
except Exception as e:
logger.warning("QW4 build_pause_payload échec (%s) — pause sans checks", e)
owning_replay["safety_checks"] = []
# Conserver le contexte de l'action (audit + reprise)
owning_replay["failed_action"] = {
"action_id": action.get("action_id"),
"type": "pause_for_human",
"reason": "user_request",
}
owning_replay["status"] = "paused_need_help"
queue.pop(0)
_replay_queues[session_id] = queue
# Inclure replay_paused/pause_message/replay_id dès ce return
# pour que la bulle Léa s'affiche au PREMIER polling (sinon
# gap 1-2s où le chat est vide). Aligné sur la branche
# target_not_found ligne 2869-2876.
return {
"action": None,
"session_id": session_id,
"machine_id": machine_id,
"replay_paused": True,
"pause_message": owning_replay.get("pause_message", "Validation requise"),
"replay_id": owning_replay["replay_id"],
}
# Mode autonome sans safety_checks → skip (comportement legacy)
logger.info(
"pause_for_human ignorée (mode autonome) — replay %s continue",
owning_replay["replay_id"] if owning_replay else "?"
)
queue.pop(0)
_replay_queues[session_id] = queue
continue
# Actions serveur : exécuter HORS event loop pour ne pas bloquer
# les autres polls (extract_text OCR ~5s, t2a_decision LLM ~8-13s).
# Le lock reste tenu (queue cohérente) mais l'event loop est libre,
# donc les polls concurrents peuvent recevoir {server_busy: True}.
#
# Borne dure 180s par action : un hang d'EasyOCR / Ollama / I/O
# ne doit JAMAIS pouvoir tenir _replay_lock indéfiniment, sinon
# tous les endpoints sous lock (get_replay_status, /replay/next…)
# gèlent le serveur. TimeoutError est rattrapée par l'except
# Exception ci-dessous → queue.pop(0) → on passe à la suite.
if type_ in _SERVER_SIDE_ACTION_TYPES and owning_replay is not None:
try:
if type_ == "extract_text":
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_extract_text_action,
action, owning_replay, session_id, _last_heartbeat,
),
timeout=180,
)
elif type_ == "extract_table":
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_extract_table_action,
action, owning_replay, session_id, _last_heartbeat,
),
timeout=180,
)
elif type_ == "t2a_decision":
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_t2a_decision_action,
action, owning_replay,
),
timeout=180,
)
elif type_ == "llm_generate":
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_llm_generate_action,
action, owning_replay,
),
timeout=180,
)
elif type_ == "_concat_text_vars":
# Action serveur interne (générée par expansion
# extract_text_scroll). Concat 2 vars runtime → 1 var finale.
# Quasi-instantanée (pas de I/O), mais on la gère via le
# même chemin pour cohérence (timeout généreux par sécurité).
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_concat_text_vars_action,
action, owning_replay,
),
timeout=10,
)
elif type_ == "paste_and_execute":
# Invoque scripts/paste_and_execute_linuxdb.sh
# (Ctrl+V + Ctrl+Enter via ydotool dans la VM).
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_paste_and_execute_action,
action, owning_replay,
),
timeout=30,
)
except Exception as e:
logger.warning(f"Action serveur {type_} a levé : {e}")
queue.pop(0)
_replay_queues[session_id] = queue
continue # action suivante
# Clic conditionnel : si l'action a un paramètre "condition", évaluer la variable
# Format : "dec.critere1_valide" → runtime_vars["dec"]["critere1_valide"]
condition_key = (action.get("parameters") or {}).get("condition")
if condition_key and owning_replay is not None:
runtime_vars = owning_replay.get("variables") or {}
parts = condition_key.split(".", 1)
if len(parts) == 2:
val = (runtime_vars.get(parts[0]) or {}).get(parts[1])
else:
val = runtime_vars.get(parts[0])
if not val:
logger.info("Clic conditionnel ignoré (%s=%s) — action %s",
condition_key, val, action.get("action_id", "?"))
queue.pop(0)
_replay_queues[session_id] = queue
continue
# Action visuelle : sortir de la boucle pour la transmettre à l'Agent V1
break
# Si la queue s'est vidée après les exécutions serveur, rien à transmettre
if not queue or action is None:
return {"action": None, "session_id": session_id, "machine_id": machine_id}
finally:
_replay_lock.release()
# ---- Pre-check écran (optionnel, non bloquant) ----
# Ne s'applique qu'aux actions qui ont un from_node (actions de workflow,
@@ -2901,7 +3198,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
auth_actions = _auth_handler.get_auth_actions(auth_request)
if auth_actions:
# Injecter les actions d'auth en tête de queue (avant l'action bloquée)
with _replay_lock:
async with _async_replay_lock():
current_q = _replay_queues.get(session_id, [])
_replay_queues[session_id] = auth_actions + current_q
logger.info(
@@ -2910,7 +3207,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
f"type={auth_request.auth_type} (confiance={auth_request.confidence:.2f})"
)
# Retourner la première action d'auth immédiatement
with _replay_lock:
async with _async_replay_lock():
first_auth = _replay_queues[session_id].pop(0)
return {
"action": first_auth,
@@ -2958,7 +3255,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
}
# Pre-check OK (ou skip) : retirer l'action de la queue et l'envoyer
with _replay_lock:
async with _async_replay_lock():
current_queue = _replay_queues.get(session_id, [])
if current_queue and current_queue[0].get("action_id") == action.get("action_id"):
current_queue.pop(0)
@@ -3004,6 +3301,51 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
f"{_precheck_sim}"
)
# QW1 — Résoudre l'écran cible et joindre l'info à l'action
# Cascade : action.monitor_index → session.last_focused_monitor → composite_fallback
try:
session_qw1 = processor.session_manager.get_session(session_id)
last_window_info_qw1 = (
session_qw1.last_window_info if session_qw1 is not None else {}
) or {}
session_state_qw1 = {
"monitors_geometry": last_window_info_qw1.get("monitors_geometry", []),
"last_focused_monitor": last_window_info_qw1.get("monitor_index"),
}
target = resolve_target_monitor(action, session_state_qw1)
action["monitor_resolution"] = {
"idx": target.idx,
"offset_x": target.offset_x,
"offset_y": target.offset_y,
"w": target.w,
"h": target.h,
"source": target.source,
}
# QW1 — Émission bus lea:monitor_routed (no-op si bus indisponible)
# Le serveur streaming n'a pas de SocketIO local : on logge en INFO
# bien lisible. Un consommateur (agent_chat / dashboard) peut tailer
# `journalctl -u rpa-streaming | grep '\[BUS\] lea:monitor_routed'`.
try:
_replay_id_bus = (
owning_replay.get("replay_id") if owning_replay else None
)
logger.info(
"[BUS] lea:monitor_routed replay=%s action=%s idx=%d source=%s "
"offset=(%d,%d) wh=(%d,%d)",
_replay_id_bus,
action.get("action_id"),
target.idx,
target.source,
target.offset_x,
target.offset_y,
target.w,
target.h,
)
except Exception as _e_bus:
logger.debug("emit lea:monitor_routed échec (non bloquant): %s", _e_bus)
except Exception as e:
logger.debug("QW1 monitor_resolution skip (%s)", e)
response: Dict[str, Any] = {
"action": action,
"session_id": session_id,
@@ -3045,7 +3387,7 @@ async def report_action_result(report: ReplayResultReport):
)
# Trouver le replay correspondant à cette session
with _replay_lock:
async with _async_replay_lock():
replay_state = None
for state in _replay_states.values():
if state["session_id"] == session_id and state["status"] == "running":
@@ -3078,7 +3420,7 @@ async def report_action_result(report: ReplayResultReport):
# Mettre à jour le dernier screenshot reçu
screenshot_after = report.screenshot_after or report.screenshot
if screenshot_after:
with _replay_lock:
async with _async_replay_lock():
replay_state["last_screenshot"] = screenshot_after
# === Vérification post-action ===
@@ -3149,7 +3491,7 @@ async def report_action_result(report: ReplayResultReport):
# Stocker le screenshot actuel comme "before" pour la prochaine action
if screenshot_after:
with _replay_lock:
async with _async_replay_lock():
replay_state["_last_screenshot_before"] = screenshot_after
# [REPLAY] log structuré de la décision de vérification
@@ -3171,7 +3513,7 @@ async def report_action_result(report: ReplayResultReport):
)
# === Enregistrer le résultat ===
with _replay_lock:
async with _async_replay_lock():
result_entry = {
"action_id": action_id,
"success": report.success,
@@ -3331,7 +3673,7 @@ async def report_action_result(report: ReplayResultReport):
except Exception as _mem_exc:
logger.debug("Memory record skipped : %s", _mem_exc)
with _replay_lock:
async with _async_replay_lock():
# === Logique de retry / success / failure ===
if report.success and (verification is None or verification.verified):
# Action réussie (vérification OK ou pas de vérification)
@@ -3742,6 +4084,82 @@ async def report_action_result(report: ReplayResultReport):
f"— worker VLM autorisé à reprendre"
)
# ===================================================================
# QW2 — LoopDetector : alimentation des anneaux + évaluation
# ===================================================================
# On n'évalue que si le replay est encore "running" — inutile de
# pauser quelque chose de déjà completed/error/paused.
if replay_state["status"] == "running":
# Snapshot image (PIL) dans l'anneau
try:
from PIL import Image
ss_raw = screenshot_after or replay_state.get("last_screenshot")
img = None
if isinstance(ss_raw, str) and ss_raw:
if os.path.isfile(ss_raw):
img = Image.open(ss_raw).copy() # détache du file handle
else:
# Possible base64 — décoder
try:
import base64
import io as _io
img_bytes = base64.b64decode(ss_raw, validate=False)
img = Image.open(_io.BytesIO(img_bytes)).copy()
except Exception:
img = None
if img is not None:
replay_state.setdefault("_screenshot_history", []).append(img)
replay_state["_screenshot_history"] = replay_state["_screenshot_history"][-5:]
except Exception as e:
logger.debug("LoopDetector: snapshot historique échoué: %s", e)
# Snapshot signature de l'action courante
try:
_act_pos = report.actual_position or {}
action_sig = {
"type": (original_action or {}).get("type")
or replay_state.get("_last_action_type", ""),
"x_pct": _act_pos.get("x_pct") if isinstance(_act_pos, dict)
else (original_action or {}).get("x_pct"),
"y_pct": _act_pos.get("y_pct") if isinstance(_act_pos, dict)
else (original_action or {}).get("y_pct"),
}
replay_state.setdefault("_action_history", []).append(action_sig)
replay_state["_action_history"] = replay_state["_action_history"][-5:]
except Exception as e:
logger.debug("LoopDetector: snapshot action_sig échoué: %s", e)
# Évaluation (silencieux si rien)
try:
verdict = _get_loop_detector().evaluate(
replay_state,
screenshots=replay_state.get("_screenshot_history", []),
actions=replay_state.get("_action_history", []),
)
if verdict.detected:
replay_state["status"] = "paused_need_help"
replay_state["pause_reason"] = "loop_detected"
replay_state["pause_message"] = (
f"Léa semble bloquée — {verdict.signal} "
f"(détail: {verdict.evidence})"
)
logger.warning(
"LoopDetector: replay %s mis en pause — signal=%s evidence=%s",
replay_state["replay_id"], verdict.signal, verdict.evidence,
)
# Bus event d'observabilité (logger pattern QW1)
try:
logger.info(
"[BUS] lea:loop_detected replay=%s signal=%s evidence=%s",
replay_state["replay_id"],
verdict.signal,
verdict.evidence,
)
except Exception as _e_bus:
logger.debug("emit lea:loop_detected échec: %s", _e_bus)
except Exception as e:
logger.warning("LoopDetector: évaluation échouée (non bloquant): %s", e)
return {
"status": "recorded",
"action_id": action_id,
@@ -3767,7 +4185,7 @@ async def register_error_callback(config: ErrorCallbackConfig):
replay_id = config.replay_id
callback_url = config.callback_url
with _replay_lock:
async with _async_replay_lock():
if replay_id not in _replay_states:
raise HTTPException(
status_code=404,
@@ -3791,34 +4209,52 @@ async def get_replay_status(replay_id: str):
Quand le replay est en pause supervisee (paused_need_help), la reponse
inclut le contexte complet de l'echec : action echouee, screenshot,
target_spec, et message utilisateur.
Endpoint poll-friendly : l'acquisition du lock est timeboxée à 0.5 s.
Si une action serveur lente (extract_text/extract_table/t2a_decision)
tient le lock, le poll repart immédiatement avec status="busy" plutôt
que de bloquer l'event loop FastAPI (qui gèlerait l'ensemble des
endpoints jusqu'à libération). Suite logique du commit 35b27ae49 qui
avait déjà appliqué ce pattern à /replay/next ; QW4 a recâblé le
polling frontend ici → même classe de bug, même remède.
"""
with _replay_lock:
import asyncio
loop = asyncio.get_event_loop()
acquired = await loop.run_in_executor(None, _replay_lock.acquire, True, 0.5)
if not acquired:
return {
"replay_id": replay_id,
"status": "busy",
"message": "Serveur occupé (action en cours), réessaie dans 1s",
}
try:
state = _replay_states.get(replay_id)
if not state:
raise HTTPException(
status_code=404, detail=f"Replay '{replay_id}' non trouvé"
)
if not state:
raise HTTPException(
status_code=404, detail=f"Replay '{replay_id}' non trouvé"
)
# Filtrer les champs internes (prefixes par _)
result = {k: v for k, v in state.items() if not k.startswith("_")}
# Filtrer les champs internes (prefixes par _)
result = {k: v for k, v in state.items() if not k.startswith("_")}
# Enrichir avec le contexte de pause si applicable
if state["status"] == "paused_need_help":
session_id = state["session_id"]
remaining = len(_replay_queues.get(session_id, []))
result["actions_completed"] = state["completed_actions"]
result["actions_remaining"] = remaining
result["message"] = state.get("pause_message", "Replay en pause")
# Le failed_action contient deja screenshot_b64 et target_spec
# Enrichir avec le contexte de pause si applicable
if state["status"] == "paused_need_help":
session_id = state["session_id"]
remaining = len(_replay_queues.get(session_id, []))
result["actions_completed"] = state["completed_actions"]
result["actions_remaining"] = remaining
result["message"] = state.get("pause_message", "Replay en pause")
# Le failed_action contient deja screenshot_b64 et target_spec
return result
return result
finally:
_replay_lock.release()
@app.get("/api/v1/traces/stream/replays")
async def list_replays():
"""Lister tous les replays (actifs, terminés, en erreur)."""
with _replay_lock:
async with _async_replay_lock():
# Filtrer les champs internes (préfixés par _)
return {
"replays": [
@@ -3828,8 +4264,16 @@ async def list_replays():
}
class ReplayResumeRequest(BaseModel):
"""Body optionnel pour /replay/resume — QW4 acquittement de safety_checks."""
acknowledged_check_ids: List[str] = []
@app.post("/api/v1/traces/stream/replay/{replay_id}/resume")
async def resume_replay(replay_id: str):
async def resume_replay(
replay_id: str,
payload: Optional[ReplayResumeRequest] = None,
):
"""Reprendre un replay en pause supervisee (paused_need_help).
L'utilisateur a intervenu manuellement (naviguer vers le bon ecran,
@@ -3837,8 +4281,12 @@ async def resume_replay(replay_id: str):
est reinjectee en tete de queue pour etre re-tentee.
Si le replay n'est pas en pause, retourne une erreur 409 (conflit).
QW4 — Si des safety_checks sont attachés à la pause, tous ceux marqués
`required` doivent figurer dans `acknowledged_check_ids`. Sinon → 400
avec `{"error": "required_checks_missing", "missing": [...]}`.
"""
with _replay_lock:
async with _async_replay_lock():
state = _replay_states.get(replay_id)
if not state:
@@ -3855,6 +4303,25 @@ async def resume_replay(replay_id: str):
),
)
# QW4 — Vérification des safety_checks required avant reprise
safety_checks = state.get("safety_checks") or []
ack_ids = (payload.acknowledged_check_ids if payload else []) or []
if safety_checks:
required_ids = {c["id"] for c in safety_checks if c.get("required")}
ack_set = set(ack_ids)
missing = sorted(required_ids - ack_set)
if missing:
raise HTTPException(
status_code=400,
detail={"error": "required_checks_missing", "missing": missing},
)
# Audit trail
state["checks_acknowledged"] = sorted(ack_set)
logger.info(
"QW4 resume replay=%s acquittements=%d (%s)",
state.get("replay_id"), len(ack_set), sorted(ack_set),
)
# Recuperer l'action echouee pour la reinjecter
failed_action = state.get("failed_action")
session_id = state["session_id"]
@@ -3863,9 +4330,15 @@ async def resume_replay(replay_id: str):
state["status"] = "running"
state["failed_action"] = None
state["pause_message"] = None
# QW4 — vider safety_checks après acquittement (la pause est résolue)
state["safety_checks"] = []
state["pause_payload"] = None
state["pause_reason"] = ""
# Reinjecter l'action echouee en tete de queue (sera re-tentee)
if failed_action and failed_action.get("action_id"):
# pause_for_human est une pause intentionnelle, pas une erreur — ne pas réinjecter
if (failed_action and failed_action.get("action_id")
and failed_action.get("reason") != "user_request"):
# Reconstruire l'action a partir du retry_pending ou de l'original
original_action_id = failed_action["action_id"]
# Chercher l'action originale dans les retry_pending
@@ -3906,6 +4379,26 @@ async def resume_replay(replay_id: str):
}
@app.post("/api/v1/traces/stream/replay/{replay_id}/cancel")
async def cancel_replay(replay_id: str):
"""Annuler un replay (quel que soit son statut) et vider sa queue."""
async with _async_replay_lock():
state = _replay_states.get(replay_id)
if not state:
raise HTTPException(status_code=404, detail=f"Replay '{replay_id}' non trouvé")
session_id = state["session_id"]
state["status"] = "cancelled"
state["failed_action"] = None
state["pause_message"] = None
_replay_queues[session_id] = []
keys_to_del = [k for k, v in _retry_pending.items() if v.get("replay_id") == replay_id]
for k in keys_to_del:
_retry_pending.pop(k, None)
logger.info("Replay %s annulé manuellement", replay_id)
return {"status": "cancelled", "replay_id": replay_id, "session_id": session_id}
# =========================================================================
# Visual Replay — Résolution visuelle des cibles (module resolve_engine)
# =========================================================================
@@ -3960,6 +4453,72 @@ async def resolve_target(request: ResolveTargetRequest):
logger.error(f"Décodage screenshot échoué: {e}")
return _fallback_response(request, "decode_error", str(e))
# Détection image tronquée + fallback heartbeat full screen.
# Bug client constaté ce 2026-05-07 (PC Windows 192.168.1.11, agent V1) :
# mss.monitors[1] retourne parfois une bande étroite type 2560x60, 2560x108,
# 600x72 — possiblement la barre des tâches Windows confondue avec un monitor,
# ou un état mss corrompu. Reproductible même PC en mono physique. Cause
# exacte non isolée côté client (cf. session_20260506_handoff_v2.md).
# Les heartbeats (capturer.py, chemin différent de executor.py) restent en
# full screen 2560x1600. On compense ici en remplaçant l'image tronquée
# par le dernier heartbeat avant la cascade _resolve_target_sync.
effective_w = request.screen_width
effective_h = request.screen_height
# Seuil large : un écran moderne fait 2560x1600 ou plus. Tout en dessous
# de 1200x800 est suspect — bug client mss.monitors[1] qui crop sur
# barre des tâches (2560x60), Edge fenêtré (622x856), etc.
if img.height < 800 or img.width < 1200:
logger.warning(
"[RESOLVE_TARGET] Image client tronquée %dx%d (declared %dx%d) — "
"fallback heartbeat full screen",
img.width, img.height, effective_w, effective_h,
)
# Source 1 : _last_heartbeat (mémoire, peuplé par /stream/image)
candidate_path = None
candidate_age_s = None
latest_hb = max(
(h for h in _last_heartbeat.values() if h.get("path")),
key=lambda h: h.get("timestamp", 0),
default=None,
)
if latest_hb and os.path.isfile(latest_hb["path"]):
candidate_path = latest_hb["path"]
candidate_age_s = time.time() - latest_hb.get("timestamp", time.time())
else:
# Source 2 : scan disque (utile après restart serveur, avant que
# _last_heartbeat ne se repeuple — ou si l'agent V1 ne polle pas)
try:
import glob as _glob
pattern = "/home/dom/ai/rpa_vision_v3/data/training/live_sessions/*/bg_*/shots/heartbeat_*.png"
all_files = _glob.glob(pattern)
files = [
f for f in all_files
if "_blurred" not in f and os.path.isfile(f)
]
logger.info(
"[RESOLVE_TARGET] Scan disque : %d match glob, %d non-blurred existants",
len(all_files), len(files),
)
if files:
files.sort(key=lambda f: os.path.getmtime(f), reverse=True)
candidate_path = files[0]
candidate_age_s = time.time() - os.path.getmtime(candidate_path)
except Exception as e:
logger.warning("[RESOLVE_TARGET] Scan disque heartbeat échoué : %s", e)
if candidate_path:
try:
img = Image.open(candidate_path)
effective_w, effective_h = img.size
logger.info(
"[RESOLVE_TARGET] Heartbeat fallback OK : %s (%dx%d, age=%.1fs)",
candidate_path, effective_w, effective_h, candidate_age_s or -1,
)
except Exception as e:
logger.warning("[RESOLVE_TARGET] Ouverture heartbeat échouée : %s", e)
else:
logger.warning("[RESOLVE_TARGET] Aucun heartbeat disponible pour fallback")
# Sauver temporairement pour les analyseurs (ils attendent un chemin fichier)
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
img.save(tmp, format="JPEG", quality=90)
@@ -3975,8 +4534,8 @@ async def resolve_target(request: ResolveTargetRequest):
_resolve_target_sync,
tmp_path,
request.target_spec,
request.screen_width,
request.screen_height,
effective_w,
effective_h,
request.fallback_x_pct,
request.fallback_y_pct,
request.strict_mode,
@@ -3992,12 +4551,88 @@ async def resolve_target(request: ResolveTargetRequest):
request.fallback_y_pct,
)
# Pré-check sémantique post-cascade : OCR sur une zone autour de la
# coordonnée résolue pour vérifier que le by_text attendu y est bien
# présent. Attrape les cas où la cascade rend des coords plausibles
# mais pointant sur un autre élément (ex : clic sur "Dossier en cours"
# du menu au lieu de "Synthèse Urgences" du tab plus bas).
#
# Pré-check OCR — RÉACTIVÉ le 8 mai 2026
# Calibrage : radius_px=280, min_token_ratio=0.50
# Désactivable via RPA_ENABLE_TEXT_PRECHECK=false
#
# Historique :
# - 6-7 mai 2026 : assouplissements progressifs des garde-fous
# (SoM, mémoire visuelle, exemptions drift) pendant prépa démo GHT
# - 8 mai 2026 (matin) : flag défaut "false" posé sur ce pré-check
# pour stabiliser (calibrage trop strict — faux rejets sur
# onglets à 2 tokens : "Examens cliniques", "Synthèse Urgences")
# - 8 mai 2026 (après-midi) : réactivé après calibrage chirurgical
# (radius_px 200→280, min_token_ratio 0.60→0.50)
#
# Si futurs faux rejets observés :
# - vérifier d'abord radius_px (élargir si textes longs coupés)
# - puis min_token_ratio (abaisser si OCR fragmente)
# - NE PAS désactiver sans entrée DECISIONS.md datée
_text_precheck_enabled = os.environ.get(
"RPA_ENABLE_TEXT_PRECHECK", "true"
).lower() in ("true", "1", "yes")
if _text_precheck_enabled and result and result.get("resolved"):
_by_text = (request.target_spec.get("by_text") or "").strip()
if _by_text:
from agent_v0.server_v1.resolve_engine import _validate_text_at_position
_is_valid, _observed, _ocr_ms = _validate_text_at_position(
tmp_path,
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
_by_text,
effective_w,
effective_h,
)
logger.info(
"[REPLAY] Pre-check OCR ACTIF : '%s' attendu @ (%.4f, %.4f) "
"via %s — observed='%s' is_valid=%s (%.0fms)",
_by_text[:40],
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
result.get("method", "?"),
_observed[:80],
_is_valid,
_ocr_ms,
)
if not _is_valid:
logger.warning(
"[REPLAY] Pre-check OCR REJET : '%s' attendu @ (%.4f, %.4f) "
"via %s mais OCR voit '%s' (%.0fms)",
_by_text[:40],
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
result.get("method", "?"),
_observed[:80],
_ocr_ms,
)
result = {
"resolved": False,
"method": "rejected_text_mismatch",
"reason": f"expected='{_by_text[:40]}' observed='{_observed[:60]}'",
"original_method": result.get("method"),
"original_score": result.get("score"),
"x_pct": None,
"y_pct": None,
}
# [REPLAY] log structuré de sortie résolution (après validation)
# Note: x_pct/y_pct peuvent être None quand le pré-check OCR rejette
# (rejected_text_mismatch). result.get('x_pct', 0) renvoie alors None
# — la clé existe, le default 0 est ignoré — et None:.4f lève
# TypeError. Fix : `(... or 0)` traite None/None/0 uniformément.
_x = result.get('x_pct') if result else None
_y = result.get('y_pct') if result else None
logger.info(
f"[REPLAY] RESOLVE_EXIT session={request.session_id} "
f"resolved={result.get('resolved', False) if result else False} "
f"method='{result.get('method', '?') if result else 'none'}' "
f"coords=({result.get('x_pct', 0):.4f}, {result.get('y_pct', 0):.4f}) "
f"coords=({(_x or 0):.4f}, {(_y or 0):.4f}) "
f"score={result.get('score', 0) if result else 0} "
f"from_memory={bool(result.get('from_memory', False)) if result else False} "
f"reason='{result.get('reason', '') if result else ''}'"
@@ -4007,7 +4642,8 @@ async def resolve_target(request: ResolveTargetRequest):
logger.error(f"[REPLAY] RESOLVE_EXCEPTION session={request.session_id} error={e}")
return _fallback_response(request, "analysis_error", str(e))
finally:
import os
# `os` est déjà importé en haut du fichier — pas de re-import local
# (sinon UnboundLocalError plus haut dans la fonction).
try:
os.unlink(tmp_path)
except OSError:

View File

@@ -256,6 +256,20 @@ class LiveSessionManager:
session.last_window_info["title"] = wc_title
if wc_app:
session.last_window_info["app_name"] = wc_app
# QW1 — propager monitor_index et monitors_geometry depuis window_capture
if "monitor_index" in window_capture:
session.last_window_info["monitor_index"] = window_capture["monitor_index"]
if "monitors_geometry" in window_capture:
session.last_window_info["monitors_geometry"] = window_capture["monitors_geometry"]
# QW1 — propager monitor_index/monitors_geometry du payload event
# (cas heartbeat enrichi sans window/window_title). Toujours
# rafraîchir le focus actif (change souvent) et la géométrie
# (l'utilisateur peut brancher/débrancher un écran).
if "monitor_index" in event_data:
session.last_window_info["monitor_index"] = event_data["monitor_index"]
if "monitors_geometry" in event_data and event_data["monitors_geometry"]:
session.last_window_info["monitors_geometry"] = event_data["monitors_geometry"]
# Accumuler les titres/apps pour le nommage automatique
title = session.last_window_info.get("title", "").strip()

View File

@@ -0,0 +1,154 @@
# agent_v0/server_v1/loop_detector.py
"""LoopDetector composite — détection de stagnation de Léa pendant un replay (QW2).
Trois signaux indépendants :
- screen_static : N captures consécutives avec CLIP similarity > seuil
- action_repeat : N actions consécutives identiques (type + coords)
- retry_threshold : nombre de retries cumulés >= seuil
Un seul signal positif → verdict.detected=True. Le serveur bascule alors le
replay en paused_need_help avec pause_reason explicite.
Désactivable via env var RPA_LOOP_DETECTOR_ENABLED=0.
"""
import logging
import os
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class LoopVerdict:
detected: bool = False
reason: str = ""
signal: str = "" # "screen_static" | "action_repeat" | "retry_threshold" | ""
evidence: Dict[str, Any] = field(default_factory=dict)
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _env_float(name: str, default: float) -> float:
try:
return float(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _env_bool_enabled(name: str) -> bool:
val = os.environ.get(name, "1").strip().lower()
return val not in ("0", "false", "no", "off", "")
def _cosine_similarity(a, b) -> float:
"""Similarité cosine entre deux vecteurs (listes ou np.array). Robuste vecteur nul."""
import numpy as np
av = np.asarray(a, dtype=np.float32).flatten()
bv = np.asarray(b, dtype=np.float32).flatten()
na, nb = float(np.linalg.norm(av)), float(np.linalg.norm(bv))
if na < 1e-8 or nb < 1e-8:
return 0.0
return float(np.dot(av, bv) / (na * nb))
class LoopDetector:
def __init__(self, clip_embedder=None):
self.clip_embedder = clip_embedder
def evaluate(
self,
state: Dict[str, Any],
screenshots: List[Any],
actions: List[Dict[str, Any]],
) -> LoopVerdict:
"""Évalue les 3 signaux. Retourne le premier déclenché.
Args:
state: replay_state (utilisé pour retried_actions)
screenshots: anneau d'embeddings CLIP (les N derniers)
actions: anneau des N dernières actions exécutées
"""
if not _env_bool_enabled("RPA_LOOP_DETECTOR_ENABLED"):
return LoopVerdict(detected=False)
# Signal A : screen_static
verdict = self._check_screen_static(screenshots)
if verdict.detected:
return verdict
# Signal B : action_repeat
verdict = self._check_action_repeat(actions)
if verdict.detected:
return verdict
# Signal C : retry_threshold
verdict = self._check_retry_threshold(state)
if verdict.detected:
return verdict
return LoopVerdict(detected=False)
def _check_screen_static(self, screenshots: List[Any]) -> LoopVerdict:
n_required = _env_int("RPA_LOOP_SCREEN_STATIC_N", 4)
threshold = _env_float("RPA_LOOP_SCREEN_STATIC_THRESHOLD", 0.99)
if self.clip_embedder is None or len(screenshots) < n_required:
return LoopVerdict()
try:
recent = screenshots[-n_required:]
# Embed chaque capture via le CLIP embedder (peut lever)
embeddings = [self.clip_embedder.embed_image(img) for img in recent]
sims = [_cosine_similarity(embeddings[i], embeddings[i + 1])
for i in range(len(embeddings) - 1)]
min_sim = min(sims)
if min_sim > threshold:
return LoopVerdict(
detected=True,
reason="loop_detected",
signal="screen_static",
evidence={"min_similarity": round(min_sim, 4),
"n_captures": n_required,
"threshold": threshold},
)
except Exception as e:
logger.warning("LoopDetector signal_A erreur (%s) — signal inerte ce tick", e)
return LoopVerdict()
def _check_action_repeat(self, actions: List[Dict[str, Any]]) -> LoopVerdict:
n_required = _env_int("RPA_LOOP_ACTION_REPEAT_N", 3)
if len(actions) < n_required:
return LoopVerdict()
recent = actions[-n_required:]
def _signature(a: Dict[str, Any]) -> tuple:
return (a.get("type"), a.get("x_pct"), a.get("y_pct"))
sigs = [_signature(a) for a in recent]
if all(s == sigs[0] for s in sigs):
return LoopVerdict(
detected=True,
reason="loop_detected",
signal="action_repeat",
evidence={"signature": sigs[0], "count": n_required},
)
return LoopVerdict()
def _check_retry_threshold(self, state: Dict[str, Any]) -> LoopVerdict:
threshold = _env_int("RPA_LOOP_RETRY_THRESHOLD", 3)
retried = int(state.get("retried_actions", 0))
if retried >= threshold:
return LoopVerdict(
detected=True,
reason="loop_detected",
signal="retry_threshold",
evidence={"retried_actions": retried, "threshold": threshold},
)
return LoopVerdict()

View File

@@ -0,0 +1,99 @@
# agent_v0/server_v1/monitor_router.py
"""MonitorRouter — résolution de l'écran cible pour le replay (QW1).
Stratégie en cascade :
1. action.monitor_index (hérité de la session source) → cible cet écran
2. session.last_focused_monitor (focus actif vu en dernier heartbeat) → fallback
3. composite (offset 0, 0) → backward compat
Émet sur le bus lea:* l'event monitor_routed avec la source de la décision.
"""
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class MonitorTarget:
"""Représente l'écran cible résolu pour une action de replay."""
idx: int
offset_x: int
offset_y: int
w: int
h: int
source: str # "action" | "focus" | "composite_fallback"
_COMPOSITE_FALLBACK = MonitorTarget(
idx=-1,
offset_x=0,
offset_y=0,
w=0,
h=0,
source="composite_fallback",
)
def _find_monitor(geometry: List[Dict[str, Any]], idx: int) -> Optional[Dict[str, Any]]:
"""Retourne le monitor d'index donné, ou None si absent."""
for m in geometry:
if m.get("idx") == idx:
return m
return None
def _to_target(monitor: Dict[str, Any], source: str) -> MonitorTarget:
return MonitorTarget(
idx=int(monitor["idx"]),
offset_x=int(monitor.get("x", 0)),
offset_y=int(monitor.get("y", 0)),
w=int(monitor.get("w", 0)),
h=int(monitor.get("h", 0)),
source=source,
)
def resolve_target_monitor(
action: Dict[str, Any],
session_state: Dict[str, Any],
) -> MonitorTarget:
"""Résout l'écran cible d'une action de replay.
Args:
action: Dict de l'action (peut contenir `monitor_index`).
session_state: État de la session (doit contenir `monitors_geometry`
et `last_focused_monitor`).
Returns:
MonitorTarget avec l'offset à appliquer aux coordonnées de grounding.
"""
geometry: List[Dict[str, Any]] = session_state.get("monitors_geometry") or []
# 1. Cible explicite via action
explicit_idx = action.get("monitor_index")
if explicit_idx is not None and geometry:
m = _find_monitor(geometry, int(explicit_idx))
if m is not None:
return _to_target(m, source="action")
# Index invalide → on tombe sur le fallback focus
logger.warning(
"[BUS] lea:monitor_invalid_index requested=%d available_idx=%s",
int(explicit_idx), [g.get("idx") for g in geometry],
)
# 2. Fallback focus actif
focused_idx = session_state.get("last_focused_monitor")
if focused_idx is not None and geometry:
m = _find_monitor(geometry, int(focused_idx))
if m is not None:
return _to_target(m, source="focus")
logger.warning(
"[BUS] lea:monitor_unavailable focused_idx=%d available_idx=%s",
int(focused_idx), [g.get("idx") for g in geometry],
)
# 3. Fallback composite (backward compat — comportement actuel mss.monitors[0])
return _COMPOSITE_FALLBACK

View File

@@ -15,6 +15,7 @@ Extrait de api_stream.py pour clarifier l'architecture.
import json
import logging
import os
import re
import threading
import time
@@ -32,8 +33,32 @@ _ALLOWED_ACTION_TYPES = {
"click", "type", "key_combo", "scroll", "wait",
"file_open", "file_save", "file_close", "file_new", "file_dialog",
"double_click", "right_click", "drag",
"verify_screen", # Replay hybride : vérification visuelle entre groupes
"verify_screen", # Replay hybride : vérification visuelle entre groupes
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
"extract_text", # OCR serveur sur dernier heartbeat → variable workflow
"extract_table", # OCR serveur + filtre regex → liste structurée (boucle)
"extract_text_scroll", # Marker côté graphe — expansé en sous-actions par _edge_to_normalized_actions
"_concat_text_vars", # Action serveur interne (générée par expansion extract_text_scroll)
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
"llm_generate", # Génération texte libre côté serveur → variable workflow
"paste_and_execute", # Bypass NoMachine : ydotool Ctrl+V+Ctrl+Enter dans VM via SSH
}
# Types d'actions exécutées CÔTÉ SERVEUR (jamais transmises à l'Agent V1).
# Le pipeline /replay/next les traite en boucle interne et passe à l'action
# suivante jusqu'à trouver une action visuelle (à transmettre au client).
_SERVER_SIDE_ACTION_TYPES = {
"extract_text",
"extract_table",
"t2a_decision",
"llm_generate",
"_concat_text_vars",
"paste_and_execute",
}
# Pause par défaut entre Ctrl+End/Home et la capture suivante (ms).
# Configurable par step via parameters.scroll_pause_ms ; default ici.
SCROLL_PAUSE_MS = 500
_MAX_ACTION_TEXT_LENGTH = 10000
_MAX_KEYS_PER_COMBO = 10
# Touches autorisées dans les key_combo (modificateurs + touches spéciales + caractères simples)
@@ -852,6 +877,87 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
keys = [action_params["key"]]
normalized["keys"] = keys
elif action_type == "pause_for_human":
normalized["type"] = "pause_for_human"
normalized["parameters"] = {
"message": action_params.get("message", "Validation requise"),
}
return [normalized] # pas de target/coords pour cette action logique
elif action_type == "extract_text":
normalized["type"] = "extract_text"
normalized["parameters"] = {
"output_var": action_params.get("output_var", "extracted_text"),
"paragraph": bool(action_params.get("paragraph", True)),
}
return [normalized]
elif action_type == "paste_and_execute":
normalized["type"] = "paste_and_execute"
normalized["parameters"] = {}
return [normalized]
elif action_type == "extract_table":
normalized["type"] = "extract_table"
normalized["parameters"] = {
"output_var": action_params.get("output_var", "table_rows"),
"pattern": action_params.get("pattern"),
"limit": action_params.get("limit"),
"region": action_params.get("region"),
}
return [normalized]
elif action_type == "extract_text_scroll":
# Expansion en séquence : OCR(top) → Ctrl+End → wait → OCR(bottom)
# → concat(top, bottom → final) → Ctrl+Home.
# variable_name (préféré) ou output_var (compat extract_text).
final_var = (
action_params.get("variable_name")
or action_params.get("output_var")
or "extracted_text"
)
paragraph = bool(action_params.get("paragraph", True))
# Pause après scroll Ctrl+End — configurable au step.
# Default 500ms (Wikipedia) ; cible 1500-2000ms pour DPI Citrix lent.
try:
scroll_pause = int(action_params.get("scroll_pause_ms", SCROLL_PAUSE_MS))
except (TypeError, ValueError):
scroll_pause = SCROLL_PAUSE_MS
# Variables internes nommées par préfixe : invisibles à l'utilisateur.
# Préfixe `_` pour signaler "interne" et éviter collision.
top_var = f"__{final_var}_top"
bottom_var = f"__{final_var}_bottom"
return _expand_extract_text_scroll(
base, final_var, top_var, bottom_var, paragraph,
scroll_pause_ms=scroll_pause,
)
elif action_type == "t2a_decision":
normalized["type"] = "t2a_decision"
normalized["parameters"] = {
"input_template": action_params.get("input_template", ""),
"output_var": action_params.get("output_var", "t2a_result"),
"model": action_params.get("model"),
}
return [normalized]
elif action_type == "llm_generate":
normalized["type"] = "llm_generate"
normalized["parameters"] = {
"prompt": action_params.get("prompt", ""),
"context": action_params.get("context", ""),
"output_var": (
action_params.get("output_var")
or action_params.get("variable_name")
or "generated_text"
),
"model": action_params.get("model"),
}
if action_params.get("temperature") is not None:
normalized["parameters"]["temperature"] = action_params.get("temperature")
return [normalized]
else:
logger.warning(f"Type d'action inconnu : {action_type}")
return []
@@ -886,6 +992,521 @@ def _substitute_variables(text: str, params: Dict[str, Any], defaults: Dict[str,
return re.sub(r'\$\{(\w+)\}', replacer, text)
# Regex pour le templating runtime : {{var}} ou {{var.champ}} ou {{var.champ.sous}}
_RUNTIME_VAR_PATTERN = re.compile(r'\{\{\s*(\w+)(?:\.([\w.]+))?\s*\}\}')
def _resolve_runtime_vars_in_str(text: str, variables: Dict[str, Any]) -> str:
"""Remplace {{var}} et {{var.field}} par leur valeur depuis le dict variables.
Variables/champs absents : laissés tels quels (ne casse pas le pipeline).
Pour les valeurs non-str (dict, list), str() est appelé.
"""
def replacer(match):
var_name = match.group(1)
path = match.group(2)
if var_name not in variables:
return match.group(0)
value = variables[var_name]
if path:
for field in path.split('.'):
if isinstance(value, dict) and field in value:
value = value[field]
else:
return match.group(0)
return str(value)
return _RUNTIME_VAR_PATTERN.sub(replacer, text)
def _resolve_runtime_vars(value: Any, variables: Dict[str, Any]) -> Any:
"""Résout récursivement les {{var}} et {{var.field}} dans une valeur.
Supporte str, dict, list. Les autres types sont retournés tels quels.
Si variables est vide ou None, value est retournée inchangée.
"""
if not variables:
return value
if isinstance(value, str):
return _resolve_runtime_vars_in_str(value, variables)
if isinstance(value, dict):
return {k: _resolve_runtime_vars(v, variables) for k, v in value.items()}
if isinstance(value, list):
return [_resolve_runtime_vars(item, variables) for item in value]
return value
# =========================================================================
# Handlers pour les actions exécutées côté serveur (extract_text, t2a_decision)
# =========================================================================
def _normalize_ollama_endpoint(raw_url: str) -> str:
"""Normalise une URL Ollama pour les clients qui attendent l'endpoint racine.
`OLLAMA_URL` est parfois configuré vers `/api/generate` alors que
`LLMActionHandler` attend la racine `http://host:port`.
"""
endpoint = (raw_url or "http://localhost:11434").strip().rstrip("/")
for suffix in ("/api/generate", "/api/chat"):
if endpoint.endswith(suffix):
return endpoint[: -len(suffix)]
return endpoint
def _handle_extract_text_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
session_id: str,
last_heartbeat: Dict[str, Dict[str, Any]],
) -> bool:
"""Traite une action extract_text côté serveur. Stocke le texte OCRisé dans
replay_state["variables"][output_var]. Retourne True si succès.
Robuste aux échecs : si pas de heartbeat ou OCR raté, stocke "" et retourne
False (le pipeline continue, pas de blocage).
"""
params = action.get("parameters") or {}
# Compatibilité VWB : "variable_name" (VWB) et "output_var" (agent libre)
output_var = (params.get("output_var") or params.get("variable_name") or "extracted_text").strip()
paragraph = bool(params.get("paragraph", True))
# Source prioritaire : screenshot envoyé par l'agent après la dernière action.
# Si c'est du base64, on le sauvegarde dans un fichier temp pour l'OCR.
# Fallback : heartbeat de fond (vrai chemin serveur, via "bg_{machine_id}").
path = None
raw_screenshot = replay_state.get("last_screenshot") or ""
if raw_screenshot:
if raw_screenshot.startswith("data:"):
# base64 → fichier temp
try:
import base64, tempfile
header, b64data = raw_screenshot.split(",", 1)
suffix = ".jpg" if "jpeg" in header else ".png"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp.write(base64.b64decode(b64data))
tmp.close()
path = tmp.name
except Exception as e:
logger.warning("extract_text: décodage base64 screenshot échoué: %s", e)
elif os.path.isfile(raw_screenshot):
path = raw_screenshot
if not path:
machine_id = replay_state.get("machine_id", "")
bg_session = f"bg_{machine_id}" if machine_id and machine_id != "default" else None
heartbeat = (
last_heartbeat.get(session_id)
or (last_heartbeat.get(bg_session) if bg_session else None)
or {}
)
path = heartbeat.get("path")
text = ""
if path:
try:
from core.llm import extract_text_from_image
text = extract_text_from_image(path, paragraph=paragraph)
except Exception as e:
logger.warning("extract_text OCR échoué (%s) — variable '%s' = ''", e, output_var)
else:
logger.warning(
"extract_text : pas de heartbeat pour session %s — variable '%s' = ''",
session_id, output_var,
)
replay_state.setdefault("variables", {})[output_var] = text
logger.info(
"extract_text → variable '%s' (%d chars) replay %s",
output_var, len(text), replay_state.get("replay_id", "?"),
)
return bool(text)
def _handle_extract_table_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
session_id: str,
last_heartbeat: Dict[str, Dict[str, Any]],
) -> bool:
"""Traite une action extract_table côté serveur. OCR + filtre regex pour
retourner une liste structurée (ex : IPP d'un tableau de patients) qui
pourra être bouclée par le templating ${patients[i]}.
Paramètres reconnus :
output_var : nom de variable runtime (default "table_rows")
pattern : regex à matcher sur chaque token OCR (ex : r"^25\\d{6}$")
limit : nb max d'entrées à retourner
region : (x, y, w, h) en pixels pour cropper avant OCR
(None = image entière)
Robuste aux échecs : si pas de heartbeat ou OCR raté, stocke [] et
retourne False — le pipeline continue.
"""
params = action.get("parameters") or {}
output_var = (params.get("output_var") or params.get("variable_name") or "table_rows").strip()
pattern = params.get("pattern") or None
limit = params.get("limit")
region = params.get("region") or None
if isinstance(limit, str):
try:
limit = int(limit)
except ValueError:
limit = None
# Source : screenshot du heartbeat (idem extract_text)
path = None
raw_screenshot = replay_state.get("last_screenshot") or ""
if raw_screenshot:
if raw_screenshot.startswith("data:"):
try:
import base64, tempfile
header, b64data = raw_screenshot.split(",", 1)
suffix = ".jpg" if "jpeg" in header else ".png"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp.write(base64.b64decode(b64data))
tmp.close()
path = tmp.name
except Exception as e:
logger.warning("extract_table: décodage base64 screenshot échoué: %s", e)
elif os.path.isfile(raw_screenshot):
path = raw_screenshot
if not path:
machine_id = replay_state.get("machine_id", "")
bg_session = f"bg_{machine_id}" if machine_id and machine_id != "default" else None
heartbeat = (
last_heartbeat.get(session_id)
or (last_heartbeat.get(bg_session) if bg_session else None)
or {}
)
path = heartbeat.get("path")
rows: list = []
if path:
try:
from core.llm import extract_table_from_image
rows = extract_table_from_image(
path,
region=tuple(region) if region else None,
pattern=pattern,
limit=limit,
)
except Exception as e:
logger.warning(
"extract_table OCR échoué (%s) — variable '%s' = []", e, output_var,
)
else:
logger.warning(
"extract_table : pas de heartbeat pour session %s — variable '%s' = []",
session_id, output_var,
)
replay_state.setdefault("variables", {})[output_var] = rows
logger.info(
"extract_table → variable '%s' (%d entrées, pattern=%r, limit=%s) replay %s",
output_var, len(rows), pattern, limit, replay_state.get("replay_id", "?"),
)
return bool(rows)
def _handle_t2a_decision_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
) -> bool:
"""Traite une action t2a_decision côté serveur. Stocke le résultat JSON
dans replay_state["variables"][output_var]. Retourne True si succès.
Le DPI à analyser vient de action.parameters.input_template (déjà résolu
par _resolve_runtime_vars donc les {{var}} sont remplis).
"""
params = action.get("parameters") or {}
output_var = (params.get("output_var") or "t2a_result").strip()
dpi_text = (params.get("input_template") or params.get("dpi") or "").strip()
model = params.get("model") or None # None → DEFAULT_MODEL
# Bypass LLM : si static_result est fourni dans les params, on l'utilise
# tel quel comme résultat. Utile pour les démos déterministes (pas de
# hallucination, pas de latence, pas de truncation de prompt).
static_result = params.get("static_result")
if isinstance(static_result, dict) and static_result.get("decision"):
replay_state.setdefault("variables", {})[output_var] = static_result
logger.info(
"t2a_decision (STATIC) → variable '%s' decision=%s replay %s",
output_var, static_result.get("decision"), replay_state.get("replay_id", "?"),
)
return True
if not dpi_text:
logger.warning(
"t2a_decision : input vide — variable '%s' = {decision: 'INDETERMINE'}", output_var,
)
replay_state.setdefault("variables", {})[output_var] = {
"decision": "INDETERMINE",
"justification": "DPI vide ou non extrait",
"confiance": "faible",
"_error": "empty_input",
}
return False
try:
from core.llm import analyze_dpi, DEFAULT_MODEL, build_dpi_enriched
# Enrichissement déterministe avant LLM : injection FAITS_CALCULÉS en
# tête (durée, âge, CCMU/GEMSA/priorité…) pour neutraliser les
# hallucinations de durée (cf. bug "23h" MOREL). La metadata est
# capturée pour les garde-fous serveur (commit 2 — Python ↔ LLM).
dpi_enriched, metadata = build_dpi_enriched(dpi_text)
logger.info(
"[build_dpi_enriched] duree_python=%sh decision_terrain=%r warnings=%d",
metadata.get("duree_heures_decimales"),
metadata.get("decision_terrain"),
len(metadata.get("parsing_warnings", [])),
)
result = analyze_dpi(dpi_enriched, model=model or DEFAULT_MODEL)
except Exception as e:
logger.warning("t2a_decision : analyze_dpi exception %s", e)
result = {
"decision": "INDETERMINE",
"justification": f"Erreur analyse : {e}",
"confiance": "faible",
"_error": str(e),
}
# Si parse_error, injecter des valeurs de fallback pour que les templates restent lisibles
if result.get("_parse_error"):
raw_preview = result.get("_raw", "")[:200]
logger.warning("t2a_decision parse_error — raw: %s", raw_preview)
result.setdefault("decision", "INDETERMINE")
result.setdefault("decision_court", "À vérifier")
result.setdefault("preuve_critere1", "Analyse non disponible (erreur LLM)")
result.setdefault("preuve_critere2", "Analyse non disponible (erreur LLM)")
result.setdefault("preuve_critere3", "Analyse non disponible (erreur LLM)")
result.setdefault("justification", f"Réponse LLM non parsable : {raw_preview}")
result.setdefault("confiance", "faible")
replay_state.setdefault("variables", {})[output_var] = result
decision = result.get("decision", "?")
elapsed = result.get("_elapsed_s", "?")
logger.info(
"t2a_decision → variable '%s' decision=%s (%ss) replay %s",
output_var, decision, elapsed, replay_state.get("replay_id", "?"),
)
return "_error" not in result and not result.get("_parse_error")
def _handle_llm_generate_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
) -> bool:
"""Traite une action llm_generate côté serveur.
Stocke le texte généré dans replay_state["variables"][output_var].
Les paramètres `prompt` et `context` sont déjà résolus via le templating
runtime avant d'arriver ici.
"""
params = action.get("parameters") or {}
output_var = (
params.get("output_var")
or params.get("variable_name")
or "generated_text"
).strip()
# Bypass LLM : si static_text est fourni dans les params, on l'utilise
# tel quel. Utile pour les démos déterministes.
static_text = params.get("static_text")
if isinstance(static_text, str) and static_text.strip():
replay_state.setdefault("variables", {})[output_var] = static_text
logger.info(
"llm_generate (STATIC) → variable '%s' (%d chars) replay %s",
output_var, len(static_text), replay_state.get("replay_id", "?"),
)
return True
prompt = str(params.get("prompt") or "").strip()
context = str(params.get("context") or "")
model = params.get("model") or None
temperature = None
if params.get("temperature") is not None:
try:
temperature = float(params.get("temperature"))
except (TypeError, ValueError):
logger.warning(
"llm_generate : temperature invalide %r — fallback valeur par défaut",
params.get("temperature"),
)
if not prompt:
logger.warning(
"llm_generate : prompt vide — variable '%s' = ''",
output_var,
)
replay_state.setdefault("variables", {})[output_var] = ""
return False
generated = ""
try:
from core.execution.llm_actions import LLMActionHandler
handler = LLMActionHandler(
ollama_endpoint=_normalize_ollama_endpoint(
os.environ.get("OLLAMA_URL", "http://localhost:11434")
),
timeout=180,
)
generated = handler.generate_text(
prompt=prompt,
context=context,
model=model,
temperature=temperature,
).strip()
except Exception as e:
logger.warning("llm_generate : génération échouée (%s) — variable '%s' = ''", e, output_var)
replay_state.setdefault("variables", {})[output_var] = generated
logger.info(
"llm_generate → variable '%s' (%d chars, model=%s) replay %s",
output_var,
len(generated),
model or "default",
replay_state.get("replay_id", "?"),
)
return bool(generated)
def _handle_concat_text_vars_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
) -> bool:
"""Traite une action serveur interne `_concat_text_vars`.
Concatène deux variables runtime existantes (top_var + separator + bottom_var)
et écrit le résultat dans output_var. Variables manquantes traitées comme "".
Action générée par l'expansion de `extract_text_scroll` ; pas exposée à
l'utilisateur final. Robuste aux échecs OCR amont (l'une ou l'autre var vide).
"""
params = action.get("parameters") or {}
top_var = (params.get("top_var") or "").strip()
bottom_var = (params.get("bottom_var") or "").strip()
output_var = (params.get("output_var") or "extracted_text").strip()
separator = params.get("separator", "\n\n")
variables = replay_state.setdefault("variables", {})
top_text = str(variables.get(top_var, "") or "")
bottom_text = str(variables.get(bottom_var, "") or "")
# Si les deux sont vides, output reste "" (cohérent avec _handle_extract_text_action).
# Si un seul est vide, on évite un séparateur inutile en début/fin.
if top_text and bottom_text:
merged = top_text + separator + bottom_text
else:
merged = top_text or bottom_text
variables[output_var] = merged
# Nettoyage des variables internes pour ne pas polluer l'état.
if top_var.startswith("__"):
variables.pop(top_var, None)
if bottom_var.startswith("__"):
variables.pop(bottom_var, None)
logger.info(
"extract_text_scroll concat → variable '%s' (%d chars) replay %s",
output_var, len(merged), replay_state.get("replay_id", "?"),
)
return bool(merged)
def _handle_paste_and_execute_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
) -> bool:
"""Action serveur : invoque scripts/paste_and_execute_linuxdb.sh pour
déclencher Ctrl+V + Ctrl+Enter dans DBeaver de la VM via ydotool.
Bypasse Léa/NoMachine (Ctrl mangé par NoMachine passive grab).
Cf. handoff 2026-05-16_handoff_ydotool_clipboard.md.
"""
import subprocess
script_path = "/home/dom/ai/rpa_vision_v3/scripts/paste_and_execute_linuxdb.sh"
try:
result = subprocess.run(
[script_path],
timeout=30,
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.warning(
"paste_and_execute échoué (rc=%d) stderr=%s",
result.returncode, (result.stderr or "")[:500],
)
return False
logger.info("paste_and_execute OK replay %s", replay_state.get("replay_id", "?"))
return True
except subprocess.TimeoutExpired:
logger.warning("paste_and_execute timeout (30s)")
return False
except Exception as e:
logger.warning("paste_and_execute exception : %s", e)
return False
def _expand_extract_text_scroll(
base: Dict[str, Any],
final_var: str,
top_var: str,
bottom_var: str,
paragraph: bool,
scroll_pause_ms: int = SCROLL_PAUSE_MS,
) -> List[Dict[str, Any]]:
"""Expanse un step extract_text_scroll en séquence d'actions atomiques.
Séquence générée :
1. extract_text(top_var) — OCR zone visible (haut de page)
2. key_combo(ctrl+end) — scroll bas (côté client Léa V1)
3. wait(scroll_pause_ms) — laisse DOM/UI se redessiner
4. extract_text(bottom_var) — OCR zone visible (bas de page)
5. _concat_text_vars(top, bottom→final) — action serveur interne
6. key_combo(ctrl+home) — remet en haut
Toutes les sous-actions héritent de `base` (edge_id, from_node, to_node)
pour la traçabilité. Chaque action obtient un action_id unique.
`scroll_pause_ms` : configurable au step (défaut SCROLL_PAUSE_MS=500ms).
"""
def _new_action() -> Dict[str, Any]:
return {**base, "action_id": f"act_{uuid.uuid4().hex[:8]}"}
a1 = _new_action()
a1["type"] = "extract_text"
a1["parameters"] = {"output_var": top_var, "paragraph": paragraph}
a2 = _new_action()
a2["type"] = "key_combo"
a2["keys"] = ["ctrl", "end"]
a3 = _new_action()
a3["type"] = "wait"
a3["duration_ms"] = scroll_pause_ms
a4 = _new_action()
a4["type"] = "extract_text"
a4["parameters"] = {"output_var": bottom_var, "paragraph": paragraph}
a5 = _new_action()
a5["type"] = "_concat_text_vars"
a5["parameters"] = {
"top_var": top_var,
"bottom_var": bottom_var,
"output_var": final_var,
"separator": "\n\n",
}
a6 = _new_action()
a6["type"] = "key_combo"
a6["keys"] = ["ctrl", "home"]
return [a1, a2, a3, a4, a5, a6]
def _expand_compound_steps(
steps: List[Dict[str, Any]], base: Dict[str, Any], params: Dict[str, Any]
) -> List[Dict[str, Any]]:
@@ -1208,6 +1829,18 @@ def _create_replay_state(
# Champs pour pause supervisée (target_not_found)
"failed_action": None, # Contexte de l'action en echec (quand paused_need_help)
"pause_message": None, # Message a afficher a l'utilisateur
# Variables d'exécution produites en cours de workflow (extract_text,
# t2a_decision, etc.). Résolues via templating {{var}} ou {{var.field}}
# dans les paramètres des actions suivantes.
"variables": {},
# QW2 — Anneaux d'historique pour LoopDetector (5 derniers max)
"_screenshot_history": [], # images PIL des N derniers heartbeats (LoopDetector embed à chaque tick)
"_action_history": [], # N dernières actions exécutées (signature)
# QW4 — Safety checks (hybride déclaratif + LLM contextuel) et audit acquittements
"safety_checks": [], # liste produite par SafetyChecksProvider
"checks_acknowledged": [], # ids acquittés via /replay/resume (audit trail)
"pause_reason": "", # "loop_detected" | "" pour V1
"pause_payload": None, # payload complet pour debug/audit
}

View File

@@ -26,6 +26,8 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from core.grounding.bbox_parser import parse_bbox_to_norm, parse_bbox_to_norm_validated
logger = logging.getLogger("api_stream")
@@ -125,7 +127,7 @@ def _resolve_by_template_matching(
best_scale = 1.0
best_anchor_size = (aw, ah)
for scale in [1.0, 0.9, 1.1, 0.8, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 1.75, 2.0]:
for scale in [1.0, 0.95, 0.93, 0.9, 1.05, 1.1, 0.85, 0.8, 1.15, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 0.4, 0.35, 0.3, 0.25, 1.75, 2.0]:
if scale != 1.0:
new_w = int(aw * scale)
new_h = int(ah * scale)
@@ -833,51 +835,8 @@ def _resolve_by_grounding(
elapsed = time.time() - t0
# Parser la réponse — supporte bbox_2d en pixels, JSON %, arrays bruts
x_pct, y_pct = None, None
# Format 1 : bbox_2d en pixels [x, y] ou [x1, y1, x2, y2]
bbox_match = re.search(r'"bbox_2d"\s*:\s*\[([^\]]+)\]', content)
if bbox_match:
coords = [float(v.strip()) for v in bbox_match.group(1).split(",")]
if len(coords) == 2:
x_pct = coords[0] / small_w
y_pct = coords[1] / small_h
elif len(coords) >= 4:
x_pct = (coords[0] + coords[2]) / 2 / small_w
y_pct = (coords[1] + coords[3]) / 2 / small_h
# Format 2 : JSON {"x": 0.XX, "y": 0.YY}
if x_pct is None:
json_match = re.search(r'"x"\s*:\s*([\d.]+).*?"y"\s*:\s*([\d.]+)', content)
if json_match:
x_val, y_val = float(json_match.group(1)), float(json_match.group(2))
# Si > 1, c'est en pixels
if x_val > 1:
x_pct = x_val / small_w
y_pct = y_val / small_h
else:
x_pct = x_val
y_pct = y_val
# Format 3 : {"x_pct": 0.XX, "y_pct": 0.YY}
if x_pct is None:
pct_match = re.search(r'"x_pct"\s*:\s*([\d.]+).*?"y_pct"\s*:\s*([\d.]+)', content)
if pct_match:
x_pct = float(pct_match.group(1))
y_pct = float(pct_match.group(2))
# Format 4 : array brut [x1, y1, x2, y2] ou [x, y]
if x_pct is None:
arr_match = re.search(r'\[[\s]*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+)\s*,\s*([\d.]+))?\s*\]', content)
if arr_match:
vals = [float(v) for v in arr_match.groups() if v is not None]
if len(vals) >= 4:
x_pct = (vals[0] + vals[2]) / 2 / small_w
y_pct = (vals[1] + vals[3]) / 2 / small_h
elif len(vals) == 2:
x_pct = vals[0] / small_w
y_pct = vals[1] / small_h
# Parser la réponse — délégué à core.grounding.bbox_parser
x_pct, y_pct = parse_bbox_to_norm(content, small_w, small_h)
if x_pct is None or y_pct is None:
# Fallback multi-image : screenshot + crop → grounding sans description
@@ -900,21 +859,12 @@ def _resolve_by_grounding(
content2 = resp2.json().get("message", {}).get("content", "")
elapsed = time.time() - t0
# Parser tous les formats
arr2 = re.search(r'\[[\s]*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+)\s*,\s*([\d.]+))?\s*\]', content2)
if arr2:
vals = [float(v) for v in arr2.groups() if v is not None]
if len(vals) >= 4:
x_pct = (vals[0] + vals[2]) / 2 / small_w
y_pct = (vals[1] + vals[3]) / 2 / small_h
elif len(vals) == 2:
x_pct = vals[0] / small_w
y_pct = vals[1] / small_h
if x_pct is None:
json2 = re.search(r'"x"\s*:\s*([\d.]+).*?"y"\s*:\s*([\d.]+)', content2)
if json2:
x_pct = float(json2.group(1)) / small_w
y_pct = float(json2.group(2)) / small_h
# Parser la réponse — délégué à core.grounding.bbox_parser
# Restriction aux 2 formats attendus par le prompt retry multi-image
# (cf. prompt_mi qui demande {"x": NNN, "y": NNN} en pixels).
x_pct, y_pct = parse_bbox_to_norm(
content2, small_w, small_h, formats={"xy_json", "raw_array"}
)
if x_pct is not None:
logger.info("Grounding multi-image OK (%.1fs)", elapsed)
except Exception as e:
@@ -1746,6 +1696,49 @@ def _resolve_target_sync(
)
return result
# ---------------------------------------------------------------
# Étape 0.5 : OCR direct (hybrid_text_direct) — chemin rapide
# ---------------------------------------------------------------
# Si on a un texte cible non vide, le localiser par OCR direct
# avant de tomber sur le VLM (~100-300ms vs 2-23s par appel VLM).
# Reconnecté le 2026-05-06 : la fonction _resolve_by_ocr_text
# existait déjà mais n'était appelée QUE depuis le runtime V4
# (resolve_order pré-compilé), qui n'est pas branché côté frontend
# (cf. audit project-quality-guardian Cas #5). La cascade legacy
# tombait directement sur VLM Quick Find d'où des replays à 23s
# par action visuelle au lieu de <500ms attendus.
# Le method est rebadgé "hybrid_text_direct" (seuil 0.80 dans
# _RESOLUTION_MIN_SCORES, identifiant historique côté client
# Agent V1 et logs Learning).
if by_text_strict:
ocr_result = _resolve_by_ocr_text(
screenshot_path=screenshot_path,
target_text=by_text_strict,
screen_width=screen_width,
screen_height=screen_height,
)
if ocr_result and ocr_result.get("score", 0) >= 0.80:
ocr_result["method"] = "hybrid_text_direct"
logger.info(
"Strict resolve OCR-DIRECT : OK '%s' → (%.4f, %.4f) score=%.2f",
by_text_strict[:40],
ocr_result.get("x_pct", 0),
ocr_result.get("y_pct", 0),
ocr_result.get("score", 0),
)
return ocr_result
elif ocr_result:
logger.info(
"Strict resolve OCR-DIRECT : '%s' trouvé score=%.2f < 0.80, passage VLM",
by_text_strict[:40],
ocr_result.get("score", 0),
)
else:
logger.info(
"Strict resolve OCR-DIRECT : '%s' non trouvé, passage VLM",
by_text_strict[:40],
)
# ---------------------------------------------------------------
# Étape 1 : VLM Quick Find (fallback, multi-image)
# ---------------------------------------------------------------
@@ -2114,7 +2107,139 @@ _RESOLUTION_MIN_SCORES: Dict[str, float] = {
# Écart maximum toléré entre coords résolues et coords enregistrées
# (en fraction d'écran, dans chaque axe). Au-delà, on considère que la
# résolution a trouvé un faux positif ailleurs sur l'écran.
_RESOLUTION_MAX_DRIFT: float = 0.20
# 15 mai 2026 : élargi 0.20 → 0.95 pour respecter le principe "100% vision"
# (Dom). Le score template/VLM seul est désormais la source de confiance,
# la position de la capture initiale n'est plus contraignante.
_RESOLUTION_MAX_DRIFT: float = 0.95
# ===========================================================================
# Pré-check sémantique : OCR de validation de position
# ===========================================================================
# Avant de dispatcher un clic, on vérifie que le texte attendu (by_text) est
# bien présent dans une fenêtre OCR autour de la coordonnée résolue. Cela
# attrape les cas où la cascade renvoie une coordonnée plausible mais qui
# pointe en réalité sur un autre élément (ex: clic sur "Dossier en cours" du
# menu au lieu de "Synthèse Urgences" du tab plus bas).
# ===========================================================================
_VALIDATION_OCR_READER = None
_VALIDATION_OCR_LOCK = threading.Lock()
_VALIDATION_OCR_FAILED = False
def _get_validation_ocr_reader():
"""Singleton EasyOCR partagé pour la validation post-cascade.
Chargement paresseux à la première requête. En cas d'échec, on cache
le statut FAILED pour ne pas retenter à chaque appel et bloquer le flux.
"""
global _VALIDATION_OCR_READER, _VALIDATION_OCR_FAILED
if _VALIDATION_OCR_FAILED:
return None
with _VALIDATION_OCR_LOCK:
if _VALIDATION_OCR_READER is None and not _VALIDATION_OCR_FAILED:
try:
import easyocr # type: ignore
_VALIDATION_OCR_READER = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
)
logger.info("[REPLAY] EasyOCR validator chargé (fr+en, GPU)")
except Exception as e:
logger.warning("[REPLAY] EasyOCR validator indisponible (%s) — pré-check désactivé", e)
_VALIDATION_OCR_FAILED = True
return None
return _VALIDATION_OCR_READER
def _normalize_for_match(s: str) -> str:
"""Normalisation pour comparaison textuelle robuste : lowercase, sans
accents, ponctuation → espace, espaces multiples écrasés.
"""
import unicodedata
decomposed = unicodedata.normalize('NFD', s.lower())
no_accents = ''.join(c for c in decomposed if unicodedata.category(c) != 'Mn')
cleaned = ''.join(c if c.isalnum() or c.isspace() else ' ' for c in no_accents)
return ' '.join(cleaned.split())
def _text_match_fuzzy(expected: str, observed: str, min_token_ratio: float = 0.60) -> bool:
"""Match tolérant aux imperfections OCR.
1. Substring exacte → match.
2. Sinon : split en tokens ≥3 caractères, retourne True si au moins
`min_token_ratio` des tokens attendus apparaissent dans observed.
Ex : "Coller ou saisir le dossier patient" → tokens
['coller', 'saisir', 'dossier', 'patient'] ; si OCR voit "u saisir
le dossier patient" → 3/4 = 75% présents → match accepté.
Cible le compromis entre strict (faux négatifs sur erreurs OCR) et
permissif (faux positifs sur textes voisins).
"""
nexp = _normalize_for_match(expected)
nobs = _normalize_for_match(observed)
if not nexp:
return True
if nexp in nobs:
return True
tokens = [t for t in nexp.split() if len(t) >= 3]
if not tokens:
return False
matched = sum(1 for t in tokens if t in nobs)
return matched / len(tokens) >= min_token_ratio
def _validate_text_at_position(
screenshot_path: str,
x_pct: float,
y_pct: float,
expected_text: str,
screen_width: int,
screen_height: int,
radius_px: int = 280,
) -> tuple:
"""Pré-check sémantique : OCR sur une zone autour de (x_pct, y_pct) et
vérifie que `expected_text` y est présent (substring ou fuzzy 50%).
Retourne (is_valid: bool, observed_text: str, elapsed_ms: float).
Politique en cas d'échec OCR (lib absente, exception) : retourne
(True, "", 0.0) pour ne pas bloquer le flux. Mieux vaut un faux positif
rare qu'une régression bloquante introduite par la validation elle-même.
"""
reader = _get_validation_ocr_reader()
if reader is None:
return True, "", 0.0
if not expected_text or not expected_text.strip():
return True, "", 0.0
try:
from PIL import Image
import numpy as np
t0 = time.time()
img = Image.open(screenshot_path).convert("RGB")
img_w, img_h = img.size
cx = int(x_pct * screen_width)
cy = int(y_pct * screen_height)
# Saturer dans les bornes de l'image (le screenshot peut être plus
# large que la fenêtre logique — utiliser min(img_*, screen_*) en sécurité).
max_x = min(img_w, screen_width)
max_y = min(img_h, screen_height)
x1 = max(0, cx - radius_px)
y1 = max(0, cy - radius_px)
x2 = min(max_x, cx + radius_px)
y2 = min(max_y, cy + radius_px)
if x2 - x1 < 10 or y2 - y1 < 10:
return True, "", 0.0
crop = img.crop((x1, y1, x2, y2))
results = reader.readtext(np.array(crop))
observed = " ".join(r[1] for r in results if r and len(r) >= 2)
elapsed_ms = (time.time() - t0) * 1000
is_valid = _text_match_fuzzy(expected_text, observed, min_token_ratio=0.50)
return is_valid, observed, elapsed_ms
except Exception as e:
logger.warning("[REPLAY] _validate_text_at_position erreur (%s) — pas de blocage", e)
return True, "", 0.0
def _validate_resolution_quality(
@@ -2193,6 +2318,30 @@ def _validate_resolution_quality(
dx = abs(resolved_x - fallback_x_pct)
dy = abs(resolved_y - fallback_y_pct)
if dx > _RESOLUTION_MAX_DRIFT or dy > _RESOLUTION_MAX_DRIFT:
# Exception : pour les méthodes "haute confiance" qui ont
# identifié sémantiquement la cible (texte exact via OCR ou
# image quasi parfaite via template), on fait confiance à la
# position visuelle peu importe le drift. Le drift par rapport
# à l'enregistrement ne reflète qu'un changement de layout
# (scroll, redimensionnement, F11, refonte UI, résolution
# différente), pas une erreur de résolution.
#
# - template_matching ≥ 0.95 : image retrouvée pixel-perfect
# - hybrid_text_direct ≥ 0.80 : texte exact reconnu par OCR
# (0.80 est déjà le seuil d'acceptation côté _RESOLUTION_MIN_SCORES,
# au-dessus on a un signal sémantique fiable).
_high_confidence_method = (
(method.startswith("template_matching") and score >= 0.95)
or (method == "hybrid_text_direct" and score >= 0.80)
)
if _high_confidence_method:
logger.info(
"[REPLAY] Drift (%.3f, %.3f) > %.2f IGNORÉ : score=%.3f "
"sur %s — résultat visuel fiable, on l'utilise",
dx, dy, _RESOLUTION_MAX_DRIFT, score, method,
)
return result
logger.warning(
"[REPLAY] Resolution REJETÉE (drift trop grand) : "
"method=%s resolved=(%.3f, %.3f) expected=(%.3f, %.3f) "
@@ -2201,6 +2350,10 @@ def _validate_resolution_quality(
fallback_x_pct, fallback_y_pct,
dx, dy, _RESOLUTION_MAX_DRIFT,
)
# 100% visuel : on ne clique JAMAIS aux coords enregistrées en aveugle.
# resolved=False → la couche supérieure tente la méthode suivante
# (VLM Quick Find, SoM, grounding) ; si toutes échouent, l'agent
# passe par "visual_resolve_failed" → Policy → pause supervisée.
return {
"resolved": False,
"method": f"rejected_drift_{method}",
@@ -2363,21 +2516,16 @@ def _locate_popup_button(
content = resp.json().get("message", {}).get("content", "")
# Parser bbox_2d — qwen2.5vl retourne des coordonnées en pixels
# relatifs à l'image envoyée, PAS sur une grille 1000x1000.
# Format JSON : [{"bbox_2d": [x1, y1, x2, y2], "label": "..."}]
bbox_match = re.search(
r'"bbox_2d"\s*:\s*\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]',
content,
# Parser bbox_2d — délégué à core.grounding.bbox_parser
# Restriction au format bbox_2d attendu par le prompt
# (cf. prompt qui demande "bounding box"). qwen2.5vl retourne
# des coordonnées en pixels relatifs à l'image envoyée.
cx, cy = parse_bbox_to_norm_validated(
content, screen_width, screen_height, formats={"bbox_2d"}
)
if bbox_match:
x1, y1, x2, y2 = [int(bbox_match.group(i)) for i in range(1, 5)]
# Normaliser par les dimensions de l'écran (pixels → 0-1)
cx = (x1 + x2) / 2 / screen_width
cy = (y1 + y2) / 2 / screen_height
if 0.0 <= cx <= 1.0 and 0.0 <= cy <= 1.0:
logger.info(f"Observer : bouton '{button_text}' localisé à ({cx:.3f}, {cy:.3f})")
return {"x_pct": cx, "y_pct": cy}
if cx is not None:
logger.info(f"Observer : bouton '{button_text}' localisé à ({cx:.3f}, {cy:.3f})")
return {"x_pct": cx, "y_pct": cy}
except Exception as e:
logger.debug(f"Observer grounding bouton erreur : {e}")

View File

@@ -0,0 +1,195 @@
# agent_v0/server_v1/safety_checks_provider.py
"""SafetyChecksProvider — checks hybrides déclaratifs + LLM contextuels (QW4).
Pour une action pause_for_human :
- les checks déclaratifs (workflow) sont toujours inclus
- si safety_level == "medical_critical" et RPA_SAFETY_CHECKS_LLM_ENABLED=1,
un appel LLM (medgemma:4b par défaut) ajoute jusqu'à N checks contextuels
Tout échec côté LLM (timeout, exception, parse) → additional_checks=[] :
le replay continue avec uniquement les déclaratifs (fallback safe).
"""
import base64
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class PausePayload:
checks: List[Dict[str, Any]] = field(default_factory=list)
pause_reason: str = ""
message: str = ""
def _env(name: str, default: str) -> str:
return os.environ.get(name, default).strip()
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _env_bool_enabled(name: str) -> bool:
val = os.environ.get(name, "1").strip().lower()
return val not in ("0", "false", "no", "off", "")
def build_pause_payload(
action: Dict[str, Any],
replay_state: Dict[str, Any],
last_screenshot: Optional[str],
) -> PausePayload:
"""Construit le payload de pause enrichi pour une action pause_for_human."""
params = action.get("parameters") or {}
message = params.get("message", "Validation requise")
safety_level = params.get("safety_level")
declarative = params.get("safety_checks") or []
# Normalisation des checks déclaratifs
checks: List[Dict[str, Any]] = []
for d in declarative:
checks.append({
"id": d.get("id") or f"decl_{uuid.uuid4().hex[:6]}",
"label": d.get("label", "Validation"),
"required": bool(d.get("required", True)),
"source": "declarative",
"evidence": None,
})
# Ajout LLM contextual si applicable
if safety_level == "medical_critical" and _env_bool_enabled("RPA_SAFETY_CHECKS_LLM_ENABLED"):
try:
additional = _call_llm_for_contextual_checks(
action=action,
replay_state=replay_state,
last_screenshot=last_screenshot,
existing_labels=[c["label"] for c in checks],
)
except Exception as e:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=exception detail=%s", e)
additional = []
for a in additional:
checks.append({
"id": f"llm_{uuid.uuid4().hex[:6]}",
"label": a.get("label", ""),
"required": False, # checks LLM = informationnels, pas obligatoires V1
"source": "llm_contextual",
"evidence": a.get("evidence", ""),
})
return PausePayload(
checks=checks,
pause_reason="",
message=message,
)
def _call_llm_for_contextual_checks(
action: Dict[str, Any],
replay_state: Dict[str, Any],
last_screenshot: Optional[str],
existing_labels: List[str],
) -> List[Dict[str, str]]:
"""Appelle Ollama en mode JSON strict pour générer 0-N checks contextuels.
Returns:
List[{label, evidence}] (max RPA_SAFETY_CHECKS_LLM_MAX_CHECKS).
[] sur tout échec (timeout, JSON invalide, exception).
"""
import requests
# Défaut gemma4:latest : meilleur compromis détection/latence sur bench
# 2026-05-06 (cf. docs/BENCH_SAFETY_CHECKS_2026-05-06.md). medgemma:4b
# retournait systématiquement [] (refus de signaler).
model = _env("RPA_SAFETY_CHECKS_LLM_MODEL", "gemma4:latest")
# Timeout 7s : warm avg gemma4 = 2.9s + marge 4s. Cold start ~10s couvert
# si le modèle reste résident (OLLAMA_KEEP_ALIVE=24h recommandé prod).
timeout_s = _env_int("RPA_SAFETY_CHECKS_LLM_TIMEOUT_S", 7)
max_checks = _env_int("RPA_SAFETY_CHECKS_LLM_MAX_CHECKS", 3)
ollama_url = _env("OLLAMA_URL", "http://localhost:11434")
params = action.get("parameters") or {}
workflow_message = params.get("message", "")
existing = ", ".join(existing_labels) if existing_labels else "aucun"
prompt = f"""Tu es Léa, assistante médicale supervisée.
Avant de continuer le workflow, tu dois lister 0 à {max_checks} vérifications supplémentaires
que l'humain doit acquitter, en regardant l'écran actuel.
Contexte workflow : {workflow_message}
Checks déjà demandés : {existing}
NE répète PAS un check déjà demandé.
Si rien d'inhabituel à signaler, retourne {{"additional_checks": []}}.
Réponds UNIQUEMENT en JSON :
{{
"additional_checks": [
{{"label": "string court", "evidence": "ce que tu as vu d'inhabituel"}}
]
}}
"""
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": 0.1, "num_predict": 200},
}
if last_screenshot and os.path.isfile(last_screenshot):
try:
with open(last_screenshot, "rb") as f:
payload["images"] = [base64.b64encode(f.read()).decode("ascii")]
except Exception as e:
logger.debug("safety_checks: lecture screenshot échouée (%s) — appel sans image", e)
try:
response = requests.post(
f"{ollama_url}/api/generate",
json=payload,
timeout=timeout_s,
)
if response.status_code != 200:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=http_status detail=%s", response.status_code)
return []
text = response.json().get("response", "").strip()
except requests.Timeout:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=timeout detail=%ss", timeout_s)
return []
except Exception as e:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=network detail=%s", e)
return []
# format=json garantit normalement du JSON valide
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
logger.warning("[BUS] lea:safety_checks_llm_failed reason=json_decode detail=%s", e)
return []
additional = parsed.get("additional_checks") or []
if not isinstance(additional, list):
return []
# Filtre + tronc
valid = []
for item in additional[:max_checks]:
if isinstance(item, dict) and item.get("label"):
valid.append({
"label": str(item["label"])[:200],
"evidence": str(item.get("evidence", ""))[:300],
})
return valid

View File

@@ -1791,6 +1791,10 @@ class StreamProcessor:
# Workflows construits (pour le matching)
self._workflows: Dict[str, Any] = {}
# Shadow learning : dernier pattern UI détecté par session
# Stocke {session_id: {"pattern": str, "ocr_text": str, "screen_state": obj, "shot_id": str}}
self._pending_ui_patterns: Dict[str, Dict[str, Any]] = {}
# Charger les workflows existants depuis le disque
self._load_persisted_workflows()
@@ -1975,6 +1979,9 @@ class StreamProcessor:
- key_combo/key_press avec uniquement des modificateurs seuls (ctrl, alt, shift, etc.)
- key_combo/key_press avec liste de touches vide
- text_input avec texte vide
Shadow learning : quand un clic suit un pattern UI détecté,
on apprend l'association dialogue→bouton.
"""
if _is_parasitic_event(event_data):
logger.debug(
@@ -1982,9 +1989,119 @@ class StreamProcessor:
f"type={event_data.get('type')}, data={event_data.get('keys', event_data.get('text', ''))}"
)
return {"status": "event_filtered", "session_id": session_id, "reason": "parasitic"}
# Shadow learning : si un pattern UI est en attente et qu'on reçoit un clic
if event_data.get("type") == "mouse_click":
self._try_shadow_learn(session_id, event_data)
self.session_manager.add_event(session_id, event_data)
return {"status": "event_recorded", "session_id": session_id}
def _try_shadow_learn(self, session_id: str, click_event: Dict[str, Any]):
"""Tente d'apprendre un pattern UI depuis un clic observé en Shadow.
Quand un screenshot contenait un pattern UI détecté (dialogue) et que
l'utilisateur clique ensuite, on extrait le texte OCR au point de clic
pour apprendre l'association : "quand je vois ce texte → cliquer sur ce bouton".
"""
with self._data_lock:
pending = self._pending_ui_patterns.pop(session_id, None)
if not pending:
return
screen_state = pending.get("screen_state")
if screen_state is None:
return
# Extraire la position du clic (pixels absolus)
pos = click_event.get("pos", [])
if not pos or len(pos) != 2:
return
click_x, click_y = pos[0], pos[1]
# Trouver le texte OCR le plus proche du point de clic
# via les ui_elements du ScreenState (ils ont bbox + label)
clicked_label = self._find_label_at_position(screen_state, click_x, click_y)
if not clicked_label:
return
# Extraire le trigger principal du texte OCR du dialogue
ocr_text = pending.get("ocr_text", "")
# Utiliser un extrait court comme trigger (max 80 chars, premier segment pertinent)
trigger_text = ocr_text[:80].strip().lower()
if not trigger_text:
return
logger.info(
f"Shadow learning: pattern '{pending['pattern_name']}' "
f"→ utilisateur a cliqué '{clicked_label}' | trigger='{trigger_text[:40]}...'"
)
# Sauvegarder le pattern appris
try:
from core.knowledge.ui_patterns import UIPatternLibrary
lib = UIPatternLibrary()
lib.save_learned_pattern({
"category": "dialog",
"triggers": [trigger_text],
"action": "click",
"target": clicked_label,
"os": "windows",
"confidence": 0.8,
})
except Exception as e:
logger.warning(f"Shadow learning: échec sauvegarde pattern: {e}")
@staticmethod
def _find_label_at_position(screen_state, click_x: int, click_y: int) -> Optional[str]:
"""Trouve le label de l'élément UI le plus proche du point de clic.
Parcourt les ui_elements du ScreenState et retourne le label de
l'élément dont la bbox contient le point, ou le plus proche si aucun
ne contient exactement le point.
"""
ui_elements = getattr(screen_state, "ui_elements", [])
if not ui_elements:
return None
best_label = None
best_dist = float("inf")
for elem in ui_elements:
bbox = getattr(elem, "bbox", None)
label = getattr(elem, "label", "")
if not bbox or not label:
continue
# BBox = (x, y, width, height) — extraire les coordonnées
try:
bx, by = bbox.x, bbox.y
bw, bh = bbox.width, bbox.height
except AttributeError:
# Fallback si bbox est une liste/tuple
if hasattr(bbox, '__len__') and len(bbox) >= 4:
bx, by, bw, bh = bbox[0], bbox[1], bbox[2], bbox[3]
else:
continue
# Vérifier si le clic est dans la bbox
if bx <= click_x <= bx + bw and by <= click_y <= by + bh:
return label.strip()
# Sinon calculer la distance au centre
cx = bx + bw / 2
cy = by + bh / 2
dist = ((click_x - cx) ** 2 + (click_y - cy) ** 2) ** 0.5
if dist < best_dist:
best_dist = dist
best_label = label.strip()
# Ne retourner le plus proche que s'il est raisonnablement proche (< 100px)
if best_label and best_dist < 100:
return best_label
return None
# =========================================================================
# Screenshots
# =========================================================================
@@ -2042,6 +2159,37 @@ class StreamProcessor:
self._screen_states[session_id] = []
self._screen_states[session_id].append(screen_state)
# Enrichir avec les patterns UI connus
try:
from core.knowledge.ui_patterns import UIPatternLibrary
detected_text = getattr(screen_state.perception, "detected_text", [])
if detected_text:
ocr_text = " ".join(str(t) for t in detected_text) if isinstance(detected_text, list) else str(detected_text)
lib = UIPatternLibrary()
pattern = lib.find_pattern(ocr_text)
if pattern:
result["ui_pattern"] = pattern["pattern"]
result["ui_pattern_action"] = pattern["action"]
result["ui_pattern_target"] = pattern["target"]
logger.info(f"Pattern UI détecté: {pattern['pattern']}{pattern['target']}")
# Shadow learning : mémoriser le pattern en attente du clic utilisateur
with self._data_lock:
self._pending_ui_patterns[session_id] = {
"pattern_name": pattern["pattern"],
"ocr_text": ocr_text,
"screen_state": screen_state,
"shot_id": shot_id,
}
else:
# Pas de pattern connu → effacer le pending (l'écran a changé)
with self._data_lock:
self._pending_ui_patterns.pop(session_id, None)
except ImportError:
pass
except Exception as e:
logger.debug(f"Pattern check: {e}")
logger.info(
f"Screenshot analysé: {shot_id} | "
f"{result['ui_elements_count']} UI elements, "

View File

@@ -0,0 +1,695 @@
Bonjour ! Aujourd'hui, nous devons réaliser la démo de bout en bout et sans accros. Il nous faut être précis pour ne pas avoir à refaire constamment le workflow, c'est long et
chronophaque. Il faut penser que l'on doit pouvoir "communiquer" avec Léa pour lui demander de faire le travail. Par exemple, traite moi les 3 premiers dossier (dans vwb on a un
outil de lescture de tableau ?). Il faut donc qu'elle soit capable de lire le tableau correctement et faire le traitement propre au fur et à mesure. On peut aussi, si c'est
nécessaire modifier l'interface de la maquette pour plus de simplicité. Pour finir, est ce que l'utilisation de vwb est suffisament solide pour faire l'exercice plutôt que
d'utiliser le mode d'apprentissage de lea ? Ne code rien, on discute.
● Bonjour. Avant de raisonner architecture, j'ai besoin de clarifier 3 choses :
Questions de cadrage
1. Quand exactement la démo ? La semaine est entamée — on parle de mardi-mercredi-jeudi ? Combien d'heures de prep on a réellement avant ?
2. Scénario souhaité : tu te projettes en quoi devant la DSI Carvella ?
- "Je dis à Léa : traite les 3 premiers dossiers. Léa lit le tableau, choisit, fait, rapporte" — pur pilotage vocal/chat
- Ou : "Je clique le bouton Léa qui lance le workflow A pour le dossier sélectionné" — démo de l'automatisation, pilotage visuel
- Ou les deux à des moments différents
3. Métier vs tech : Carvella vient en adversaire technique (RPA classique). Tu veux qu'il soit bluffé sur l'INTELLIGENCE de Léa (compréhension naturelle, lecture de tableau,
généralisation) ou sur la PROFONDEUR métier (preuves cliniques citées, gain ROI 100k€/mois) ? Les deux mais quel curseur ?
Maintenant mes réponses sur tes 4 points :
Outil de lecture de tableau dans VWB
À ma connaissance non, pas d'action spécifique extract_table. Ce qui existe :
- extract_text (OCR sur une zone) → renvoie du texte plat, pas une structure
- Import Excel via /api/v3/excel/upload (vu hier dans le code) — mais c'est pour DÉPOSER un Excel, pas lire un tableau à l'écran
- Les learned_workflows qui ont une notion de "boucle données → UI" (vu un commit 9da804bb6 feat: import Excel → SQLite + boucle données → UI dans le VWB)
→ Il faudrait que je vérifie learned_workflows.py et la logique de boucle pour voir si ça peut servir. Mais à ce jour, pour lire le tableau de patients à l'écran et boucler dessus,
il y a un trou. C'est probablement à construire ou à contourner.
VWB vs apprentissage Léa pour la démo
┌───────────────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────┐
│ Critère │ Workflow VWB │ Apprentissage Léa │
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
│ Déterminisme (rejouable, pas de surprise) │ ✅ │ ⚠️ moins prévisible │
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
│ Robustesse aux variations UI │ ❌ (cf. nos 8h d'hier) │ ✅ │
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
│ Vitesse de mise au point │ Long, fragile (re-enregistrement) │ Court (Léa s'adapte) │
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
│ Crédibilité démo "IA vraie" │ ⚠️ (RPA classique +) │ ✅ (ce qui te démarque d'Evanov) │
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
│ Risque le jour J │ Modéré (si répété) │ Plus élevé (moins testé) │
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
│ Prêt aujourd'hui │ Presque (re-enregistrement) │ Probablement pas │
└───────────────────────────────────────────┴───────────────────────────────────┴──────────────────────────────────┘
Mon avis honnête : pour la démo qui arrive cette semaine, workflow VWB pour le squelette + intelligence Léa pour la partie qui démarque est le bon mix.
Concrètement :
- Léa lit le tableau patients (étape "intelligente" qu'on met en avant)
- Léa choisit les N premiers (capacité de compréhension)
- Pour chaque patient, Léa lance le workflow VWB Urgence (mécanique fiable)
- Léa rapporte au humain (l'analyse PMSI, les preuves citées)
L'enveloppe est intelligente. Le moteur dedans est du VWB solide. C'est ce qui te démarque tout en étant fiable.
Communication avec Léa pour le scénario "traite les 3 premiers"
Architecturalement, ça implique :
1. Un endpoint chat Léa (déjà : rpa-agent-chat port 5004) qui reçoit "traite les 3 premiers dossiers"
2. Un NLP léger côté Léa qui interprète : action=process_patients, nombre=3, ordre=top
3. Une lecture du tableau : OCR de la zone tableau + parsing par lignes (le tableau est régulier : IPP | Nom | Prénom | … donc parser les colonnes par espaces multiples)
4. Une boucle : pour chaque IPP des 3 premiers, lancer le workflow Urgence avec ce dossier en paramètre (ce qui implique de variabiliser le by_text="{{patient_id}}")
5. Un retour utilisateur : "Dossier 25003284 codé en FORFAIT_URGENCE — Dossier 25003362 codé en FORFAIT — Dossier 25003364 codé en UHCD avec ces preuves : …"
L'élément manquant central : paramétrisation du workflow par variable d'entrée + mécanisme de boucle.
Modifier la maquette ? Oui mais avec discernement
Modifier pour SIMPLIFIER (rendre les anchors plus stables) = OK. Exemples acceptables :
- Compacter les 3 textareas pour tenir dans le viewport sans scroll
- Ajouter des bordures contrastées sur les zones cibles (template matching plus robuste)
- Mettre un placeholder unique dans chaque textarea (texte distinctif que template matching reconnaît)
Modifier pour TRICHER (afficher déjà la décision UHCD avant que Léa ait analysé) = NON, ça tue la crédibilité.
Question de précision et non-régression
C'est ton vrai point critique. Je propose qu'on :
1. Fige une procédure d'enregistrement documentée (config Chrome précise, étapes, validation)
2. Capture une fois proprement, et après on n'y touche plus
3. Crée un script de test E2E qui valide en 30 secondes que le workflow marche encore (à lancer avant chaque session, et avant la démo)
---
Bref, ma recommandation pour aujourd'hui : on construit dans cet ordre :
1. Discussion : tu réponds à mes 3 questions de cadrage → on aligne sur le scénario démo
2. Variabilisation du workflow Urgence : by_text="{{patient_id}}" + extension templating
3. Mécanisme Léa lit tableau + boucle sur N patients (enveloppe intelligente autour du workflow VWB)
4. Re-enregistrement précis des anchors dans config bureau standard
5. Procédure documentée + script de test E2E
Mais avant tout, je veux entendre tes réponses aux 3 questions de cadrage. Ça change radicalement l'angle.
✻ Cogitated for 1m 24s
[Image #2] Il y a bien dans vwb un extracteur de tableau. La démo dans 4 jours. Je n'ai pas compris ta question : "Scénario souhaité : tu te projettes en quoi devant la DSI Carvella
?" Pour le 3) je dirais les deux. Pas simplement pour kervella, mais aussi pour les dim/tim/dg. Si je suis assez confiant sur la partie workflow, c'est surtout la partie analyse du
dossier qui m"interroge ?
 [Image #2]
● OK, message reçu. L'enjeu majeur c'est la qualité de l'analyse, pas la mécanique. C'est exactement le point qui démarque Léa d'un RPA classique : un workflow qui clique, c'est
UiPath. Une Léa qui raisonne juste sur du texte clinique, c'est ton offre.
Reformulation de ma question scénario
Concrètement, ce que tes 4 minutes devant les DG vont ressembler. Je veux comprendre le déroulé pour caler le bon niveau de polish à chaque étape :
- Minute 0 : tu ouvres l'écran, qu'est-ce qui est affiché ? (Easily avec liste de 11 dossiers ? Chat Léa ? Les deux côte à côte ?)
- Minute 1 : tu dis quoi à voix / en chat à Léa ? Et qu'est-ce que Carvella+DG voient se passer à l'écran ?
- Minute 2-3 : Léa analyse et code 1 ou 3 dossiers. Que regardent-ils ? La maquette qui se remplit ? Léa qui parle ? Un dashboard qui synthétise ?
- Minute 4 : Léa rapporte le résultat. Sous quelle forme ? "J'ai codé 3 dossiers : 2 forfaits, 1 UHCD. Voici les preuves de l'UHCD : …". Vocal ? Texte ? Dashboard ?
Tu n'es pas obligé d'avoir une réponse précise — c'est ce qu'on doit décider ensemble. Mais c'est ce que je voulais dire par "scénario souhaité".
Sur ton inquiétude (l'analyse) — c'est le vrai sujet
Je propose un plan de durcissement de l'analyse LLM en 3 axes :
A. Bench complet sur tes 11 dossiers
- Lancer t2a_decision sur les 11 dossiers anonymisés (les 4 UHCD + les 4 forfaits + les 3 PE2/SU2/SU2)
- Vérifier la décision (vrai positif / vrai négatif)
- Vérifier la qualité des preuves (citations littérales vraiment du dossier ? pertinentes ? pas inventées ?)
- Vérifier la confiance (le LLM dit "elevee" quand il devrait dire "moyenne" ?)
- Tableau de scores : accuracy + qualité preuves + temps + VRAM
B. Comparer 2-3 modèles
On a déjà bench medgemma:4b (docs/BENCH_MEDGEMMA.md) et qwen2.5:7b. À tester aussi peut-être gemma3:12b pour voir la marge. Choix final selon : qualité, vitesse, VRAM, robustesse.
C. Renforcer le prompt si besoin
Selon le bench, on peut ajouter :
- Des exemples few-shot (1 exemple UHCD, 1 forfait, copiés en début de prompt)
- Une étape de raisonnement explicite ("Avant de décider, liste les 3 critères et pour chacun cite une preuve")
- Un garde-fou : si confiance="faible", Léa demande à l'humain au lieu de coder
Cela rend l'analyse défendable devant Carvella : tu peux dire "Léa cite le dossier mot pour mot, et quand elle doute, elle nous le dit."
Pour les 4 jours, je propose ce découpage
┌───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Jour │ Focus │
├───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Aujourd'hui (J-4) │ Cadrage scénario démo + bench analyse LLM sur 11 dossiers + identification des cas problématiques │
├───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ J-3 │ Si bench OK : variabilisation workflow + Extraire tableau + boucle Léa. Si bench KO : amélioration prompt et/ou modèle │
├───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ J-2 │ Re-enregistrement workflow propre + intégration mécanisme "Léa lit tableau et traite N dossiers" │
├───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ J-1 │ Test E2E répété 5-10 fois sur scénario démo. Tweaks finaux. Préparation slides │
└───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Ce que je propose maintenant (avant de coder)
1. Tu réponds aux questions scénario ci-dessus (ça cadre tout le reste)
2. Je vais voir le code de "Extraire tableau" pour comprendre ses capacités exactes (lecture seule, on discute ensuite)
3. Je propose un script de bench des 11 dossiers (qui ne touche pas le code prod, juste pour mesurer)
OK ?
✻ Crunched for 58s
Alors, pour demarrer, chat lea. Je lui demande de traiter 3 dossiers.Elle se réduit (si ça doit être possible) Elle ouvre chrome, tape l'adresse de la maquette, elle lit le
tableau. Elle traite aprés dossier par dossier. A la fin, elle dis que c'est fini ! Tu en penses quoi ? Est ce que c'est complexe, ça demande beaucoup de changement ?
● Ce que tu décris est clair et faisable en 4 jours. Pas de révolution — on a les briques. Il faut surtout assembler et variabiliser.
Décomposition technique du scénario
┌────────────────────────────────────────┬─────────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────┐
│ Étape │ Brique nécessaire │ Existe ? │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ Chat Léa reçoit "traite 3 dossiers" │ NLP léger (regex ou mini-LLM) côté chat │ ⚠️ partiel — chat existe, NLP commande à coder │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ Léa se réduit │ iconify() Tkinter ou raccourci Win+D │ 🟢 trivial │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ Léa ouvre Chrome + tape URL │ Action launch_app ou raccourci Win + Chrome déjà ouvert │ ⚠️ à brancher │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ Léa lit le tableau │ "Extraire tableau" VWB │ 🟢 existe (bouton vu) — à vérifier ce qu'il rend │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ Léa boucle sur 3 IPP │ Mécanisme boucle workflow + variable {{patient_id}} │ ⚠️ "boucle données→UI" existe partiellement (commit 9da804bb6) — à confirmer │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ Pour chaque dossier : workflow Urgence │ Workflow existant variabilisé │ ✏️ workflow OK, juste variabiliser le step 2 │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
│ Léa dit "c'est fini + synthèse" │ Réponse chat + récap des décisions │ ⚠️ à coder simple │
└────────────────────────────────────────┴─────────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────┘
Évaluation honnête
Pas complexe au sens "casse-tête". C'est de la plomberie d'assemblage :
- 4-5 modifs de code structurelles, chacune ~30 min à 2h
- 1 re-enregistrement de workflow (30 min si bien préparé)
- 1 jour de test E2E qu'il faut absolument répéter 10× pour la fiabilité
Le risque majeur : c'est qu'on découvre en cours de route que "Extraire tableau" ne renvoie pas ce qu'on espère, ou que le mécanisme de boucle VWB n'est pas mature, et qu'on doive
coder du contournement. Je propose qu'on fasse les 2 vérifications en premier (1h grand max).
Découpage 4 jours proposé
┌──────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬──────────────────────────────────────────┐
│ Jour │ Focus │ Livrable │
├──────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
│ J-4 │ (1) Vérifier capacités "Extraire tableau" et boucle VWB. (2) Bench analyse LLM sur les 11 dossiers. (3) Cadrage │ Verdict bench LLM + go/no-go boucle VWB │
│ (aujourd'hui) │ final scénario │ │
├──────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
│ J-3 │ (1) Variabilisation workflow + extension templating. (2) NLP commande chat Léa. (3) Action minimiser + ouvrir │ Léa peut traiter 1 dossier sur commande │
│ │ Chrome │ chat │
├──────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
│ J-2 │ (1) Re-enregistrement workflow propre. (2) Intégration boucle (Léa traite N dossiers). (3) Synthèse fin │ Démo end-to-end fonctionne au moins 1 │
│ │ │ fois │
├──────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
│ J-1 │ Test E2E répété 5-10 fois. Polish. Procédure documentée. │ Démo robuste, reproductible │
└──────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────────────────────────────┘
Liste précise des changements (cumul sur 4 jours)
1. Variabiliser by_text du step "sélection patient" : 30 min
2. Étendre templating {{var}} au by_text et autres champs (aujourd'hui c'est sur text uniquement) : 1h
3. NLP commande chat Léa : regex simple "traite (\d+) dossiers?" + dispatch action : 2h
4. Action launch_url ou utilisation du Chrome déjà ouvert (selon préférence) : 30 min
5. Mécanisme boucle : à valider (peut-être 0, peut-être 4h)
6. Re-enregistrement workflow Urgence + test : 1h-2h
7. Synthèse fin de boucle envoyée au chat : 1h
8. Procédure et tests E2E : 1 journée
Total 1.5-2 jours de dev pur, 1 jour de re-enregistrement+test, 1 jour de polish/répétition. Confortable sur 4 jours.
Question pour décider maintenant
Avant que je vérifie quoi que ce soit, 2 décisions à prendre :
A. Sur "Léa ouvre Chrome + tape URL" : c'est un détail théâtral (ça impressionne) mais ça ajoute 1 step potentiellement fragile. Alternative : Chrome est déjà ouvert sur la maquette
quand la démo commence, et Léa enchaîne directement la lecture du tableau. Tu préfères ?
B. Sur "Léa se réduit" : tu veux qu'elle disparaisse de l'écran pendant le travail (ça augmente le côté "elle bosse toute seule"), ou qu'elle reste visible en petite popup (côté "je
vois ce qu'elle fait") ? Les deux marchent.
Une fois ces 2 réponses, je vais voir le code "Extraire tableau" et "boucle VWB" — et reviens avec un verdict en 30 min.
✻ Cooked for 1m 7s
Pour le A j'ai déjà fait avec vwb sous linux, ça fonctionne bien et c'est rapide. Sous windows pas testé. Donc, on part sans que chrome soit ouvert. Oui, pendant la démo, léa se
réduit completement.
● OK, je vais voir les 4 capacités existantes en mode read-only et reviens avec un verdict.
Searched for 11 patterns, read 1 file (ctrl+o to expand)
● Voici mon verdict après vérification :
Bilan des briques existantes
┌───────────────────────────────────┬─────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────┐
│ Brique │ État │ Verdict │
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
│ Action extract_table │ 🟡 Bouton frontend OK avec params │ Backend NON implémenté — coquille vide actuellement │
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
│ Mécanisme boucle db_foreach │ ✅ Codé dans dag_execute.py:274 │ Mais côté backend VWB Flask (Linux), pas côté serveur replay (PC Windows) │
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
│ Templating ${current_row.col} │ ✅ Existe pour db_foreach │ Réutilisable │
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
│ Templating {{var}} │ ⚠️ Existe mais uniquement sur le champ text │ À étendre au by_text │
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
│ Action launch_url / ouvrir Chrome │ ❌ Pas trouvée │ À coder ou contourner │
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
│ Minimiser Léa Tkinter │ ✅ iconify() existe │ Trivial │
└───────────────────────────────────┴─────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────┘
Ce qu'il faut construire pour ton scénario
┌─────┬─────────────────────────────────────────────────────────────────────────────────┬─────────────┐
│ # │ Brique │ Coût estimé │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 1 │ extract_table côté serveur replay (OCR + parsing par lignes) │ 3-4h │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 2 │ Mécanisme boucle côté replay OU orchestration Python depuis chat Léa │ 3-4h │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 3 │ Action launch_chrome_url pour PC Windows (subprocess.Popen Chrome.exe avec URL) │ 1h │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 4 │ Étendre templating {{var}} au by_text du click_anchor │ 30 min │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 5 │ Variabiliser le step 2 du workflow Urgence │ 30 min │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 6 │ NLP commande chat ("traite N dossiers" → action structurée) │ 1-2h │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 7 │ Action minimiser Léa │ 30 min │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 8 │ Synthèse fin de boucle envoyée au chat │ 1h │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 9 │ Re-enregistrement workflow Urgence dans config bureau │ 1h │
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
│ 10 │ Tests E2E répétés │ 1 jour │
└─────┴─────────────────────────────────────────────────────────────────────────────────┴─────────────┘
Total : ~2 jours de dev + 1 jour re-enregistrement + 1 jour de polish. Tient en 4 jours, mais sans marge pour les surprises.
Le seul vrai point d'incertitude
extract_table n'a pas d'implémentation serveur. Il faut la construire. C'est faisable proprement (EasyOCR + parsing par espaces multiples sur les colonnes IPP/Nom/Prénom) mais c'est
de l'OCR de tableau qui peut avoir des erreurs (caractères mal lus, alignement colonnes). À tester sur ta maquette pour mesurer la précision.
Plan de mitigation si OCR fragile : la maquette urgence.labs.laurinebazin.design est statique HTML — j'ai vu hier que le <table> est accessible via fetch GET. Si l'OCR rate, on peut
récupérer la liste IPP via une simple requête HTTP côté serveur (ce qui n'est pas "tricherie" puisque c'est UNE source de vérité du tableau, juste plus fiable que l'OCR). À garder en
filet de sécurité.
Décision à prendre maintenant
Option A — On code extract_table côté replay (vraie vision) : aligné feedback_100pct_visual, démontrable comme capacité visuelle de Léa. Risque OCR.
Option B — Léa lit la liste via fetch HTTP de la maquette : robuste, plus rapide à coder, mais on perd le côté "Léa lit visuellement le tableau" qui est un argument fort vs RPA
classique.
Option C — Hybride : on code extract_table (option A), si l'OCR rate > X%, on bascule auto sur fetch HTTP. Robuste ET visuel.
Pour la démo qui doit faire effet, C est probablement le bon choix. Mais c'est plus de boulot.
Mon recommandation : A pour démarrer (~3-4h), test rapide, si OCR donne > 90% de précision sur les 11 lignes c'est gagné. Sinon on bascule B avant la fin de la journée.
Tu valides A en premier essai ?
Et pour le chat Léa, tu confirmes que c'est bien rpa-agent-chat (port 5004) qu'on utilise, ou un autre bout d'interface ?
✻ Baked for 2m 50s
Ok, il faut que tu me dise pour simplifier l'arbre décisionnel pour moins de "mouvement". On pourrait avoir 1 seul champs texte dans lequel on décompose l'arbre ?
● Très bonne idée, et je la pousserais même un cran plus loin. Voici ma proposition :
Maquette simplifiée — passer de 6 anchors à 2
Avant (actuel)
- 3 textareas séparées (preuve_critere1/2/3)
- 3 checkboxes (validation par critère)
- → 6 anchors visuels à enregistrer + 5 actions à mécaniquer
Après (proposition)
- 1 grande textarea "Analyse PMSI" avec un texte structuré multi-sections
- 1 sélecteur unique de décision : 2 boutons radio [ ] UHCD [ ] Forfait
- → 2 anchors visuels + 2 actions
Format texte unique structuré (rendu par le LLM)
══════════════════════════════════════════
DÉCISION : REQUALIFICATION_HOSPITALISATION (UHCD)
Durée passage : 14h | Confiance : élevée
CRITÈRE 1 — Pathologie potentiellement évolutive : ✓ VALIDÉ
« Asthme sévère avec FC 110 bpm, SpO2 92% en air ambiant »
Patient instable nécessitant titration salbutamol IV.
CRITÈRE 2 — Surveillance médicale prolongée : ✓ VALIDÉ
« Constantes itératives H+0, H+2, H+4, H+6 »
6h de surveillance documentée par IDE.
CRITÈRE 3 — Examens / actes : ✗ NON VALIDÉ
Aucun acte technique au compte-rendu hors aérosols.
JUSTIFICATION
2/3 critères validés → REQUALIFICATION HOSPITALISATION justifiée.
══════════════════════════════════════════
Ce qu'on gagne
1. Robustesse démo : 2 anchors au lieu de 6 → 3× moins de risques de panne
2. Re-enregistrement rapide : ~10 min au lieu de 30
3. Workflow plus court : ~20 actions au lieu de 25 (et surtout plus fiable)
4. LLM plus simple : 1 sortie narrative + 1 décision booléenne au lieu de 9 champs (3 preuves + 3 booléens + 3 méta)
5. Plus lisible pour DG/DIM : un rapport structuré qu'ils peuvent lire et défendre, pas du remplissage de formulaire
6. Plus rapide visuellement : 1 frappe + 1 clic au lieu de 3 frappes + 3 clics
Ce qu'on perd
1. Distinction "case cochée par critère" : le côté formel "Léa coche chaque case" parle au métier mais c'est aussi un peu bureaucratique. Le rapport unique structuré est plus
Ce qu'on gagne
1. Robustesse démo : 2 anchors au lieu de 6 → 3× moins de risques de panne
2. Re-enregistrement rapide : ~10 min au lieu de 30
3. Workflow plus court : ~20 actions au lieu de 25 (et surtout plus fiable)
4. LLM plus simple : 1 sortie narrative + 1 décision booléenne au lieu de 9 champs (3 preuves + 3 booléens + 3 méta)
5. Plus lisible pour DG/DIM : un rapport structuré qu'ils peuvent lire et défendre, pas du remplissage de formulaire
6. Plus rapide visuellement : 1 frappe + 1 clic au lieu de 3 frappes + 3 clics
Ce qu'on perd
1. Distinction "case cochée par critère" : le côté formel "Léa coche chaque case" parle au métier mais c'est aussi un peu bureaucratique. Le rapport unique structuré est plus consultatif.
2. Rigueur formelle PMSI : si Easily Assure final exige 3 champs séparés, la maquette diverge — mais c'est un MOCKUP de démo, pas l'outil cible. À argumenter aux DG : "en intégration prod,
on remplit les 3 champs métier".
Ma recommandation : encore mieux ?
Pour pousser le côté "Léa raisonne et propose" :
Format affiché en deux parties dans la maquette simplifiée :
1. Bloc gauche (auto par Léa) : la grande textarea avec le rapport structuré
2. Bloc droit (validation médecin) : les 2 boutons radio UHCD/Forfait
Léa remplit le bloc gauche (analyse) et coche le bloc droit (décision). Le médecin DIM valide ou corrige. C'est un message démo très fort : "Léa propose, le médecin reste maître". Cela
parle aux DG (gain de temps) et au DIM (contrôle métier).
Décisions à prendre consultatif.
2. Rigueur formelle PMSI : si Easily Assure final exige 3 champs séparés, la maquette diverge — mais c'est un MOCKUP de démo, pas l'outil cible. À argumenter aux DG : "en intégration
prod, on remplit les 3 champs métier".
Ma recommandation : encore mieux ?
Pour pousser le côté "Léa raisonne et propose" :
Format affiché en deux parties dans la maquette simplifiée :
1. Bloc gauche (auto par Léa) : la grande textarea avec le rapport structuré
2. Bloc droit (validation médecin) : les 2 boutons radio UHCD/Forfait
Léa remplit le bloc gauche (analyse) et coche le bloc droit (décision). Le médecin DIM valide ou corrige. C'est un message démo très fort : "Léa propose, le médecin reste maître".
Cela parle aux DG (gain de temps) et au DIM (contrôle métier).
Décisions à prendre
1. Tu valides la simplification maquette → 1 grande textarea + 2 radio UHCD/Forfait ?
2. Tu modifies la maquette toi-même (HTML statique) ou je te propose un patch que tu appliques ?
3. Le format texte structuré que j'ai donné en exemple te convient (ASCII frame, sections claires, citations entre « ») ou tu veux autre chose (JSON pretty-print, markdown, tableau)
?
Une fois ça calé, je peux mettre à jour le prompt LLM en conséquence (réécrire t2a_decision.py pour produire 1 rapport narratif + 1 décision au lieu de 9 champs). Et toi tu
modifies/me confirmes la maquette → on capture seulement 2 anchors et le workflow devient ultra-stable.
✻ Crunched for 57s
※ recap: Objectif : démo Léa "traite N dossiers" jeudi/vendredi en bouclant sur le tableau patients. Tâche actuelle : valider la simplification maquette (1 grande textarea + 2 radios
UHCD/Forfait) au lieu de 3 textareas + 3 cases. Prochaine action : ton go/no-go sur cette simplification.
Pour la simplification de l'interface sur l'arbre de décision et ce ça t'irait ou trop complexe : [Image #3]
 [Image #3]
● OUI excellente idée — cette interface est BEAUCOUP plus forte que ce que je proposais. Et techniquement elle simplifie radicalement la chaîne.
Ce que ça change positivement
┌───────────────────────────────┬───────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────┐
│ Aspect │ Avant (3 textareas + 3 checkboxes Easily) │ Après (aiva-vision) │
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Anchors visuels à enregistrer │ 6 fragiles │ 2 robustes (textarea DPI + bouton Analyser) │
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Actions de saisie │ 3 type_text + 3 click conditionnels │ 1 type_text + 1 click │
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Risque de drift UI │ Élevé (formulaire formel) │ Faible (interface stable, contraste fort) │
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Démontrabilité │ "Léa coche des cases" (RPA classique++) │ "Léa lit, analyse, propose une décision" (différenciation forte vs Evanov) │
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Lisibilité pour DG/DIM │ Formulaire administratif │ Synthèse intelligente avec justification + critères + confiance + latence │
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Crédibilité métier │ "C'est juste de la saisie" │ "C'est une vraie analyse motivée" │
└───────────────────────────────┴───────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────┘
Et plus subtil — le bandeau "Vérité-terrain"
Vérité-terrain : FORFAIT_URGENCE — concordance OK
→ ÇA, c'est génial pour la démo. Carvella va te demander "comment je sais que Léa ne se trompe pas ?". Tu pointes : "On a une vérité-terrain par dossier, on mesure la concordance.
Sur les 11 dossiers qu'on a benchés, on est à X% d'accuracy".
C'est l'argument anti-FUD parfait.
Le scénario démo redessiné
Minute 0 : Dom dans le chat Léa
« Léa, traite-moi 3 dossiers urgences »
Minute 0:10 : Léa se réduit. Chrome s'ouvre sur Easily Assure.
Minute 0:30 : Léa lit le tableau patients (extract_table)
et énumère : « 25003284, 25003362, 25003364 »
Minute 1 : pour chaque dossier :
- Ouvrir le dossier (click)
- Naviguer onglets, lire (extract_text × 6)
- Bascule sur aiva-vision
- Coller le DPI consolidé (1 type_text)
- Cliquer "Analyser" (1 click)
- Aiva affiche la décision en 3s
- Léa capture (screenshot d'évidence) + note la décision
Minute 4 : retour au chat Léa
« Terminé.
- Dossier 25003284 (MOREL, asthme) : FORFAIT URGENCES — confiance élevée
- Dossier 25003362 (LAFFONT, intoxication) : FORFAIT URGENCES — confiance moyenne
- Dossier 25003364 (LEROY, dyspnée) : REQUALIFICATION HOSPITALISATION (UHCD)
→ valorisation +1500€ (forfait 50€ → MCO 1k-5k€) »
C'est propre, démontrable, court, et surtout chaque action est fiable (interface aiva-vision stable).
Mes questions pour finaliser
1. Cette interface aiva-vision est-elle déjà déployée et accessible depuis le PC Windows ? Quelle URL ?
2. Le bouton "Analyser" déclenche le même backend t2a_decision.py côté serveur ou un autre endpoint ?
3. Le DPI à coller : tu attends quel format ? (concaténation brute des t0+t1+t2+t3+t5+t6 que les extract_text produisent ?)
4. Vérité-terrain : d'où vient-elle ? Stockée par dossier dans la maquette Easily ? Ou en base à part ?
5. Question d'orchestration : après l'analyse aiva, on revient sur Easily pour saisir la décision (geste "Léa boucle la boucle" administrativement) ou on reste sur aiva (Léa propose,
le médecin DIM saisira manuellement après) ? Les deux marchent, c'est un choix de narration.
Et la simplification qui découle
Avec cette interface, je peux supprimer de mon plan :
- ❌ Re-enregistrer 6 anchors → seulement 2 désormais (gain : 30 min vs 1h)
- ❌ Étendre templating au by_text (plus crucial puisque l'analyse se fait ailleurs)
- ❌ La complexité "3 cases conditionnelles"
Ce qui reste à faire (revu à la baisse) :
┌─────┬─────────────────────────────────────────────────────────────────┬─────────────────────────┐
│ # │ Brique │ Coût │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 1 │ extract_table côté serveur replay (OCR + parsing) │ 3-4h │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 2 │ Boucle sur N IPP (orchestration Python OU mécanisme replay) │ 3h │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 3 │ Action launch_chrome_url pour PC Windows │ 1h │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 4 │ Variabiliser le step "sélection patient" │ 30 min │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 5 │ NLP commande chat "traite N dossiers" │ 1-2h │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 6 │ Action minimiser Léa │ 30 min │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 7 │ Workflow "lecture aiva-vision après analyse" (capture décision) │ 1h │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 8 │ Synthèse fin de boucle vers chat │ 1h │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 9 │ Re-enregistrement workflow Urgence simplifié │ 30 min (au lieu de 1h+) │
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
│ 10 │ Tests E2E │ 1 jour │
└─────┴─────────────────────────────────────────────────────────────────┴─────────────────────────┘
Total : ~1.5 jours de dev + 0.5 jour re-enregistrement + 1 jour test. 2 jours de marge sur les 4 pour les imprévus. Bien plus confortable.
OK pour partir là-dessus ? Réponds aux 5 questions et je peux commencer dès que tu valides.
✻ Baked for 1m 14s
Ok, pour moi ! De mon coté, je m'ocuppe de reprendre l'interface. Question, pourquoi un nlp sur léa ? ça ne risque pas d'alourdir et ralentir le traitement ?
● Excellente question. Tu as raison de challenger.
"NLP" est un mot un peu fort pour ce qu'on a vraiment besoin
Pour interpréter « Léa, traite-moi 3 dossiers », on a 3 niveaux de complexité possibles :
┌──────────────────────────────────────────────┬───────────────────────────────┬───────────────────────────────────────────────────────────────┬─────────────────────────────────┐
│ Approche │ Coût │ Robustesse phrasing │ Effet démo │
├──────────────────────────────────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────────────┼─────────────────────────────────┤
│ A. Regex simple │ 0 ms, 0 GPU │ Comprend les variations clés (traite N, code N dossiers, │ Suffisant si Dom dit la phrase │
│ │ │ etc.) si on les anticipe │ prévue │
├──────────────────────────────────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────────────┼─────────────────────────────────┤
│ B. Mini-LLM local (qwen2.5:0.5b ou │ ~500 ms, faible GPU │ Très robuste à toute variation │ Crédible "on parle vraiment à │
│ llama3.2:1b, ~500 Mo) │ │ │ Léa" │
├──────────────────────────────────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────────────┼─────────────────────────────────┤
│ C. Gros LLM (qwen2.5:7b déjà chargé pour │ 1-3 s, conflit GPU avec │ Excellente │ Sur-dimensionné, risque de swap │
│ t2a_decision) │ t2a_decision │ │ GPU │
└──────────────────────────────────────────────┴───────────────────────────────┴───────────────────────────────────────────────────────────────┴─────────────────────────────────┘
C est exclu — ça reproduirait le problème de hier soir (t2a_decision LLM + NLP LLM = swap VRAM, ralentissement).
Ma recommandation : A (regex) pour démo + bouton de secours
A — Regex :
# Patterns reconnus
"traite (\d+) dossiers?"
"code(?:-moi)? (?:les )?(\d+) (?:premiers )?dossiers?"
"(?:peux-tu )?(?:traiter|coder) (\d+) dossiers?"
Si la regex matche → action lancée immédiatement. Si elle matche pas → Léa répond « Je n'ai pas compris. Tu peux dire par exemple : "traite 3 dossiers" ».
Coût : ~0 ms, 0 GPU, ultra fiable.
Bouton de secours dans l'UI chat : à côté du champ texte, un bouton dédié [ Traiter N dossiers ▼ ] (avec un select 1/3/5/tous). Si le jour J Dom est stressé et tape mal, il a
toujours le bouton qui marche. Ceinture + bretelles.
Pour la démo, ça donne
[Dom dans le chat] : Léa, traite-moi 3 dossiers
[Léa] : Compris, je traite 3 dossiers... (commence à bosser)
Visuellement : pareil que si c'était un gros LLM. Mais 0 latence sur cette étape, 0 risque de mauvais parsing.
Et si tu veux vraiment l'effet "Léa comprend tout" pour la démo
Option B (mini-LLM) est viable : qwen2.5:0.5b ou llama3.2:1b font ~500 Mo de VRAM, latence ~500 ms, pas en conflit avec t2a_decision (qui est sur qwen2.5:7b). Mais ça ajoute :
- Une dépendance modèle de plus à gérer
- Une étape de classification d'intention (parser le naturel en JSON action)
- Un risque d'erreur d'interprétation que la regex n'a pas
Pour la démo, A est largement suffisant et te donne 100% de fiabilité. On garde B en bonus si on a 2h en J-1.
Et donc
Tu confirmes A (regex + bouton secours) ? C'est ~30 min de code au lieu de 1-2h, ça allège le plan.
Une fois que tu reviens avec aiva-vision côté interface, j'ai tout pour démarrer côté code. Réponds-moi en parallèle aux 5 questions techniques de mon précédent message (URL aiva,
format DPI, vérité-terrain, etc.) quand tu peux.
✻ Worked for 51s
on va essayer B pour mini llm. La question concernant l'interface : "Cette interface aiva-vision est-elle déjà déployée et accessible depuis le PC Windows ? Quelle URL ?" On parle
bien de celle-la : [Image #4] ou c'est de celle là : [Image #5] Pour le dpi à coller, je te laisse le choix le plus "facileé. Mais [Image #6] ça serait pas trop mal. Attention, pour
qu'il n'y ai pas de confusion, le dpi doit être lu et traiter paendant la démo. On est d'accord ? ça devrait répondre à tes autres questions ?
 [Image #4]
 [Image #5]
 [Image #6]
● C'est limpide, on tient la chaîne :
Easily (image 4) ──[Léa lit DPI via extract_text]──▶ aiva-vision (image 5)
↑ ↑
Source des données Cerveau d'analyse + affichage
Et oui sur le PENDANT — c'est ce qui fait la valeur démo. La promesse à Carvella & DG :
- Léa lit le dossier en direct (extraits OCR du DPI) → reformule → soumet à analyse → décision motivée.
- Pas de pré-remplissage caché. Tout est public à l'écran.
3 questions résiduelles avant de coder
1. URL de aiva-vision ? (j'imagine aiva.labs.laurinebazin.design ou similaire, mais je veux la vraie pour aller voir le DOM des 2 anchors textarea + bouton)
2. Vérité-terrain : sur ton image 5 c'est affiché concordance OK — d'où vient cette info ? Stockée dans aiva-vision (par dossier, étiquettes pré-faites) ou calculée à la volée ?
(Important : si la vérité-terrain est cachée dans aiva-vision, Léa peut la voir et un sceptique pourrait crier triche.)
3. Une fois l'analyse faite côté aiva : Léa s'arrête là (capture la décision et passe au dossier suivant) ou elle revient sur Easily pour saisir la décision dans le formulaire Codage
(boucle administrative complète) ?
- Stop à aiva = démo plus courte, message clair "Léa propose"
- Retour Easily = démo plus longue, message "Léa va jusqu'au bout"
- Mon avis : stop à aiva pour la démo (plus court, plus impactant). On dira "en intégration, Léa pourrait bien sûr remplir Easily aussi".
Sur le DPI à coller — choix simple et fidèle
Le format de l'image 6 est exactement ce que la concaténation brute des extract_text produit (Easily a déjà ses sections "Motif", "Constantes", "Examen", "Bio", etc., l'OCR retourne
ce texte tel quel). Donc :
dpi_complet = f"{t0}\n{t1}\n{t2}\n{t3}\n{t5}\n{t6}"
Pas besoin de LLM de reformulation, pas d'étape supplémentaire, fidèle à ce que dit le dossier. Si en pratique c'est mal mis en page, on insère un step minimal (regex de nettoyage
des doubles sauts de ligne).
Sur le mini-LLM B
Bon choix pour la démo. Mes candidats :
┌──────────────┬────────┬─────────────┬─────────┬──────────────────────────┐
│ Modèle │ Taille │ Latence │ VRAM │ Robustesse │
├──────────────┼────────┼─────────────┼─────────┼──────────────────────────┤
│ llama3.2:1b │ 1.3 GB │ ~300-500 ms │ ~1.5 GB │ Très bonne en français │
├──────────────┼────────┼─────────────┼─────────┼──────────────────────────┤
│ qwen2.5:0.5b │ 400 MB │ ~200 ms │ ~800 MB │ Bonne, plus léger │
├──────────────┼────────┼─────────────┼─────────┼──────────────────────────┤
│ gemma3:1b │ 800 MB │ ~400 ms │ ~1.2 GB │ Excellente compréhension │
└──────────────┴────────┴─────────────┴─────────┴──────────────────────────┘
Mon top : llama3.2:1b — meilleur compromis fr / latence / robustesse. ~500 ms pour parser "Léa, traite-moi 3 dossiers" → {action: "process_patients", count: 3, order: "first"}.
Imperceptible, et surtout pas en conflit avec qwen2.5:7b (t2a_decision) car charges séparées.
Tu veux que je parte sur llama3.2:1b pour le NLP, ou tu as une autre préférence ?
Plan d'action immédiat (dès que tu réponds aux 3 questions)
1. Je vais voir aiva-vision avec Chrome MCP pour cartographier les 2 anchors
2. Je pull llama3.2:1b côté Ollama si pas déjà là
3. Je commence par extract_table (3-4h) — la brique la plus risquée à dérisquer en premier
Toi en parallèle : tu reprends l'interface (modifier la maquette si besoin) et tu me confirmes l'URL aiva.
✻ Sautéed for 57s
https://urgence.labs.laurinebazin.design/index.html. Concordance ok est géré comme le reste à la volé, tu le vera lors de l'exploration du dom. Pour l'intégration, je pense que l'on
pourrait faire boucle administrative complète. ça montre que l'on sait écrire aussi Pour le modéle, je pense qu'il faut faire des tests non ? tu peux dédier un agent dessus ? Si
les modéles ne sont pas présent telecharge les. c'est bon pour toi ? jepeux aller manger ?
● Vas-y, bon appétit ! Je m'occupe de tout pendant ce temps.
Mon programme pendant que tu manges :
1. Bench NLP via agent dédié (en background) — comparer llama3.2:1b, qwen2.5:0.5b, gemma3:1b sur des commandes types français + rapport comparatif
2. Cartographier le DOM aiva-vision (textarea, bouton, zone décision, vérité-terrain)
3. Préparer le terrain pour qu'on enchaîne sur le code à ton retour
● Bash(ollama list 2>&1 | head -30)
 Running…
✻ Hatching… (22s · ↓ 752 tokens)
⎿  ◻ Diagnostiquer le pre-click qui ne prend pas le focus textarea
✔ Récupérer agent.log Windows pour replay 15:56
✔ Trouver le revert type_text x_pct/y_pct dans dag_execute.py
✔ Présenter diagnostic complet + recommandation chiffrée
✔ Élucider pourquoi 3 dispatch serveur, 1 reçu agent (3ᵉ)
… +8 completed

View File

@@ -0,0 +1,643 @@
"""
Bridge entre les workflows Lea (core) et PM4Py pour le process mining.
Genere des diagrammes BPMN et KPIs depuis les traces Shadow.
Usage:
from core.analytics.process_mining_bridge import (
sessions_to_event_log,
workflow_to_event_log,
discover_bpmn,
compute_kpis,
)
# Depuis des sessions JSONL brutes
df = sessions_to_event_log(sessions_data)
result = discover_bpmn(df, output_dir="data/analytics/bpmn")
kpis = compute_kpis(df)
# Depuis un workflow core (dict JSON)
df = workflow_to_event_log(workflow_dict)
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
logger = logging.getLogger(__name__)
# ---- Import conditionnel PM4Py -----------------------------------------
try:
import pm4py
PM4PY_AVAILABLE = True
except ImportError:
PM4PY_AVAILABLE = False
logger.warning("pm4py non installe -- le process mining est desactive")
def _sanitize_label(label: str) -> str:
"""
Supprime les caracteres de controle (0x00-0x1F sauf tab/newline)
qui sont invalides en XML et font planter PM4Py.
"""
return "".join(
c if c in ("\t", "\n", "\r") or ord(c) >= 0x20 else f"<0x{ord(c):02x}>"
for c in label
)
# ---- Types d'evenements a ignorer (bruit) --------------------------------
_NOISE_EVENT_TYPES = frozenset({
"heartbeat",
"action_result",
"screenshot",
})
# Types d'evenements significatifs pour le process mining
_RELEVANT_EVENT_TYPES = frozenset({
"mouse_click",
"text_input",
"key_press",
"key_combo",
"window_focus_change",
})
# ===========================================================================
# Conversion sessions JSONL -> event log PM4Py
# ===========================================================================
def _build_activity_label(event: dict) -> Optional[str]:
"""
Construit un label d'activite lisible depuis un event JSONL brut.
Regles :
- mouse_click -> "Clic - <app_name> (<window_title tronque>)"
- text_input -> "Saisie '<text>' - <app_name>"
- key_press -> "Touche <key> - <app_name>"
- key_combo -> "Raccourci <keys> - <app_name>"
- window_focus_change -> "Fenetre <to.title> (<to.app_name>)"
Tous les labels sont sanitises pour supprimer les caracteres de controle
(ex: \\x13 pour Ctrl+S) qui sont invalides en XML/BPMN.
"""
evt = event.get("event", event)
etype = evt.get("type", "")
if etype in _NOISE_EVENT_TYPES:
return None
# Extraction fenetre
window = evt.get("window", {})
app_name = window.get("app_name", "inconnu")
win_title = window.get("title", "")
# Tronquer le titre a 40 caracteres
short_title = (win_title[:40] + "...") if len(win_title) > 40 else win_title
label: Optional[str] = None
if etype == "mouse_click":
label = f"Clic - {app_name} ({short_title})"
elif etype == "text_input":
text = evt.get("text", "")
# Tronquer le texte a 20 caracteres pour rester lisible
short_text = (text[:20] + "...") if len(text) > 20 else text
label = f"Saisie '{short_text}' - {app_name}"
elif etype == "key_press":
key = evt.get("key", "?")
label = f"Touche {key} - {app_name}"
elif etype == "key_combo":
keys = evt.get("keys", [])
combo = "+".join(str(k) for k in keys)
label = f"Raccourci {combo} - {app_name}"
elif etype == "window_focus_change":
to_info = evt.get("to", {})
if not to_info:
return None
to_title = to_info.get("title", "?")
to_app = to_info.get("app_name", "?")
label = f"Fenetre {to_title} ({to_app})"
else:
# Types non reconnus : label generique
label = f"{etype} - {app_name}"
return _sanitize_label(label) if label else None
def _extract_timestamp(event: dict) -> Optional[float]:
"""Extrait le timestamp unix depuis un event JSONL."""
# Le timestamp peut etre au niveau racine ou dans event.timestamp
evt = event.get("event", event)
ts = evt.get("timestamp") or event.get("timestamp")
if ts is not None:
return float(ts)
# Fallback sur le champ 't' (format simplifie)
t = evt.get("t") or event.get("t")
if t is not None:
return float(t)
return None
def sessions_to_event_log(
sessions_data: List[dict],
deduplicate_windows: bool = True,
) -> pd.DataFrame:
"""
Convertit des traces de sessions brutes (events JSONL) en event log PM4Py.
Chaque event pertinent devient une ligne :
- case:concept:name = session_id
- concept:name = label d'activite (ex: "Clic - Notepad.exe (Bloc-notes)")
- time:timestamp = timestamp UTC
Args:
sessions_data: liste de dicts, chaque dict est une ligne JSONL parsee.
deduplicate_windows: si True, supprime les window_focus_change
consecutifs vers la meme fenetre (bruit typique de Windows).
Returns:
DataFrame pret pour PM4Py.
"""
rows: List[Dict[str, Any]] = []
# Regrouper par session_id pour le deduplication
sessions: Dict[str, List[dict]] = {}
for event in sessions_data:
sid = event.get("session_id", "unknown")
sessions.setdefault(sid, []).append(event)
for sid, events in sessions.items():
# Trier par timestamp
events.sort(key=lambda e: _extract_timestamp(e) or 0.0)
last_window_label: Optional[str] = None
for event in events:
label = _build_activity_label(event)
if label is None:
continue
ts = _extract_timestamp(event)
if ts is None:
continue
# Deduplication des changements de fenetre consecutifs
evt = event.get("event", event)
if deduplicate_windows and evt.get("type") == "window_focus_change":
if label == last_window_label:
continue
last_window_label = label
else:
last_window_label = None
rows.append({
"case:concept:name": sid,
"concept:name": label,
"time:timestamp": pd.Timestamp(
datetime.fromtimestamp(ts, tz=timezone.utc)
),
"event_type": evt.get("type", ""),
"app_name": evt.get("window", {}).get("app_name", ""),
})
if not rows:
logger.warning("Aucun evenement pertinent trouve dans les sessions")
return pd.DataFrame(columns=[
"case:concept:name",
"concept:name",
"time:timestamp",
"event_type",
"app_name",
])
df = pd.DataFrame(rows)
df = df.sort_values(["case:concept:name", "time:timestamp"]).reset_index(drop=True)
logger.info(
"Event log cree : %d evenements, %d sessions, %d activites distinctes",
len(df),
df["case:concept:name"].nunique(),
df["concept:name"].nunique(),
)
return df
# ===========================================================================
# Conversion workflow core (dict JSON) -> event log PM4Py
# ===========================================================================
def workflow_to_event_log(workflow_dict: dict) -> pd.DataFrame:
"""
Convertit un workflow core (dict JSON) en DataFrame PM4Py.
Utilise les nodes et edges pour reconstituer une trace.
Chaque chemin du entry_node vers un end_node = un case.
Mapping :
- case:concept:name = workflow_id + suffixe de chemin
- concept:name = node.name
- time:timestamp = deduced from edge stats ou created_at
"""
wf_id = workflow_dict.get("workflow_id", "wf_unknown")
nodes = {n["node_id"]: n for n in workflow_dict.get("nodes", [])}
edges = workflow_dict.get("edges", [])
entry_nodes = workflow_dict.get("entry_nodes", [])
created_at = workflow_dict.get("created_at", datetime.now(timezone.utc).isoformat())
if not nodes or not edges:
logger.warning("Workflow vide ou sans edges : %s", wf_id)
return pd.DataFrame(columns=[
"case:concept:name",
"concept:name",
"time:timestamp",
])
# Construire un graphe d'adjacence
adjacency: Dict[str, List[dict]] = {}
for edge in edges:
from_node = edge.get("from_node") or edge.get("source_node", "")
adjacency.setdefault(from_node, []).append(edge)
# Parcours DFS pour trouver les chemins (limites a eviter l'explosion)
MAX_PATHS = 100
paths: List[List[str]] = []
def _dfs(current: str, path: List[str], visited: set) -> None:
if len(paths) >= MAX_PATHS:
return
if current in visited:
# Boucle detectee, sauvegarder le chemin tel quel
paths.append(path[:])
return
visited.add(current)
path.append(current)
outgoing = adjacency.get(current, [])
if not outgoing:
# End node
paths.append(path[:])
else:
for edge in outgoing:
to_node = edge.get("to_node") or edge.get("target_node", "")
if to_node:
_dfs(to_node, path, visited)
path.pop()
visited.discard(current)
for entry in entry_nodes:
if entry in nodes:
_dfs(entry, [], set())
# Si pas d'entry nodes, essayer tous les nodes sans edges entrants
if not paths:
target_nodes = set()
for edge in edges:
to_node = edge.get("to_node") or edge.get("target_node", "")
target_nodes.add(to_node)
root_nodes = [nid for nid in nodes if nid not in target_nodes]
for root in root_nodes[:3]:
_dfs(root, [], set())
# Construire le DataFrame
rows: List[Dict[str, Any]] = []
try:
base_time = pd.Timestamp(datetime.fromisoformat(created_at))
except (ValueError, TypeError):
base_time = pd.Timestamp(datetime.now(timezone.utc))
for i, path in enumerate(paths):
case_id = f"{wf_id}_path_{i}"
for step_idx, node_id in enumerate(path):
node = nodes.get(node_id, {})
rows.append({
"case:concept:name": case_id,
"concept:name": node.get("name", node_id),
"time:timestamp": base_time + pd.Timedelta(seconds=step_idx),
})
df = pd.DataFrame(rows)
if not df.empty:
df = df.sort_values(["case:concept:name", "time:timestamp"]).reset_index(drop=True)
logger.info(
"Event log depuis workflow : %d evenements, %d chemins",
len(df), len(paths),
)
return df
# ===========================================================================
# Decouverte BPMN
# ===========================================================================
def discover_bpmn(
event_log_df: pd.DataFrame,
output_dir: str = "data/analytics/bpmn",
name: str = "process",
) -> dict:
"""
Decouvre un modele BPMN depuis un event log via Inductive Miner.
Args:
event_log_df: DataFrame au format PM4Py.
output_dir: repertoire de sortie pour les fichiers generes.
name: prefixe pour les noms de fichiers.
Returns:
{
'bpmn_xml_path': str,
'bpmn_image_path': str,
'petri_net_image_path': str,
'dfg_image_path': str,
'stats': {
'activities': int,
'variants': int,
'cases': int,
}
}
"""
if not PM4PY_AVAILABLE:
raise ImportError("pm4py n'est pas installe. Installez-le : pip install pm4py")
if event_log_df.empty:
raise ValueError("Event log vide, impossible de decouvrir un BPMN")
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# Decouverte BPMN par Inductive Miner
bpmn_model = pm4py.discover_bpmn_inductive(event_log_df)
# Export BPMN XML
bpmn_xml_path = str(out / f"{name}.bpmn")
try:
pm4py.write_bpmn(bpmn_model, bpmn_xml_path)
except Exception as e:
# PM4Py layout peut echouer avec des labels contenant des caracteres
# speciaux (accents, guillemets, etc.). Fallback : export via l'exporter
# interne sans layout.
logger.warning("Layout BPMN echoue (%s), export sans layout", e)
from pm4py.objects.bpmn.exporter import exporter as bpmn_exporter
bpmn_exporter.apply(bpmn_model, bpmn_xml_path)
logger.info("BPMN XML exporte : %s", bpmn_xml_path)
# Export image BPMN (PNG) — grande taille pour lisibilité
bpmn_image_path = str(out / f"{name}_bpmn.png")
try:
from pm4py.visualization.bpmn import visualizer as bpmn_vis
gviz = bpmn_vis.apply(bpmn_model, parameters={
"rankdir": "TB",
"font_size": "12",
})
gviz.graph_attr["dpi"] = "150"
gviz.graph_attr["size"] = "40,20!"
gviz.graph_attr["rankdir"] = "TB"
gviz.render(filename=bpmn_image_path.replace(".png", ""), format="png", cleanup=True)
logger.info("BPMN PNG exporte : %s", bpmn_image_path)
except Exception as e:
logger.warning("BPMN image fallback : %s", e)
try:
pm4py.save_vis_bpmn(bpmn_model, bpmn_image_path)
except Exception:
bpmn_image_path = None
# DFG (Directly-Follows Graph) — grande taille
dfg_image_path = str(out / f"{name}_dfg.png")
try:
from pm4py.visualization.dfg import visualizer as dfg_vis
dfg, sa, ea = pm4py.discover_dfg(event_log_df)
gviz = dfg_vis.apply(dfg, activities_count=sa, parameters={
"start_activities": sa,
"end_activities": ea,
"rankdir": "TB",
"font_size": "11",
})
gviz.graph_attr["dpi"] = "150"
gviz.graph_attr["size"] = "40,20!"
gviz.graph_attr["rankdir"] = "TB"
gviz.render(filename=dfg_image_path.replace(".png", ""), format="png", cleanup=True)
logger.info("DFG PNG exporte : %s", dfg_image_path)
except Exception as e:
logger.warning("DFG image fallback : %s", e)
try:
pm4py.save_vis_dfg(*pm4py.discover_dfg(event_log_df), file_path=dfg_image_path)
except Exception:
dfg_image_path = None
# Petri net via Inductive Miner (pour visualisation alternative)
petri_image_path = str(out / f"{name}_petri.png")
try:
net, im, fm = pm4py.discover_petri_net_inductive(event_log_df)
pm4py.save_vis_petri_net(net, im, fm, file_path=petri_image_path)
logger.info("Petri net PNG exporte : %s", petri_image_path)
except Exception as e:
logger.warning("Impossible de generer le Petri net : %s", e)
petri_image_path = None
# Stats de base
variants = pm4py.get_variants(event_log_df)
n_cases = event_log_df["case:concept:name"].nunique()
n_activities = event_log_df["concept:name"].nunique()
result = {
"bpmn_xml_path": bpmn_xml_path,
"bpmn_image_path": bpmn_image_path,
"petri_net_image_path": petri_image_path,
"dfg_image_path": dfg_image_path,
"stats": {
"activities": n_activities,
"variants": len(variants),
"cases": n_cases,
},
}
logger.info("Decouverte BPMN terminee : %s", result["stats"])
return result
# ===========================================================================
# KPIs de process mining
# ===========================================================================
def compute_kpis(event_log_df: pd.DataFrame) -> dict:
"""
Calcule les KPIs de process mining.
Returns:
{
'total_cases': int,
'total_events': int,
'unique_activities': int,
'variants_count': int,
'variants_top5': list,
'avg_case_duration_seconds': float,
'median_case_duration_seconds': float,
'avg_events_per_case': float,
'activity_stats': {
'<activity_name>': {
'count': int,
'avg_duration_seconds': float,
'min_duration_seconds': float,
'max_duration_seconds': float,
}
},
'bottlenecks': [...], # top 3 activites les plus lentes
'app_distribution': { '<app_name>': int },
}
"""
if event_log_df.empty:
return {
"total_cases": 0,
"total_events": 0,
"unique_activities": 0,
"variants_count": 0,
"variants_top5": [],
"avg_case_duration_seconds": 0.0,
"median_case_duration_seconds": 0.0,
"avg_events_per_case": 0.0,
"activity_stats": {},
"bottlenecks": [],
"app_distribution": {},
}
df = event_log_df.copy()
# ---- Metriques globales ----
total_cases = df["case:concept:name"].nunique()
total_events = len(df)
unique_activities = df["concept:name"].nunique()
# ---- Variantes (PM4Py) ----
if PM4PY_AVAILABLE:
variants = pm4py.get_variants(df)
variants_count = len(variants)
# Top 5 variantes par frequence
sorted_variants = sorted(variants.items(), key=lambda x: x[1], reverse=True)
variants_top5 = [
{"variant": " -> ".join(v), "count": c}
for v, c in sorted_variants[:5]
]
else:
variants_count = 0
variants_top5 = []
# ---- Duree par case ----
case_durations: List[float] = []
for _case_id, group in df.groupby("case:concept:name"):
ts = group["time:timestamp"]
if len(ts) >= 2:
duration = (ts.max() - ts.min()).total_seconds()
case_durations.append(duration)
avg_case_dur = float(pd.Series(case_durations).mean()) if case_durations else 0.0
median_case_dur = float(pd.Series(case_durations).median()) if case_durations else 0.0
avg_events_per_case = total_events / total_cases if total_cases > 0 else 0.0
# ---- Stats par activite ----
activity_stats: Dict[str, Dict[str, Any]] = {}
# Calculer la duree entre chaque evenement et le suivant dans le meme case
df_sorted = df.sort_values(["case:concept:name", "time:timestamp"])
df_sorted["next_timestamp"] = df_sorted.groupby("case:concept:name")[
"time:timestamp"
].shift(-1)
df_sorted["duration_to_next"] = (
df_sorted["next_timestamp"] - df_sorted["time:timestamp"]
).dt.total_seconds()
for activity, grp in df_sorted.groupby("concept:name"):
durations = grp["duration_to_next"].dropna()
# Filtrer les durees aberrantes (> 5 min = probablement une pause)
durations = durations[durations <= 300]
stats: Dict[str, Any] = {
"count": len(grp),
"avg_duration_seconds": round(float(durations.mean()), 2) if len(durations) > 0 else 0.0,
"min_duration_seconds": round(float(durations.min()), 2) if len(durations) > 0 else 0.0,
"max_duration_seconds": round(float(durations.max()), 2) if len(durations) > 0 else 0.0,
}
activity_stats[activity] = stats
# ---- Goulots d'etranglement (top 3 activites les plus lentes) ----
bottlenecks = sorted(
[
{"activity": act, "avg_duration_seconds": s["avg_duration_seconds"]}
for act, s in activity_stats.items()
if s["avg_duration_seconds"] > 0
],
key=lambda x: x["avg_duration_seconds"],
reverse=True,
)[:3]
# ---- Distribution par application ----
app_distribution: Dict[str, int] = {}
if "app_name" in df.columns:
app_distribution = df["app_name"].value_counts().to_dict()
return {
"total_cases": total_cases,
"total_events": total_events,
"unique_activities": unique_activities,
"variants_count": variants_count,
"variants_top5": variants_top5,
"avg_case_duration_seconds": round(avg_case_dur, 2),
"median_case_duration_seconds": round(median_case_dur, 2),
"avg_events_per_case": round(avg_events_per_case, 1),
"activity_stats": activity_stats,
"bottlenecks": bottlenecks,
"app_distribution": app_distribution,
}
# ===========================================================================
# Helpers : chargement sessions JSONL
# ===========================================================================
def load_jsonl_session(jsonl_path: str) -> List[dict]:
"""
Charge un fichier live_events.jsonl en liste de dicts.
Ignore les lignes vides ou invalides.
"""
events: List[dict] = []
path = Path(jsonl_path)
if not path.exists():
raise FileNotFoundError(f"Fichier JSONL introuvable : {jsonl_path}")
with open(path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
except json.JSONDecodeError as e:
logger.warning("Ligne %d invalide dans %s : %s", line_num, jsonl_path, e)
logger.info("Charge %d evenements depuis %s", len(events), jsonl_path)
return events
def load_multiple_sessions(session_dirs: List[str]) -> List[dict]:
"""
Charge plusieurs sessions depuis leurs repertoires.
Cherche un fichier live_events.jsonl dans chaque repertoire.
"""
all_events: List[dict] = []
for session_dir in session_dirs:
jsonl_path = Path(session_dir) / "live_events.jsonl"
if jsonl_path.exists():
all_events.extend(load_jsonl_session(str(jsonl_path)))
else:
logger.warning("Pas de live_events.jsonl dans %s", session_dir)
return all_events

View File

@@ -0,0 +1,60 @@
"""
Détection rapide de changement d'écran via perceptual hash (pHash).
Utilise imagehash pour calculer un hash perceptuel par screenshot.
La distance de Hamming entre deux hashes indique le degré de changement :
- < 5 : même écran (bruit, curseur déplacé)
- 5-15 : changement mineur (scroll, popup, champ rempli)
- > 15 : nouvel écran (nouvelle fenêtre, navigation)
Performance : ~15ms par hash sur CPU pour des screenshots 2560x1600.
"""
from PIL import Image
import imagehash
from typing import Tuple, Optional
from enum import Enum
class ScreenChangeLevel(Enum):
SAME = "same" # distance < 5
MINOR = "minor" # 5 <= distance < 15
MAJOR = "major" # distance >= 15
def compute_phash(image: Image.Image, hash_size: int = 8) -> imagehash.ImageHash:
"""Calcule le pHash d'une image PIL."""
return imagehash.phash(image, hash_size=hash_size)
def compare_screenshots(img1: Image.Image, img2: Image.Image, hash_size: int = 8) -> Tuple[int, ScreenChangeLevel]:
"""
Compare deux screenshots et retourne la distance + le niveau de changement.
Returns:
(distance, level) — distance de Hamming et niveau de changement
"""
h1 = compute_phash(img1, hash_size)
h2 = compute_phash(img2, hash_size)
distance = h1 - h2
if distance < 5:
level = ScreenChangeLevel.SAME
elif distance < 15:
level = ScreenChangeLevel.MINOR
else:
level = ScreenChangeLevel.MAJOR
return distance, level
def compare_hashes(hash1: imagehash.ImageHash, hash2: imagehash.ImageHash) -> Tuple[int, ScreenChangeLevel]:
"""Compare deux hashes pré-calculés."""
distance = hash1 - hash2
if distance < 5:
level = ScreenChangeLevel.SAME
elif distance < 15:
level = ScreenChangeLevel.MINOR
else:
level = ScreenChangeLevel.MAJOR
return distance, level

View File

View File

@@ -0,0 +1,191 @@
"""
Orchestrateur VRAM — gère le chargement/déchargement des modèles selon le mode.
Deux modes :
- SHADOW : streaming server + agent_chat actifs, VLM raisonnement déchargé
- REPLAY : VLM raisonnement (qwen2.5vl:7b) chargé, services non-essentiels stoppés
Bascule automatique ou manuelle selon le contexte.
"""
import logging
import os
import subprocess
import time
from enum import Enum
from typing import Optional
logger = logging.getLogger(__name__)
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
REASONING_MODEL = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
MIN_VRAM_FOR_REASONING = 5.0 # Go minimum pour charger le modèle de raisonnement
class VRAMMode(Enum):
SHADOW = "shadow"
REPLAY = "replay"
class VRAMOrchestrator:
"""Gère la VRAM pour éviter les conflits entre modèles."""
def __init__(self):
self._current_mode: Optional[VRAMMode] = None
self._stopped_services: list = []
def get_free_vram_gb(self) -> float:
"""Retourne la VRAM libre en Go."""
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=memory.free", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5
)
return float(result.stdout.strip()) / 1024
except Exception:
return 0.0
def get_used_vram_gb(self) -> float:
"""Retourne la VRAM utilisée en Go."""
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5
)
return float(result.stdout.strip()) / 1024
except Exception:
return 0.0
def switch_to_replay(self) -> bool:
"""Bascule en mode replay : libère la VRAM pour le VLM de raisonnement.
1. Stoppe les services non-essentiels (agent_chat)
2. Redémarre Ollama pour libérer les modèles chargés
3. Précharge le modèle de raisonnement
"""
if self._current_mode == VRAMMode.REPLAY:
logger.info("Déjà en mode REPLAY")
return True
logger.info("Bascule en mode REPLAY...")
# Stopper agent_chat si il tourne
try:
result = subprocess.run(
["pgrep", "-f", "agent_chat"],
capture_output=True, text=True, timeout=5
)
pids = result.stdout.strip().split('\n')
for pid in pids:
if pid.strip():
subprocess.run(["kill", pid.strip()], timeout=5)
self._stopped_services.append(("agent_chat", pid.strip()))
logger.info(f"agent_chat stoppé (PID {pid.strip()})")
except Exception as e:
logger.debug(f"Pas d'agent_chat à stopper: {e}")
# Redémarrer Ollama pour libérer la mémoire
try:
subprocess.run(["sudo", "systemctl", "restart", "ollama"],
timeout=10, check=True)
time.sleep(2)
logger.info("Ollama redémarré")
except Exception as e:
logger.warning(f"Impossible de redémarrer Ollama: {e}")
# Vérifier la VRAM disponible
free = self.get_free_vram_gb()
logger.info(f"VRAM libre: {free:.1f} Go")
if free < MIN_VRAM_FOR_REASONING:
logger.warning(f"VRAM insuffisante ({free:.1f} Go < {MIN_VRAM_FOR_REASONING} Go)")
return False
# Précharger le modèle de raisonnement
try:
import requests
logger.info(f"Préchargement {REASONING_MODEL}...")
resp = requests.post(f"{OLLAMA_URL}/api/generate", json={
"model": REASONING_MODEL,
"prompt": "test",
"stream": False,
"options": {"num_predict": 1}
}, timeout=60)
if resp.status_code == 200:
logger.info(f"{REASONING_MODEL} chargé en VRAM")
free_after = self.get_free_vram_gb()
logger.info(f"VRAM libre après chargement: {free_after:.1f} Go")
except Exception as e:
logger.warning(f"Préchargement échoué: {e}")
self._current_mode = VRAMMode.REPLAY
return True
def switch_to_shadow(self) -> bool:
"""Bascule en mode shadow : relance les services d'observation.
1. Redémarre Ollama (décharge le VLM de raisonnement)
2. Relance les services stoppés
"""
if self._current_mode == VRAMMode.SHADOW:
logger.info("Déjà en mode SHADOW")
return True
logger.info("Bascule en mode SHADOW...")
# Redémarrer Ollama
try:
subprocess.run(["sudo", "systemctl", "restart", "ollama"],
timeout=10, check=True)
time.sleep(2)
except Exception as e:
logger.warning(f"Impossible de redémarrer Ollama: {e}")
# Relancer les services stoppés
for service_name, _pid in self._stopped_services:
try:
if service_name == "agent_chat":
subprocess.Popen(
["python3", "-m", "agent_chat.app"],
cwd="/home/dom/ai/rpa_vision_v3",
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
logger.info(f"{service_name} relancé")
except Exception as e:
logger.warning(f"Impossible de relancer {service_name}: {e}")
self._stopped_services.clear()
self._current_mode = VRAMMode.SHADOW
return True
def ensure_reasoning_ready(self) -> bool:
"""Vérifie que le VLM de raisonnement est prêt. Bascule si nécessaire."""
free = self.get_free_vram_gb()
if free >= MIN_VRAM_FOR_REASONING:
return True
return self.switch_to_replay()
@property
def current_mode(self) -> Optional[str]:
return self._current_mode.value if self._current_mode else None
def status(self) -> dict:
return {
"mode": self.current_mode,
"vram_free_gb": round(self.get_free_vram_gb(), 1),
"vram_used_gb": round(self.get_used_vram_gb(), 1),
"reasoning_model": REASONING_MODEL,
"stopped_services": [s[0] for s in self._stopped_services],
}
# Singleton
_orchestrator: Optional[VRAMOrchestrator] = None
def get_orchestrator() -> VRAMOrchestrator:
global _orchestrator
if _orchestrator is None:
_orchestrator = VRAMOrchestrator()
return _orchestrator

View File

@@ -0,0 +1,260 @@
"""
Mémoire de travail de Léa — contexte cognitif pendant l'exécution.
Donne à Léa la conscience de "où elle en est" :
- Quel objectif elle poursuit
- Quel écran elle voit
- Ce qu'elle vient de faire
- Ce qu'elle doit faire ensuite
- Ce qu'elle a appris en cours de route
Sans ça, chaque étape est indépendante — Léa est amnésique entre
deux actions. Avec ça, elle raisonne en contexte.
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class Observation:
"""Ce que Léa observe sur l'écran à un instant donné."""
timestamp: datetime
window_title: str = ""
application: str = ""
ocr_text: str = ""
ui_pattern: Optional[str] = None
screen_description: str = ""
confidence: float = 0.0
@dataclass
class ActionRecord:
"""Une action que Léa a effectuée."""
timestamp: datetime
action_type: str
target: str = ""
result: str = ""
success: bool = True
duration_ms: float = 0
@dataclass
class CognitiveContext:
"""Contexte cognitif complet — la "pensée" de Léa à un instant donné.
C'est le bloc-notes interne qui est réinjecté à chaque décision.
Le VLM reçoit ce contexte pour raisonner en connaissance de cause.
"""
# Objectif global (ce que Léa essaie d'accomplir)
objective: str = ""
# Étape courante dans le plan
current_step: int = 0
total_steps: int = 0
current_step_description: str = ""
# Ce que Léa voit maintenant
current_observation: Optional[Observation] = None
# Historique des N dernières actions (mémoire court terme)
action_history: List[ActionRecord] = field(default_factory=list)
max_history: int = 10
# Ce que Léa a appris pendant cette session
learned_facts: List[str] = field(default_factory=list)
# Plan : les étapes restantes
remaining_steps: List[str] = field(default_factory=list)
# État émotionnel / confiance
confidence: float = 1.0
needs_help: bool = False
help_reason: str = ""
# Timing
session_id: str = ""
machine_id: str = ""
started_at: Optional[datetime] = None
step_started_at: Optional[datetime] = None
step_durations: Dict[str, List[float]] = field(default_factory=dict)
# Ce que Léa devrait voir à l'écran (comparaison attendu vs réel)
expected_screen: str = ""
def record_action(self, action_type: str, target: str = "",
result: str = "", success: bool = True,
duration_ms: float = 0):
"""Enregistre une action dans l'historique."""
self.action_history.append(ActionRecord(
timestamp=datetime.now(),
action_type=action_type,
target=target,
result=result,
success=success,
duration_ms=duration_ms,
))
if len(self.action_history) > self.max_history:
self.action_history = self.action_history[-self.max_history:]
if not success:
self.confidence = max(0, self.confidence - 0.2)
else:
self.confidence = min(1.0, self.confidence + 0.05)
def observe(self, window_title: str = "", application: str = "",
ocr_text: str = "", ui_pattern: Optional[str] = None,
screen_description: str = ""):
"""Met à jour l'observation courante."""
self.current_observation = Observation(
timestamp=datetime.now(),
window_title=window_title,
application=application,
ocr_text=ocr_text,
ui_pattern=ui_pattern,
screen_description=screen_description,
)
def advance_step(self):
"""Passe à l'étape suivante du plan."""
# Enregistrer la durée de l'étape précédente
if self.step_started_at:
duration = (datetime.now() - self.step_started_at).total_seconds()
step_key = self.current_step_description or f"step_{self.current_step}"
self.step_durations.setdefault(step_key, []).append(duration)
self.current_step += 1
self.step_started_at = datetime.now()
if self.remaining_steps:
self.current_step_description = self.remaining_steps.pop(0)
def get_step_timing(self) -> Optional[Dict[str, Any]]:
"""Retourne les infos de timing de l'étape en cours."""
if not self.step_started_at:
return None
elapsed = (datetime.now() - self.step_started_at).total_seconds()
step_key = self.current_step_description or f"step_{self.current_step}"
history = self.step_durations.get(step_key, [])
avg = sum(history) / len(history) if history else None
result = {"elapsed_seconds": elapsed}
if avg:
result["avg_previous"] = avg
result["is_slow"] = elapsed > avg * 2
return result
def set_expected_screen(self, description: str):
"""Définit ce que Léa devrait voir à l'écran pour cette étape."""
self.expected_screen = description
def check_screen_matches_expected(self) -> Optional[bool]:
"""Compare l'observation actuelle avec l'écran attendu."""
if not self.expected_screen or not self.current_observation:
return None
obs_text = (self.current_observation.window_title + " " +
self.current_observation.ocr_text).lower()
expected_words = self.expected_screen.lower().split()
matches = sum(1 for w in expected_words if w in obs_text)
return matches / max(len(expected_words), 1) > 0.3
def learn(self, fact: str):
"""Enregistre un fait appris pendant l'exécution."""
if fact not in self.learned_facts:
self.learned_facts.append(fact)
logger.info(f"Fait appris: {fact}")
def ask_for_help(self, reason: str):
"""Signale que Léa a besoin d'aide."""
self.needs_help = True
self.help_reason = reason
self.confidence = max(0, self.confidence - 0.3)
logger.warning(f"Léa demande de l'aide: {reason}")
def to_prompt_context(self) -> str:
"""Génère le contexte à injecter dans le prompt VLM.
C'est ce texte qui donne au VLM la conscience de la situation.
"""
lines = []
if self.objective:
lines.append(f"OBJECTIF : {self.objective}")
if self.current_step > 0:
lines.append(f"PROGRESSION : étape {self.current_step}/{self.total_steps}")
if self.current_step_description:
lines.append(f"ÉTAPE EN COURS : {self.current_step_description}")
if self.current_observation:
obs = self.current_observation
if obs.window_title:
lines.append(f"FENÊTRE ACTIVE : {obs.window_title}")
if obs.application:
lines.append(f"APPLICATION : {obs.application}")
if obs.ui_pattern:
lines.append(f"DIALOGUE DÉTECTÉ : {obs.ui_pattern}")
if self.action_history:
last_actions = self.action_history[-3:]
lines.append("DERNIÈRES ACTIONS :")
for a in last_actions:
status = "OK" if a.success else "ÉCHEC"
lines.append(f" - {a.action_type} '{a.target}'{status}")
if self.learned_facts:
lines.append("FAITS APPRIS :")
for fact in self.learned_facts[-5:]:
lines.append(f" - {fact}")
if self.remaining_steps:
lines.append("PROCHAINES ÉTAPES :")
for step in self.remaining_steps[:3]:
lines.append(f" - {step}")
timing = self.get_step_timing()
if timing:
lines.append(f"TEMPS ÉTAPE : {timing['elapsed_seconds']:.1f}s")
if timing.get('avg_previous'):
lines.append(f"MOYENNE PRÉCÉDENTE : {timing['avg_previous']:.1f}s")
if timing.get('is_slow'):
lines.append("⚠ ÉTAPE ANORMALEMENT LENTE")
if self.expected_screen:
match = self.check_screen_matches_expected()
if match is False:
lines.append(f"⚠ ÉCRAN INATTENDU (attendu: {self.expected_screen})")
elif match is True:
lines.append(f"ÉCRAN CONFORME : {self.expected_screen}")
lines.append(f"CONFIANCE : {self.confidence:.0%}")
if self.needs_help:
lines.append(f"BESOIN D'AIDE : {self.help_reason}")
return "\n".join(lines)
def to_dict(self) -> Dict[str, Any]:
"""Sérialise le contexte pour le stockage/transport."""
return {
"objective": self.objective,
"current_step": self.current_step,
"total_steps": self.total_steps,
"current_step_description": self.current_step_description,
"confidence": self.confidence,
"needs_help": self.needs_help,
"help_reason": self.help_reason,
"action_count": len(self.action_history),
"learned_facts": self.learned_facts,
"remaining_steps": self.remaining_steps,
"last_observation": {
"window_title": self.current_observation.window_title,
"application": self.current_observation.application,
"ui_pattern": self.current_observation.ui_pattern,
} if self.current_observation else None,
}

View File

@@ -25,6 +25,10 @@ logger = logging.getLogger(__name__)
# Modèle VLM par défaut — Gemma 4 latest (8B dense, Q4_K_M)
# Nécessite think=false dans le payload (sinon tokens vides sur Ollama >=0.20)
# Bench 2026-05-16 : tentatives qwen2.5vl:7b et :3b écartées (runtime Ollama
# avec context = 10-13 GB → débordent toutes en 100% CPU sur RTX 5070 12 GB).
# qwen3-vl:8b écarté : think:false ignoré → tout en thinking field, pas de réponse.
# gemma4:latest reste le seul stable malgré son cold start ~20s (1 fois par run).
DEFAULT_VLM_MODEL = "gemma4:latest"
# Modèles de fallback, testés dans l'ordre si le modèle principal n'est pas dispo

View File

@@ -58,9 +58,19 @@ class CLIPEmbedder(EmbedderBase):
"Install it with: pip install open-clip-torch"
)
# Default to CPU to save GPU for vision models (Qwen3-VL, etc.)
if device is None:
device = "cpu"
try:
import torch
if torch.cuda.is_available():
free_vram = torch.cuda.mem_get_info()[0] / 1024**3
if free_vram > 1.5:
device = "cuda"
else:
device = "cpu"
else:
device = "cpu"
except Exception:
device = "cpu"
self.model_name = model_name
self.pretrained = pretrained

View File

@@ -10,6 +10,7 @@ from .error_handler import ErrorHandler, ErrorType, RecoveryStrategy
from .workflow_runner import WorkflowRunner, RunResult, RunStatus, RunnerConfig
from .dag_executor import DAGExecutor, WorkflowStep, StepType, StepStatus, DAGExecutionResult
from .llm_actions import LLMActionHandler
from .observe_reason_act import ORALoop, Observation, Decision, VerificationResult, LoopResult
# Import tardif pour éviter import circulaire avec pipeline
def _get_execution_loop():
@@ -34,5 +35,11 @@ __all__ = [
'StepStatus',
'DAGExecutionResult',
'LLMActionHandler',
# ORA — boucle Observe-Raisonne-Agit avec vérification
'ORALoop',
'Observation',
'Decision',
'VerificationResult',
'LoopResult',
# ExecutionLoop accessible via import direct du module
]

View File

@@ -654,7 +654,8 @@ class ActionExecutor:
if PYAUTOGUI_AVAILABLE:
pyautogui.click(click_x, click_y)
time.sleep(0.2)
pyautogui.write(text, interval=0.05)
from .input_handler import safe_type_text
safe_type_text(text)
else:
logger.info(f" (Simulated click at {click_x:.0f}, {click_y:.0f})")
logger.info(f" (Simulated typing: {text[:50]}...)")

View File

@@ -0,0 +1,757 @@
"""
Module partagé de saisie texte et gestion des dialogues.
Utilisé par les deux executors :
- VWB executor (visual_workflow_builder/backend/api_v3/execute.py)
- Core executor (core/execution/action_executor.py)
Garantit le même comportement AZERTY/VM/Citrix partout.
"""
import logging
import subprocess
import shutil
import time
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
try:
import pyautogui
PYAUTOGUI_AVAILABLE = True
except Exception:
# pyautogui peut lever Xlib.error.DisplayConnectionError (pas un ImportError)
# quand X n'est pas accessible — typique d'un service systemd côté serveur.
PYAUTOGUI_AVAILABLE = False
try:
import mss
MSS_AVAILABLE = True
except ImportError:
MSS_AVAILABLE = False
try:
from PIL import Image as PILImage
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
def safe_type_text(text: str):
"""Saisie de texte compatible VM/Citrix et claviers AZERTY/QWERTY.
Priorité :
1. xdotool type avec refresh layout → traverse les VM spice/QEMU
2. Presse-papier (xclip) + Ctrl+V → fallback
3. pyautogui.write() → dernier recours
"""
if not text:
return
# Méthode 1 : xdotool type avec refresh du layout clavier
if shutil.which('xdotool') and shutil.which('setxkbmap'):
try:
subprocess.run(['setxkbmap', 'fr'], timeout=2)
subprocess.run(
['xdotool', 'type', '--delay', '0', '--clearmodifiers', '--', text],
timeout=max(30, len(text) * 0.05),
check=True
)
logger.debug(f"Saisie via xdotool type ({len(text)} car.)")
return
except Exception as e:
logger.debug(f"xdotool type échoué: {e}")
# Méthode 2 : Presse-papier
xclip = shutil.which('xclip')
if xclip and PYAUTOGUI_AVAILABLE:
try:
p = subprocess.Popen(
['xclip', '-selection', 'clipboard'],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
p.stdin.write(text.encode('utf-8'))
p.stdin.close()
time.sleep(0.2)
pyautogui.hotkey('ctrl', 'v')
time.sleep(0.3)
logger.debug(f"Saisie via presse-papier ({len(text)} car.)")
return
except Exception as e:
logger.debug(f"xclip échoué: {e}")
# Méthode 3 : pyautogui
if PYAUTOGUI_AVAILABLE:
logger.warning("Saisie via pyautogui.write() (AZERTY non garanti)")
pyautogui.write(text, interval=0.02)
else:
logger.warning(f"Aucune méthode de saisie disponible pour: {text[:50]}")
def check_screen_for_patterns() -> Optional[Dict[str, Any]]:
"""Vérifie si l'écran contient un pattern UI connu (dialogue, popup).
Capture l'écran, extrait le texte via OCR, et cherche un pattern
dans la UIPatternLibrary.
Returns:
Dict avec le pattern trouvé, ou None.
"""
try:
from core.knowledge.ui_patterns import UIPatternLibrary
import mss
from PIL import Image
lib = UIPatternLibrary()
with mss.mss() as sct:
monitor = sct.monitors[0]
screenshot = sct.grab(monitor)
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
try:
# Essayer docTR d'abord (peut être importé depuis différents chemins)
try:
from services.ocr_service import ocr_extract_text
except ImportError:
from core.extraction.field_extractor import FieldExtractor
extractor = FieldExtractor()
ocr_extract_text = lambda img: extractor.extract_text_from_image(img)
ocr_text = ocr_extract_text(screen)
except ImportError:
logger.debug("OCR non disponible pour pattern check")
return None
if not ocr_text or len(ocr_text) < 5:
return None
pattern = lib.find_pattern(ocr_text)
if pattern and pattern['category'] in ('dialog', 'popup'):
print(f"🧠 [PatternCheck] Détecté: '{pattern['pattern']}'{pattern['action']} '{pattern['target']}'")
return pattern
return None
except Exception as e:
print(f"⚠️ [PatternCheck] Erreur: {e}")
return None
def handle_detected_pattern(pattern: Dict[str, Any]) -> bool:
"""Gère automatiquement un pattern UI détecté.
Cherche le bouton cible via OCR (position réelle sur l'écran).
100% vision — zéro coordonnée hardcodée.
Returns:
True si le pattern a été géré avec succès.
"""
if not PYAUTOGUI_AVAILABLE:
logger.warning("pyautogui non disponible — impossible de gérer le pattern")
return False
action = pattern.get('action')
target = pattern.get('target', '')
alternatives = pattern.get('alternatives', [])
if action == 'click':
candidates_labels = [target] + alternatives
print(f"🔧 [Réflexe/handle] Recherche bouton parmi: {candidates_labels}")
try:
import mss
import numpy as np
from PIL import Image
with mss.mss() as sct:
monitor = sct.monitors[0]
screenshot = sct.grab(monitor)
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
# EasyOCR (rapide, bonne qualité GUI) avec fallback docTR.
# gpu=True : harmonisé avec dialog_handler.py et title_verifier.py.
# Coût VRAM ~0.5 GB, sous le budget RTX 5070 (cf. deploy/VRAM_BUDGET.md).
words = []
try:
import easyocr
_reader = easyocr.Reader(['fr', 'en'], gpu=True, verbose=False)
results = _reader.readtext(np.array(screen))
for (bbox_pts, text, conf) in results:
if not text or len(text.strip()) < 1:
continue
x1 = int(min(p[0] for p in bbox_pts))
y1 = int(min(p[1] for p in bbox_pts))
x2 = int(max(p[0] for p in bbox_pts))
y2 = int(max(p[1] for p in bbox_pts))
words.append({'text': text.strip(), 'bbox': [x1, y1, x2, y2]})
except ImportError:
try:
from services.ocr_service import ocr_extract_words
words = ocr_extract_words(screen) or []
except ImportError:
pass
print(f"🔧 [Réflexe/handle] {len(words)} mots OCR détectés")
# Collecter tous les matchs, prendre le plus bas (bouton = bas du dialogue)
all_matches = []
for candidate in candidates_labels:
candidate_lower = candidate.lower()
for word in words:
word_text = word['text'].lower()
if len(word_text) < 2 or len(candidate_lower) < 2:
continue
# Match exact ou inclusion
if word_text == candidate_lower or candidate_lower in word_text or word_text in candidate_lower:
x1, y1, x2, y2 = word['bbox']
all_matches.append({
'text': word['text'],
'x': int((x1 + x2) / 2),
'y': int((y1 + y2) / 2),
'candidate': candidate,
})
if all_matches:
best = max(all_matches, key=lambda m: m['y'])
print(f"✅ [Réflexe/handle] Clic sur '{best['text']}' à ({best['x']}, {best['y']})")
pyautogui.click(best['x'], best['y'])
time.sleep(1.0)
return True
print(f"⚠️ [Réflexe/handle] Bouton '{target}' introuvable parmi {[w['text'] for w in words[:15]]}")
return False
except Exception as e:
print(f"⚠️ [Réflexe/handle] Erreur: {e}")
return False
elif action == 'hotkey':
keys = target.split('+')
logger.info(f"Raccourci automatique: {target}")
pyautogui.hotkey(*keys)
time.sleep(0.5)
return True
return False
def vlm_reason_about_screen(objective: str = "", context: str = "") -> Optional[Dict[str, Any]]:
"""Demande au VLM de raisonner sur l'écran actuel et proposer une action.
Utilisé quand les réflexes (patterns) ne suffisent pas.
Le VLM voit l'écran et décide quoi faire.
Args:
objective: Ce que Léa essaie de faire (ex: "cliquer sur Enregistrer")
context: Contexte additionnel (ex: "un dialogue est apparu")
Returns:
Dict avec 'action', 'target', 'reasoning' ou None si le VLM ne peut pas aider.
"""
try:
import mss
import requests
import json
import base64
import io
import os
from PIL import Image
with mss.mss() as sct:
monitor = sct.monitors[0]
screenshot = sct.grab(monitor)
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
buffer = io.BytesIO()
screen.save(buffer, format='JPEG', quality=70)
image_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
prompt = f"""Analyse cet écran et dis-moi quoi faire.
Objectif : {objective or "Interagir avec l'interface visible"}
Contexte : {context or "Aucun contexte supplémentaire"}
Réponds en JSON strict :
{{
"action": "click" ou "type" ou "wait" ou "nothing",
"target": "texte exact du bouton ou champ à cliquer",
"reasoning": "explication courte de ton choix"
}}
Si tu vois un dialogue ou une popup, indique quel bouton cliquer.
Si l'écran est normal sans action nécessaire, réponds action="nothing".
Réponds UNIQUEMENT le JSON, pas d'explication."""
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
response = requests.post(
f"{ollama_url}/api/generate",
json={
"model": model,
"prompt": prompt,
"images": [image_b64],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 200}
},
timeout=30
)
if response.status_code != 200:
logger.warning(f"VLM reasoning failed: HTTP {response.status_code}")
return None
result = response.json()
text = result.get('response', '').strip()
import re
match = re.search(r'\{[\s\S]*\}', text)
if match:
parsed = json.loads(match.group())
logger.info(f"VLM reasoning: {parsed.get('action')} '{parsed.get('target')}'{parsed.get('reasoning', '')[:80]}")
return parsed
logger.debug(f"VLM response not parseable: {text[:100]}")
return None
except Exception as e:
logger.debug(f"VLM reasoning failed: {e}")
return None
def find_element_on_screen(
target_text: str,
target_description: str = "",
anchor_image_base64: Optional[str] = None,
anchor_bbox: Optional[Dict] = None,
monitor_idx: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
"""
Cherche un élément sur l'écran en utilisant 3 méthodes en cascade.
Niveau 1 — OCR (rapide, ~1s) : docTR pour trouver le texte exact
Niveau 2 — UI-TARS grounding (~3s) : modèle GUI spécialisé
Niveau 3 — VLM reasoning (~10s) : raisonnement + OCR de confirmation
Args:
target_text: Texte de l'élément à trouver (ex: "Demo", "Enregistrer")
target_description: Description plus longue (ex: "le dossier Demo sur le bureau")
anchor_image_base64: Image de référence de l'ancre (pour CLIP matching, réservé futur)
anchor_bbox: Position originale de l'ancre (pour désambiguïser les matchs multiples)
monitor_idx: Index logique 0..N-1 du monitor à scruter. None = composite legacy.
Returns:
{'x': int, 'y': int, 'method': str, 'confidence': float} ou None
"""
# Si le target_text est vide ou c'est juste le type d'action,
# utiliser le VLM pour décrire l'image de l'ancre
action_types = {'click_anchor', 'double_click_anchor', 'right_click_anchor',
'hover_anchor', 'focus_anchor', 'scroll_to_anchor'}
has_useful_text = target_text and target_text not in action_types
if not has_useful_text and anchor_image_base64:
desc = _describe_anchor_image(anchor_image_base64)
if desc:
logger.info(f"[Grounding] Ancre décrite par VLM: '{desc}'")
target_description = desc
if not has_useful_text:
target_text = desc
if not target_text and not target_description:
logger.debug("find_element_on_screen: ni target_text ni target_description fournis")
return None
# Propager monitor_idx au niveau OCR via anchor_bbox (sans muter l'argument original)
if monitor_idx is not None and anchor_bbox is not None:
anchor_bbox = dict(anchor_bbox) # copie pour ne pas muter l'argument
anchor_bbox["monitor_idx"] = monitor_idx
elif monitor_idx is not None:
anchor_bbox = {"monitor_idx": monitor_idx}
search_label = target_description or target_text
logger.info(f"[Grounding] Recherche élément: '{search_label}' (cascade 3 niveaux)")
# ─── Niveau 1 — OCR (rapide, ~1s) ───
result = _grounding_ocr(target_text, anchor_bbox=anchor_bbox)
if result:
return result
# ─── Niveau 2 — UI-TARS grounding (~3s) ───
result = _grounding_ui_tars(target_text, target_description, monitor_idx=monitor_idx)
if result:
return result
# ─── Niveau 3 — VLM reasoning (~10s) ───
result = _grounding_vlm(target_text, target_description, monitor_idx=monitor_idx)
if result:
return result
logger.warning(f"[Grounding] ÉCHEC total pour '{search_label}' — aucune méthode n'a trouvé l'élément")
return None
def _describe_anchor_image(anchor_image_base64: str) -> Optional[str]:
"""Demande au VLM de décrire l'image de l'ancre en quelques mots.
Utilisé quand le label est vide — le VLM regarde le crop de l'ancre
et décrit ce qu'il voit ("folder icon named Demo", "Save button", etc.)
pour que UI-TARS puisse chercher cet élément sur l'écran complet.
"""
try:
import requests
import os
if ',' in anchor_image_base64:
anchor_image_base64 = anchor_image_base64.split(',', 1)[1]
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = "qwen2.5vl:3b"
logger.info(f"[Grounding] Description ancre via {model}...")
response = requests.post(
f"{ollama_url}/api/generate",
json={
"model": model,
"prompt": "Describe this UI element in 5 words maximum. Just the element name, nothing else. Example: 'folder icon named Demo' or 'Save button' or 'Chrome browser icon'",
"images": [anchor_image_base64],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 20}
},
timeout=30
)
if response.status_code == 200:
desc = response.json().get('response', '').strip().strip('"').strip("'")
if desc and len(desc) > 2:
return desc
return None
except Exception as e:
logger.warning(f"[Grounding] Description ancre échouée: {e}")
return None
def _capture_screen(monitor_idx=None):
"""Capture l'écran et retourne (PIL.Image, width, height, offset_x, offset_y).
Args:
monitor_idx: Index logique 0..N-1 du monitor à capturer (cf. screeninfo).
Si None : capture composite (mss.monitors[0]) — comportement legacy.
Returns:
(image, w, h, offset_x, offset_y). offset = (0,0) en mode composite.
"""
try:
with mss.mss() as sct:
if monitor_idx is None:
# Comportement actuel : composite tous écrans
monitor = sct.monitors[0]
offset_x, offset_y = 0, 0
else:
# mss skip monitors[0] (composite). Index logique 0 → mss.monitors[1].
mss_idx = int(monitor_idx) + 1
if mss_idx >= len(sct.monitors):
logger.warning(
"mss.monitors[%d] hors limites (n=%d) — fallback composite",
mss_idx, len(sct.monitors),
)
monitor = sct.monitors[0]
offset_x, offset_y = 0, 0
else:
monitor = sct.monitors[mss_idx]
offset_x = int(monitor.get("left", 0))
offset_y = int(monitor.get("top", 0))
screenshot = sct.grab(monitor)
screen = PILImage.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
return screen, monitor['width'], monitor['height'], offset_x, offset_y
except Exception as e:
logger.debug(f"Capture écran échouée: {e}")
return None, 0, 0, 0, 0
def _grounding_ocr(target_text: str, anchor_bbox: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
"""Niveau 1 — Cherche le texte par OCR (docTR). ~1s.
Collecte TOUS les matchs et choisit le plus pertinent :
- Si anchor_bbox fourni → le plus proche de la position originale
- Sinon → le plus proche du centre de l'écran (zone contenu)
"""
logger.debug(f"[Grounding/OCR] target='{target_text}' bbox={anchor_bbox}")
if not target_text:
return None
try:
monitor_idx_param = anchor_bbox.get("monitor_idx") if anchor_bbox else None
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx_param)
if screen is None:
return None
try:
from services.ocr_service import ocr_extract_words
except ImportError:
from core.extraction.field_extractor import FieldExtractor
extractor = FieldExtractor()
def ocr_extract_words(img):
return extractor.extract_words_from_image(img)
words = ocr_extract_words(screen)
if not words:
logger.debug("[Grounding/OCR] Aucun mot détecté")
return None
target_lower = target_text.lower()
all_matches = []
# Collecter tous les matchs
for word in words:
word_lower = word['text'].lower()
x1, y1, x2, y2 = word['bbox']
cx, cy = int((x1 + x2) / 2), int((y1 + y2) / 2)
if word_lower == target_lower:
all_matches.append({'text': word['text'], 'x': cx, 'y': cy, 'type': 'exact', 'conf': 0.95})
elif len(word_lower) >= 3 and len(target_lower) >= 3:
if target_lower in word_lower or word_lower in target_lower:
# Pénaliser les matchs partiels trop courts par rapport au target
ratio = len(word_lower) / max(len(target_lower), 1)
conf = 0.80 if ratio > 0.5 else 0.50
all_matches.append({'text': word['text'], 'x': cx, 'y': cy, 'type': 'partial', 'conf': conf})
# Matching lettre initiale manquante
if not all_matches and len(target_lower) > 3:
partial = target_lower[1:]
for word in words:
if partial in word['text'].lower():
x1, y1, x2, y2 = word['bbox']
all_matches.append({'text': word['text'], 'x': int((x1+x2)/2), 'y': int((y1+y2)/2), 'type': 'partial_cut', 'conf': 0.70})
if not all_matches:
logger.debug(f"[Grounding/OCR] '{target_text}' non trouvé parmi {len(words)} mots")
return None
# Choisir le meilleur match
if len(all_matches) == 1:
best = all_matches[0]
elif anchor_bbox:
# Prendre le plus proche de la position originale de l'ancre
orig_x = anchor_bbox.get('x', 0) + anchor_bbox.get('width', 0) / 2
orig_y = anchor_bbox.get('y', 0) + anchor_bbox.get('height', 0) / 2
best = min(all_matches, key=lambda m: ((m['x'] - orig_x)**2 + (m['y'] - orig_y)**2))
else:
# Prendre le plus central (zone contenu, pas les barres de titre)
center_x, center_y = screen_w / 2, screen_h / 2
best = min(all_matches, key=lambda m: ((m['x'] - center_x)**2 + (m['y'] - center_y)**2))
for m in all_matches:
sel = " ← CHOISI" if m is best else ""
logger.info(f" [OCR] Candidat: '{m['text']}' à ({m['x']}, {m['y']}) [{m['type']}]{sel}")
return {'x': best['x'] + ox, 'y': best['y'] + oy, 'method': 'ocr', 'confidence': best['conf']}
except Exception as e:
logger.debug(f"[Grounding/OCR] Erreur: {e}")
return None
def _grounding_ui_tars(target_text: str, target_description: str = "", monitor_idx=None) -> Optional[Dict[str, Any]]:
"""Niveau 2 — UI-TARS grounding visuel (~3s)."""
try:
import requests
import base64
import io
import re
import os
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx)
if screen is None:
return None
# Encoder le screenshot en base64
buffer = io.BytesIO()
screen.save(buffer, format='JPEG', quality=70)
image_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
# Construire le prompt pour UI-TARS
click_target = target_description or target_text
prompt = f"click on {click_target}"
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
model = "0000/ui-tars-1.5-7b-q8_0:7b"
logger.info(f"[Grounding/UI-TARS] Envoi à {model}: '{prompt}'")
response = requests.post(
f"{ollama_url}/api/generate",
json={
"model": model,
"prompt": prompt,
"images": [image_b64],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 50}
},
timeout=30
)
if response.status_code != 200:
logger.warning(f"[Grounding/UI-TARS] HTTP {response.status_code}")
return None
result = response.json()
text = result.get('response', '').strip()
logger.debug(f"[Grounding/UI-TARS] Réponse brute: {text[:200]}")
# Parser les coordonnées de UI-TARS
coords = _parse_ui_tars_coordinates(text, screen_w, screen_h)
if coords:
x, y = coords
# Valider que les coordonnées sont dans l'écran
if 0 <= x <= screen_w and 0 <= y <= screen_h:
logger.info(f"[Grounding/UI-TARS] Grounding → ({x}, {y})")
return {'x': x + ox, 'y': y + oy, 'method': 'ui_tars', 'confidence': 0.85}
else:
logger.warning(f"[Grounding/UI-TARS] Coordonnées hors écran: ({x}, {y}) pour {screen_w}x{screen_h}")
return None
logger.debug(f"[Grounding/UI-TARS] Pas de coordonnées parsées dans: {text[:100]}")
return None
except Exception as e:
logger.debug(f"[Grounding/UI-TARS] Erreur: {e}")
return None
def _parse_ui_tars_coordinates(text: str, screen_w: int, screen_h: int) -> Optional[tuple]:
"""Parse les coordonnées retournées par UI-TARS.
UI-TARS peut retourner :
- Coordonnées normalisées (0-1000) : "click at (500, 300)"
- Coordonnées en pixels : "click at (960, 540)"
- Format (x, y) ou [x, y] ou x,y
- Format "Action: click\nCoordinate: (500, 300)" ou "[500, 300]"
Returns:
(x_pixel, y_pixel) ou None
"""
import re
# Chercher des patterns de coordonnées
patterns = [
r'Coordinate:\s*\[?\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?\]?',
r'click\s+(?:at\s+)?\[?\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?\]?',
r'\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)',
r'\[\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\]',
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
raw_x = float(match.group(1))
raw_y = float(match.group(2))
# UI-TARS utilise souvent des coordonnées normalisées 0-1000
if raw_x <= 1000 and raw_y <= 1000 and (raw_x > 1 or raw_y > 1):
# Probablement normalisées sur 1000
x = int(raw_x * screen_w / 1000)
y = int(raw_y * screen_h / 1000)
elif raw_x <= 1.0 and raw_y <= 1.0:
# Normalisées 0-1
x = int(raw_x * screen_w)
y = int(raw_y * screen_h)
else:
# Pixels directs
x = int(raw_x)
y = int(raw_y)
return (x, y)
return None
def _grounding_vlm(target_text: str, target_description: str = "", monitor_idx=None) -> Optional[Dict[str, Any]]:
"""Niveau 3 — VLM reasoning + confirmation OCR (~10s)."""
try:
search_label = target_description or target_text
vlm_result = vlm_reason_about_screen(
objective=f"Cliquer sur {search_label}",
context=f"Je cherche l'élément '{target_text}' sur l'écran pour cliquer dessus"
)
if not vlm_result:
logger.debug("[Grounding/VLM] VLM n'a pas retourné de résultat")
return None
if vlm_result.get('action') != 'click' or not vlm_result.get('target'):
logger.debug(f"[Grounding/VLM] VLM action={vlm_result.get('action')}, pas un clic")
return None
vlm_target = vlm_result['target']
logger.info(f"[Grounding/VLM] VLM suggère de cliquer sur: '{vlm_target}'")
# Confirmation par OCR : chercher le target VLM sur l'écran
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx)
if screen is None:
return None
try:
try:
from services.ocr_service import ocr_extract_words
except ImportError:
from core.extraction.field_extractor import FieldExtractor
extractor = FieldExtractor()
def ocr_extract_words(img):
return extractor.extract_words_from_image(img)
words = ocr_extract_words(screen)
vlm_target_lower = vlm_target.lower()
for word in words:
if vlm_target_lower in word['text'].lower() or word['text'].lower() in vlm_target_lower:
x1, y1, x2, y2 = word['bbox']
x = int((x1 + x2) / 2)
y = int((y1 + y2) / 2)
logger.info(f"[Grounding/VLM] Confirmé par OCR: '{word['text']}' à ({x}, {y})")
return {'x': x + ox, 'y': y + oy, 'method': 'vlm', 'confidence': 0.75}
logger.debug(f"[Grounding/VLM] Target VLM '{vlm_target}' non trouvé par OCR")
return None
except Exception as e:
logger.debug(f"[Grounding/VLM] OCR de confirmation échoué: {e}")
return None
except Exception as e:
logger.debug(f"[Grounding/VLM] Erreur: {e}")
return None
def post_execution_cleanup(execution_mode: str = 'debug'):
"""Vérifie l'écran après exécution et gère les dialogues restants.
Appelé après la dernière étape d'un workflow pour laisser l'écran propre.
"""
if execution_mode not in ('intelligent', 'debug'):
return
logger.info("Vérification écran final...")
time.sleep(1.0)
for _ in range(3):
detected = check_screen_for_patterns()
if detected:
logger.info(f"Dialogue résiduel détecté: {detected.get('pattern')}")
handle_detected_pattern(detected)
time.sleep(1.0)
else:
vlm_result = vlm_reason_about_screen(
objective="Vérifier que l'écran est propre après l'exécution",
context="Le workflow vient de se terminer"
)
if vlm_result and vlm_result.get('action') in ('click', 'type'):
logger.info(f"VLM post-workflow: {vlm_result.get('action')} '{vlm_result.get('target')}'")
break

View File

@@ -40,12 +40,16 @@ class LLMActionHandler:
def __init__(
self,
ollama_endpoint: str = "http://localhost:11434",
model: str = "qwen3-vl:8b",
model: str = None,
temperature: float = 0.1,
timeout: int = 120,
):
self.endpoint = ollama_endpoint.rstrip("/")
self.model = model
if model is not None:
self.model = model
else:
from core.detection.vlm_config import get_vlm_model
self.model = get_vlm_model()
self.temperature = temperature
self.timeout = timeout

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
# Configuration Ollama (coherente avec le reste du projet)
OLLAMA_DEFAULT_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_DEFAULT_MODEL = os.environ.get("VLM_MODEL", "qwen3-vl:8b")
OLLAMA_DEFAULT_MODEL = os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))
class FieldExtractor:

View File

@@ -2,7 +2,7 @@
GPU Resource Management Module for RPA Vision V3
This module provides dynamic GPU resource allocation between ML models:
- Ollama VLM (qwen3-vl:8b) for UI classification
- Ollama VLM (gemma4:e4b par défaut, configurable via RPA_VLM_MODEL) for UI classification
- CLIP (ViT-B-32) for embedding matching
The GPUResourceManager optimizes VRAM usage by:

View File

@@ -2,7 +2,7 @@
GPU Resource Manager - Central orchestrator for GPU resource allocation
Manages dynamic allocation of GPU resources between:
- Ollama VLM (qwen3-vl:8b) - ~10.5 GB VRAM for UI classification
- Ollama VLM (gemma4:e4b par défaut) - ~10 GB VRAM for UI classification
- CLIP (ViT-B-32) - ~500 MB VRAM for embedding matching
Optimizes VRAM usage based on execution mode:
@@ -12,13 +12,14 @@ Optimizes VRAM usage based on execution mode:
"""
import asyncio
import contextlib
import logging
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, Iterator, List, Optional
logger = logging.getLogger(__name__)
@@ -53,7 +54,7 @@ class VRAMInfo:
class GPUResourceConfig:
"""Configuration for GPU resource management."""
ollama_endpoint: str = "http://localhost:11434"
vlm_model: str = "qwen3-vl:8b"
vlm_model: str = "gemma4:e4b"
clip_model: str = "ViT-B-32"
idle_timeout_seconds: int = 300 # 5 minutes
vram_threshold_for_clip_gpu_mb: int = 1024 # 1 GB
@@ -126,6 +127,12 @@ class GPUResourceManager:
# Operation queue for sequential processing
self._operation_queue: asyncio.Queue = asyncio.Queue()
self._operation_lock = asyncio.Lock()
# Lock d'inférence synchrone : sérialise les appels GPU concurrents
# (ScreenAnalyzer.analyze, UIDetector, CLIP.encode) entre
# ExecutionLoop et stream_processor pour éviter la saturation VRAM
# sur RTX 5070 (12 Go). Un seul analyze à la fois sur le GPU.
self._inference_lock = threading.Lock()
# Event callbacks
self._on_resource_changed: List[Callable[[ResourceChangedEvent], None]] = []
@@ -207,7 +214,45 @@ class GPUResourceManager:
def get_execution_mode(self) -> ExecutionMode:
"""Get the current execution mode."""
return self._execution_mode
# =========================================================================
# Inference serialization (sync)
# =========================================================================
@contextlib.contextmanager
def acquire_inference(self, timeout: Optional[float] = None) -> Iterator[bool]:
"""
Context manager synchrone pour sérialiser les inférences GPU.
Garantit qu'un seul appel d'inférence (ScreenAnalyzer.analyze,
UIDetector.detect, CLIP.encode…) tourne à la fois sur le GPU.
Évite la saturation VRAM quand ExecutionLoop et stream_processor
appellent analyze() simultanément sur une RTX 5070 (12 Go).
Args:
timeout: Délai max d'attente (secondes). None = bloquant.
Yields:
True si le lock est acquis, False en cas de timeout.
Example:
>>> with gpu_manager.acquire_inference(timeout=30.0) as acquired:
... if not acquired:
... logger.warning("GPU lock timeout")
... state = analyzer.analyze(path)
"""
if timeout is None:
self._inference_lock.acquire()
acquired = True
else:
acquired = self._inference_lock.acquire(timeout=timeout)
try:
yield acquired
finally:
if acquired:
self._inference_lock.release()
# =========================================================================
# VLM Management
# =========================================================================

View File

@@ -32,7 +32,7 @@ class OllamaManager:
def __init__(
self,
endpoint: str = "http://localhost:11434",
model: str = "qwen3-vl:8b",
model: str = "gemma4:e4b",
default_keep_alive: str = "5m"
):
"""

View File

@@ -0,0 +1,20 @@
# core/grounding — Module de localisation d'éléments UI
#
# Centralise les méthodes de grounding visuel : template matching,
# OCR, VLM, etc. Chaque méthode produit un GroundingResult uniforme.
#
# Le serveur de grounding (server.py) tourne dans un process séparé
# sur le port 8200. Le client HTTP (UITarsGrounder) l'appelle via HTTP.
# Le pipeline (GroundingPipeline) orchestre template → OCR → UI-TARS → static.
from core.grounding.template_matcher import TemplateMatcher, MatchResult
from core.grounding.target import GroundingTarget, GroundingResult
from core.grounding.ui_tars_grounder import UITarsGrounder
from core.grounding.pipeline import GroundingPipeline
__all__ = [
'TemplateMatcher', 'MatchResult',
'GroundingTarget', 'GroundingResult',
'UITarsGrounder',
'GroundingPipeline',
]

View File

@@ -0,0 +1,120 @@
"""
Parser des réponses VLM de grounding (bbox_2d, x/y, x_pct/y_pct, array brut).
Centralise le parsing des coordonnées retournées par les modèles VLM
(Qwen-VL via Ollama, vLLM ou Transformers direct) vers une représentation
normalisée (x_pct, y_pct).
Module pur : regex + arithmétique, sans dépendance lourde.
Convention des diviseurs (DETTE-006 ouverte) : actuellement les call sites
passent les dimensions de l'image envoyée au VLM (PRE-resize). C'est la
source du bug d'échelle pixel grounding — sera corrigé au commit 3/5 du
fix DETTE-006 en passant les dimensions POST-smart_resize.
"""
import re
_ALL_FORMATS = frozenset({"bbox_2d", "xy_json", "xy_pct", "raw_array"})
def parse_bbox_to_norm(
content: str,
divisor_w: int | float,
divisor_h: int | float,
*,
formats: set[str] | None = None,
) -> tuple[float | None, float | None]:
"""Parse une réponse VLM en (x_pct, y_pct) normalisés.
Cascade des formats (premier qui matche gagne) :
1. ``"bbox_2d"`` : ``{"bbox_2d": [x, y]}`` ou ``[x1, y1, x2, y2]``
2. ``"xy_json"`` : ``{"x": ..., "y": ...}`` (heuristique x>1 → pixels)
3. ``"xy_pct"`` : ``{"x_pct": ..., "y_pct": ...}``
4. ``"raw_array"`` : array brut ``[...]`` 2 ou 4 coords
Args:
content: réponse texte du VLM.
divisor_w, divisor_h: dimensions normalisant les pixels en pct.
formats: ensemble des formats à essayer. Si ``None`` (défaut),
cascade complète des 4. Pour restreindre, passer un sous-ensemble
de ``{"bbox_2d", "xy_json", "xy_pct", "raw_array"}``.
Returns:
``(x_pct, y_pct)`` ou ``(None, None)`` si aucun format ne matche.
"""
enabled = _ALL_FORMATS if formats is None else formats
x_pct, y_pct = None, None
# Format 1 : bbox_2d en pixels [x, y] ou [x1, y1, x2, y2]
if "bbox_2d" in enabled:
bbox_match = re.search(r'"bbox_2d"\s*:\s*\[([^\]]+)\]', content)
if bbox_match:
coords = [float(v.strip()) for v in bbox_match.group(1).split(",")]
if len(coords) == 2:
x_pct = coords[0] / divisor_w
y_pct = coords[1] / divisor_h
elif len(coords) >= 4:
x_pct = (coords[0] + coords[2]) / 2 / divisor_w
y_pct = (coords[1] + coords[3]) / 2 / divisor_h
# Format 2 : JSON {"x": 0.XX, "y": 0.YY}
if x_pct is None and "xy_json" in enabled:
json_match = re.search(r'"x"\s*:\s*([\d.]+).*?"y"\s*:\s*([\d.]+)', content)
if json_match:
x_val, y_val = float(json_match.group(1)), float(json_match.group(2))
if x_val > 1:
x_pct = x_val / divisor_w
y_pct = y_val / divisor_h
else:
x_pct = x_val
y_pct = y_val
# Format 3 : JSON {"x_pct": 0.XX, "y_pct": 0.YY}
if x_pct is None and "xy_pct" in enabled:
pct_match = re.search(r'"x_pct"\s*:\s*([\d.]+).*?"y_pct"\s*:\s*([\d.]+)', content)
if pct_match:
x_pct = float(pct_match.group(1))
y_pct = float(pct_match.group(2))
# Format 4 : array brut [x1, y1, x2, y2] ou [x, y]
if x_pct is None and "raw_array" in enabled:
arr_match = re.search(
r'\[[\s]*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+)\s*,\s*([\d.]+))?\s*\]',
content,
)
if arr_match:
vals = [float(v) for v in arr_match.groups() if v is not None]
if len(vals) >= 4:
x_pct = (vals[0] + vals[2]) / 2 / divisor_w
y_pct = (vals[1] + vals[3]) / 2 / divisor_h
elif len(vals) == 2:
x_pct = vals[0] / divisor_w
y_pct = vals[1] / divisor_h
return x_pct, y_pct
def parse_bbox_to_norm_validated(
content: str,
divisor_w: int | float,
divisor_h: int | float,
*,
formats: set[str] | None = None,
) -> tuple[float | None, float | None]:
"""Idem :func:`parse_bbox_to_norm` + validation domaine [0, 1].
Retourne ``(None, None)`` si le résultat parsé est hors ``[0, 1]`` sur
l'un des deux axes — comportement de ``_locate_popup_button``
(cf. resolve_engine.py:2569-2580).
Implémentation : appelle :func:`parse_bbox_to_norm` puis valide. Pas
de duplication de la logique de parsing.
"""
x_pct, y_pct = parse_bbox_to_norm(content, divisor_w, divisor_h, formats=formats)
if x_pct is None or y_pct is None:
return None, None
if not (0.0 <= x_pct <= 1.0 and 0.0 <= y_pct <= 1.0):
return None, None
return x_pct, y_pct

View File

@@ -0,0 +1,256 @@
"""
core/grounding/dialog_handler.py — Gestion intelligente des dialogues
Quand un dialogue inattendu apparaît (pHash change après une action) :
1. Lire le titre de la fenêtre (EasyOCR crop 45px, ~130ms)
2. Si titre connu (Enregistrer sous, Confirmer, etc.) → action connue
3. Demander à InfiGUI de cliquer sur le bon bouton (~3s)
4. Vérifier que le dialogue a disparu (pHash)
Pas de patterns prédéfinis pour les boutons. InfiGUI comprend
visuellement le dialogue et clique au bon endroit.
Utilisation :
from core.grounding.dialog_handler import DialogHandler
handler = DialogHandler()
result = handler.handle_if_dialog(screenshot_pil)
if result['handled']:
print(f"Dialogue '{result['title']}' géré → {result['action']}")
"""
from __future__ import annotations
import time
from typing import Any, Dict, Optional
# Titres connus → quelle action demander à InfiGUI.
#
# IMPORTANT — ordre du dict = priorité de matching.
# L'OCR est full-screen et capte souvent le texte du dialog parent ET du popup
# modal qui apparaît par-dessus (ex: "Enregistrer sous" reste visible derrière
# "Confirmer l'enregistrement"). Les popups modaux DOIVENT matcher avant les
# fenêtres principales, sinon Léa clique sur le bouton du parent qui n'a pas
# le focus.
KNOWN_DIALOGS = {
# ── Popups modaux de confirmation (priorité HAUTE) ──────────────────
"voulez-vous le remplacer": {"target": "Oui", "description": "Clique sur Oui pour confirmer le remplacement du fichier"},
"do you want to replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"},
"existe déjà": {"target": "Oui", "description": "Clique sur Oui, le fichier existe déjà et doit être remplacé"},
"already exists": {"target": "Yes", "description": "Click Yes, the file already exists"},
"remplacer": {"target": "Oui", "description": "Clique sur le bouton Oui pour confirmer le remplacement du fichier"},
"replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"},
"écraser": {"target": "Oui", "description": "Clique sur Oui pour écraser le fichier"},
"overwrite": {"target": "Yes", "description": "Click Yes to overwrite"},
"confirmer l'enregistrement": {"target": "Oui", "description": "Clique sur Oui dans le popup de confirmation d'enregistrement"},
"confirmer": {"target": "Oui", "description": "Clique sur le bouton Oui dans le dialogue de confirmation"},
# ── Avertissements/erreurs (priorité haute, 1 seul bouton OK) ───────
"erreur": {"target": "OK", "description": "Clique sur OK pour fermer le message d'erreur"},
"error": {"target": "OK", "description": "Click OK to close the error message"},
"avertissement": {"target": "OK", "description": "Clique sur OK pour fermer l'avertissement"},
"warning": {"target": "OK", "description": "Click OK to close the warning"},
# ── Dialogs principaux de sauvegarde (priorité BASSE — fenêtres parents) ─
"voulez-vous enregistrer": {"target": "Enregistrer", "description": "Clique sur Enregistrer pour sauvegarder les modifications"},
"do you want to save": {"target": "Save", "description": "Click Save to save changes"},
"enregistrer sous": {"target": "Enregistrer", "description": "Clique sur le bouton Enregistrer dans le dialogue Enregistrer sous"},
"save as": {"target": "Save", "description": "Click the Save button in the Save As dialog"},
}
class DialogHandler:
"""Gestion intelligente des dialogues via titre + InfiGUI."""
def __init__(self):
self._easyocr_reader = None
def handle_if_dialog(
self,
screenshot_pil,
previous_title: str = "",
) -> Dict[str, Any]:
"""Vérifie si l'écran montre un dialogue et le gère.
Args:
screenshot_pil: Screenshot PIL actuel.
previous_title: Titre de la fenêtre avant l'action (pour comparaison).
Returns:
Dict avec 'handled' (bool), 'title', 'action', 'position'.
"""
t0 = time.time()
# 1. Lire le titre de la fenêtre
title = self._read_title(screenshot_pil)
if not title or len(title) < 3:
return {'handled': False, 'title': '', 'reason': 'Titre illisible'}
print(f"🔍 [Dialog] Titre lu: '{title}'")
# 2. Chercher si c'est un dialogue connu
matched_dialog = None
for key, action_info in KNOWN_DIALOGS.items():
if key in title.lower():
matched_dialog = (key, action_info)
break
if not matched_dialog:
# Pas un dialogue connu — le workflow continue normalement
return {'handled': False, 'title': title, 'reason': 'Pas un dialogue connu'}
dialog_key, action_info = matched_dialog
target = action_info['target']
description = action_info['description']
print(f"🧠 [Dialog] Dialogue détecté: '{dialog_key}' → clic '{target}'")
# 3. Demander à InfiGUI de cliquer sur le bouton
click_result = self._click_via_infigui(
target, description, screenshot_pil
)
dt = (time.time() - t0) * 1000
if click_result:
print(f"✅ [Dialog] Clic '{target}' à ({click_result['x']}, {click_result['y']}) ({dt:.0f}ms)")
return {
'handled': True,
'title': title,
'dialog_type': dialog_key,
'action': f"click '{target}'",
'position': (click_result['x'], click_result['y']),
'time_ms': dt,
}
else:
# InfiGUI n'a pas trouvé le bouton — essayer le clic direct via OCR
print(f"⚠️ [Dialog] InfiGUI n'a pas trouvé '{target}', essai OCR direct")
ocr_result = self._click_via_ocr(target, screenshot_pil)
dt = (time.time() - t0) * 1000
if ocr_result:
print(f"✅ [Dialog] OCR clic '{target}' à ({ocr_result[0]}, {ocr_result[1]}) ({dt:.0f}ms)")
return {
'handled': True,
'title': title,
'dialog_type': dialog_key,
'action': f"click '{target}' (OCR)",
'position': ocr_result,
'time_ms': dt,
}
print(f"❌ [Dialog] Impossible de cliquer '{target}' ({dt:.0f}ms)")
return {
'handled': False,
'title': title,
'dialog_type': dialog_key,
'reason': f"Bouton '{target}' introuvable",
'time_ms': dt,
}
# ------------------------------------------------------------------
# Lecture titre
# ------------------------------------------------------------------
def _read_title(self, screenshot_pil) -> str:
"""Lit TOUT le texte visible via EasyOCR full-screen (~500ms).
En VM QEMU, la barre de titre Windows est à l'intérieur du framebuffer,
pas en haut absolu de l'écran. On fait l'OCR full-screen et on cherche
les mots-clés des dialogues connus dans le texte complet.
"""
try:
import numpy as np
reader = self._get_easyocr()
if reader is None:
return ""
results = reader.readtext(np.array(screenshot_pil))
full_text = ' '.join(r[1] for r in results if r[1].strip())
return full_text
except Exception as e:
print(f"⚠️ [Dialog] Erreur lecture écran: {e}")
return ""
# ------------------------------------------------------------------
# Clic via InfiGUI (serveur grounding)
# ------------------------------------------------------------------
def _click_via_infigui(
self, target: str, description: str, screenshot_pil
) -> Optional[Dict]:
"""Demande à InfiGUI (subprocess one-shot) de localiser et cliquer sur le bouton."""
try:
from core.grounding.ui_tars_grounder import UITarsGrounder
grounder = UITarsGrounder.get_instance()
result = grounder.ground(
target_text=target,
target_description=description,
screen_pil=screenshot_pil,
)
if result and result.x is not None:
import pyautogui
pyautogui.click(result.x, result.y)
return {'x': result.x, 'y': result.y}
return None
except Exception as e:
print(f"⚠️ [Dialog/InfiGUI] Erreur: {e}")
return None
# ------------------------------------------------------------------
# Clic via OCR (fallback rapide)
# ------------------------------------------------------------------
def _click_via_ocr(self, target: str, screenshot_pil) -> Optional[tuple]:
"""Cherche le bouton par OCR et clique dessus."""
try:
import numpy as np
reader = self._get_easyocr()
if reader is None:
return None
results = reader.readtext(np.array(screenshot_pil))
target_lower = target.lower()
matches = []
for (bbox_pts, text, conf) in results:
if target_lower in text.lower() or text.lower() in target_lower:
x = int(sum(p[0] for p in bbox_pts) / 4)
y = int(sum(p[1] for p in bbox_pts) / 4)
matches.append((x, y, text))
if matches:
# Prendre le match le plus bas (boutons = bas du dialogue)
best = max(matches, key=lambda m: m[1])
import pyautogui
pyautogui.click(best[0], best[1])
return (best[0], best[1])
return None
except Exception as e:
print(f"⚠️ [Dialog/OCR] Erreur: {e}")
return None
# ------------------------------------------------------------------
# EasyOCR singleton
# ------------------------------------------------------------------
def _get_easyocr(self):
if self._easyocr_reader is not None:
return self._easyocr_reader
try:
import easyocr
self._easyocr_reader = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
)
return self._easyocr_reader
except ImportError:
return None

View File

@@ -0,0 +1,239 @@
"""
core/grounding/element_signature.py — Signatures d'éléments UI apprises
Chaque élément cliqué avec succès enrichit sa signature :
- texte OCR, type, position relative, voisins contextuels
- nombre de succès/échecs, confiance moyenne
- variantes observées (résolutions, positions)
Les signatures sont stockées en SQLite pour un lookup rapide.
Pattern identique à TargetMemoryStore (validé en prod).
Utilisation :
from core.grounding.element_signature import SignatureStore
store = SignatureStore()
# Après un clic réussi
store.record_success("btn_valider", "notepad_1920x1080", element, confidence=0.92)
# Au replay
sig = store.lookup("btn_valider", "notepad_1920x1080")
if sig:
print(f"Signature connue : {sig['text']} position={sig['relative_position']}")
"""
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import threading
import time
from typing import Any, Dict, List, Optional
from core.grounding.fast_types import DetectedUIElement
# Chemin par défaut de la DB
_DEFAULT_DB = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"data", "learning", "element_signatures.db",
)
class SignatureStore:
"""Stockage SQLite des signatures d'éléments UI appris."""
def __init__(self, db_path: str = _DEFAULT_DB):
self.db_path = db_path
self._lock = threading.Lock()
self._ensure_db()
def _ensure_db(self):
"""Crée la DB et la table si nécessaire."""
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS signatures (
target_key TEXT NOT NULL,
screen_context TEXT NOT NULL,
text TEXT DEFAULT '',
element_type TEXT DEFAULT 'element',
relative_position TEXT DEFAULT '',
neighbors TEXT DEFAULT '[]',
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
avg_confidence REAL DEFAULT 0.0,
last_seen TEXT DEFAULT '',
variants TEXT DEFAULT '[]',
PRIMARY KEY (target_key, screen_context)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_target_key
ON signatures(target_key)
""")
# ------------------------------------------------------------------
# Lookup
# ------------------------------------------------------------------
def lookup(self, target_key: str, screen_context: str = "") -> Optional[Dict[str, Any]]:
"""Cherche une signature connue.
Args:
target_key: Clé unique de la cible (hash du texte + description).
screen_context: Contexte d'écran (hash titre fenêtre + résolution).
Returns:
Dict avec les champs de la signature, ou None.
"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
# Chercher avec le contexte exact d'abord
row = conn.execute(
"SELECT * FROM signatures WHERE target_key = ? AND screen_context = ?",
(target_key, screen_context),
).fetchone()
# Fallback : chercher sans contexte (toutes les variantes)
if row is None and screen_context:
row = conn.execute(
"SELECT * FROM signatures WHERE target_key = ? ORDER BY success_count DESC LIMIT 1",
(target_key,),
).fetchone()
if row is None:
return None
return {
"target_key": row["target_key"],
"screen_context": row["screen_context"],
"text": row["text"],
"element_type": row["element_type"],
"relative_position": row["relative_position"],
"neighbors": json.loads(row["neighbors"]),
"success_count": row["success_count"],
"fail_count": row["fail_count"],
"avg_confidence": row["avg_confidence"],
"last_seen": row["last_seen"],
"variants": json.loads(row["variants"]),
}
# ------------------------------------------------------------------
# Enregistrement
# ------------------------------------------------------------------
def record_success(
self,
target_key: str,
screen_context: str,
element: DetectedUIElement,
confidence: float,
):
"""Enregistre un succès — crée ou enrichit la signature."""
with self._lock:
existing = self.lookup(target_key, screen_context)
now = time.strftime("%Y-%m-%dT%H:%M:%S")
if existing:
# Enrichir la signature existante
n = existing["success_count"]
new_avg = (existing["avg_confidence"] * n + confidence) / (n + 1)
# Ajouter la variante si position différente
variants = existing["variants"]
variant = {
"position": element.relative_position,
"center": list(element.center),
"confidence": confidence,
"timestamp": now,
}
variants.append(variant)
# Garder les 20 dernières variantes max
variants = variants[-20:]
# Mettre à jour les voisins (union)
neighbors = list(set(existing["neighbors"] + element.neighbors))[:10]
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
UPDATE signatures SET
success_count = success_count + 1,
avg_confidence = ?,
last_seen = ?,
neighbors = ?,
variants = ?,
relative_position = ?
WHERE target_key = ? AND screen_context = ?
""", (
new_avg, now,
json.dumps(neighbors),
json.dumps(variants),
element.relative_position,
target_key, screen_context,
))
else:
# Créer une nouvelle signature
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
INSERT INTO signatures
(target_key, screen_context, text, element_type, relative_position,
neighbors, success_count, fail_count, avg_confidence, last_seen, variants)
VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?)
""", (
target_key, screen_context,
element.ocr_text,
element.element_type,
element.relative_position,
json.dumps(element.neighbors[:10]),
confidence, now,
json.dumps([{
"position": element.relative_position,
"center": list(element.center),
"confidence": confidence,
"timestamp": now,
}]),
))
print(f"📝 [Signature] '{target_key}' {'enrichie' if existing else 'créée'} "
f"(conf={confidence:.2f}, ctx='{screen_context[:30]}')")
def record_failure(self, target_key: str, screen_context: str):
"""Enregistre un échec pour une signature."""
with self._lock:
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
UPDATE signatures SET fail_count = fail_count + 1, last_seen = ?
WHERE target_key = ? AND screen_context = ?
""", (time.strftime("%Y-%m-%dT%H:%M:%S"), target_key, screen_context))
# ------------------------------------------------------------------
# Utilitaires
# ------------------------------------------------------------------
@staticmethod
def make_target_key(text: str, description: str = "") -> str:
"""Génère une clé unique pour une cible."""
raw = f"{text.lower().strip()}|{description.lower().strip()}"
return hashlib.md5(raw.encode()).hexdigest()[:16]
@staticmethod
def make_screen_context(window_title: str, resolution: tuple = (0, 0)) -> str:
"""Génère un contexte d'écran."""
raw = f"{window_title.lower().strip()}|{resolution[0]}x{resolution[1]}"
return hashlib.md5(raw.encode()).hexdigest()[:12]
def get_stats(self) -> Dict[str, Any]:
"""Statistiques de la base de signatures."""
with sqlite3.connect(self.db_path) as conn:
total = conn.execute("SELECT COUNT(*) FROM signatures").fetchone()[0]
reliable = conn.execute(
"SELECT COUNT(*) FROM signatures WHERE success_count >= 3 AND fail_count = 0"
).fetchone()[0]
return {
"total_signatures": total,
"reliable": reliable,
"db_path": self.db_path,
}

View File

@@ -0,0 +1,326 @@
"""
core/grounding/fast_detector.py — Layer FAST : détection rapide des éléments UI
Capture l'écran, détecte tous les éléments UI via RF-DETR (~120ms),
enrichit chaque élément avec le texte OCR et le contexte spatial.
Produit un ScreenSnapshot utilisable par le SmartMatcher.
Utilisation :
from core.grounding.fast_detector import FastDetector
detector = FastDetector()
snapshot = detector.detect()
print(f"{len(snapshot.elements)} éléments en {snapshot.total_time_ms:.0f}ms")
"""
from __future__ import annotations
import math
import time
from typing import Any, Dict, List, Optional, Tuple
from core.grounding.fast_types import DetectedUIElement, ScreenSnapshot
class FastDetector:
"""Détection rapide de tous les éléments UI visibles sur l'écran.
Combine RF-DETR (détection bbox) + docTR (OCR) pour produire
un ScreenSnapshot enrichi.
Le modèle RF-DETR est un singleton chargé au premier appel (~1s),
puis les appels suivants sont rapides (~120ms).
"""
def __init__(self, detection_threshold: float = 0.30):
self.detection_threshold = detection_threshold
self._last_snapshot: Optional[ScreenSnapshot] = None
self._last_phash: str = ""
def detect(
self,
screenshot_pil: Optional[Any] = None,
phash: str = "",
window_title: str = "",
) -> ScreenSnapshot:
"""Détecte et enrichit tous les éléments UI de l'écran.
Args:
screenshot_pil: Image PIL. Si None, capture via mss.
phash: Hash perceptuel pour le cache. Si identique au dernier, réutilise le cache.
window_title: Titre de la fenêtre active.
Returns:
ScreenSnapshot avec tous les éléments enrichis.
"""
t0 = time.time()
# Cache : même écran → même résultat
if phash and phash == self._last_phash and self._last_snapshot is not None:
print(f"⚡ [FAST] Cache hit (pHash identique)")
return self._last_snapshot
# Capture si pas fourni
if screenshot_pil is None:
screenshot_pil = self._capture_screen()
if screenshot_pil is None:
return ScreenSnapshot(elements=[], ocr_words=[], resolution=(0, 0))
w, h = screenshot_pil.size
# --- Détection RF-DETR (~120ms) ---
t_det = time.time()
raw_elements = self._detect_rfdetr(screenshot_pil)
detection_ms = (time.time() - t_det) * 1000
# --- OCR sur les crops des éléments détectés (pas full screen) ---
t_ocr = time.time()
ocr_words = self._ocr_extract(screenshot_pil)
ocr_ms = (time.time() - t_ocr) * 1000
# --- Enrichissement : attribuer texte + voisins + position ---
enriched = self._enrich_elements(raw_elements, ocr_words, w, h)
total_ms = (time.time() - t0) * 1000
snapshot = ScreenSnapshot(
elements=enriched,
ocr_words=ocr_words,
resolution=(w, h),
window_title=window_title,
phash=phash,
detection_time_ms=detection_ms,
ocr_time_ms=ocr_ms,
total_time_ms=total_ms,
)
# Mettre en cache
if phash:
self._last_phash = phash
self._last_snapshot = snapshot
print(f"⚡ [FAST] {len(enriched)} éléments détectés en {total_ms:.0f}ms "
f"(det={detection_ms:.0f}ms, ocr={ocr_ms:.0f}ms)")
return snapshot
# ------------------------------------------------------------------
# Détection RF-DETR
# ------------------------------------------------------------------
def _detect_rfdetr(self, image) -> List[DetectedUIElement]:
"""Détecte les éléments via RF-DETR (réutilise le singleton existant)."""
try:
import sys
sys.path.insert(0, 'visual_workflow_builder/backend')
from services.ui_detection_service import detect_ui_elements
result = detect_ui_elements(image, threshold=self.detection_threshold)
elements = []
for e in result.elements:
x1 = e.bbox["x1"]
y1 = e.bbox["y1"]
x2 = e.bbox["x2"]
y2 = e.bbox["y2"]
elements.append(DetectedUIElement(
id=e.id,
bbox=(x1, y1, x2, y2),
center=(e.center["x"], e.center["y"]),
confidence=e.confidence,
))
return elements
except Exception as ex:
print(f"⚠️ [FAST/detect] RF-DETR erreur: {ex}")
return []
# ------------------------------------------------------------------
# OCR
# ------------------------------------------------------------------
_easyocr_reader = None # Singleton EasyOCR (chargé une fois)
def _ocr_extract(self, image) -> List[Dict[str, Any]]:
"""Extrait les mots visibles via EasyOCR (GPU, ~500ms).
Fallback sur docTR si EasyOCR non disponible.
"""
try:
import numpy as np
import easyocr
# Singleton : charger le reader une seule fois
if FastDetector._easyocr_reader is None:
print(f"🔍 [FAST/ocr] Chargement EasyOCR (GPU)...")
FastDetector._easyocr_reader = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
)
results = FastDetector._easyocr_reader.readtext(np.array(image))
words = []
for (bbox_pts, text, conf) in results:
if not text or len(text.strip()) < 1:
continue
# bbox_pts = [[x1,y1],[x2,y1],[x2,y2],[x1,y2]]
x1 = int(min(p[0] for p in bbox_pts))
y1 = int(min(p[1] for p in bbox_pts))
x2 = int(max(p[0] for p in bbox_pts))
y2 = int(max(p[1] for p in bbox_pts))
words.append({
'text': text.strip(),
'bbox': [x1, y1, x2, y2],
'confidence': float(conf),
})
return words
except ImportError:
# Fallback docTR
try:
import sys
sys.path.insert(0, 'visual_workflow_builder/backend')
from services.ocr_service import ocr_extract_words
return ocr_extract_words(image) or []
except Exception:
return []
except Exception as ex:
print(f"⚠️ [FAST/ocr] EasyOCR erreur: {ex}")
return []
# ------------------------------------------------------------------
# Enrichissement
# ------------------------------------------------------------------
def _enrich_elements(
self,
elements: List[DetectedUIElement],
ocr_words: List[Dict[str, Any]],
screen_w: int,
screen_h: int,
) -> List[DetectedUIElement]:
"""Enrichit chaque élément avec texte OCR, voisins et position relative."""
for elem in elements:
# 1. Attribuer le texte OCR par intersection bbox
elem.ocr_text = self._assign_ocr_text(elem, ocr_words)
# 2. Position relative dans l'écran (grille 3x3)
elem.relative_position = self._compute_relative_position(
elem.center, screen_w, screen_h
)
# 3. Classifier le type d'élément (heuristique taille + ratio)
elem.element_type = self._classify_element_type(elem)
# 4. Calculer les voisins (texte des éléments proches)
for elem in elements:
elem.neighbors = self._find_neighbors(elem, elements)
return elements
def _assign_ocr_text(
self,
elem: DetectedUIElement,
ocr_words: List[Dict[str, Any]],
) -> str:
"""Attribue le texte OCR à un élément par intersection géométrique."""
x1, y1, x2, y2 = elem.bbox
# Élargir la bbox de 20% pour capturer le texte autour
margin_x = int((x2 - x1) * 0.2)
margin_y = int((y2 - y1) * 0.2)
ex1, ey1 = x1 - margin_x, y1 - margin_y
ex2, ey2 = x2 + margin_x, y2 + margin_y
texts = []
for word in ocr_words:
wb = word.get('bbox', [0, 0, 0, 0])
if len(wb) < 4:
continue
wx1, wy1, wx2, wy2 = wb[0], wb[1], wb[2], wb[3]
# Intersection ?
if wx1 < ex2 and wx2 > ex1 and wy1 < ey2 and wy2 > ey1:
text = word.get('text', '').strip()
if text and len(text) > 1:
texts.append(text)
return ' '.join(texts)
@staticmethod
def _compute_relative_position(
center: Tuple[int, int],
screen_w: int,
screen_h: int,
) -> str:
"""Calcule la position relative dans une grille 3x3."""
cx, cy = center
col = "left" if cx < screen_w / 3 else ("right" if cx > 2 * screen_w / 3 else "center")
row = "top" if cy < screen_h / 3 else ("bottom" if cy > 2 * screen_h / 3 else "middle")
return f"{row}_{col}"
@staticmethod
def _classify_element_type(elem: DetectedUIElement) -> str:
"""Classifie le type d'élément par heuristique taille/ratio."""
w, h = elem.width, elem.height
if w == 0 or h == 0:
return "element"
ratio = w / h
area = w * h
# Petit carré → icône
if area < 5000 and 0.5 < ratio < 2.0:
return "icon"
# Large et fin → bouton ou champ
if ratio > 3.0 and h < 60:
return "input"
if ratio > 2.0 and h < 50:
return "button"
# Grand bloc → zone de contenu
if area > 50000:
return "container"
return "element"
@staticmethod
def _find_neighbors(
elem: DetectedUIElement,
all_elements: List[DetectedUIElement],
max_neighbors: int = 5,
) -> List[str]:
"""Trouve les textes OCR des éléments proches (rayon 1.5x diagonale)."""
diag = math.sqrt(elem.width**2 + elem.height**2)
radius = max(diag * 1.5, 100) # minimum 100px
neighbors = []
for other in all_elements:
if other.id == elem.id or not other.ocr_text:
continue
dx = other.center[0] - elem.center[0]
dy = other.center[1] - elem.center[1]
dist = math.sqrt(dx**2 + dy**2)
if dist < radius:
neighbors.append(other.ocr_text)
return neighbors[:max_neighbors]
# ------------------------------------------------------------------
# Capture écran
# ------------------------------------------------------------------
@staticmethod
def _capture_screen():
"""Capture l'écran via mss."""
try:
import mss
from PIL import Image
with mss.mss() as sct:
mon = sct.monitors[0]
grab = sct.grab(mon)
return Image.frombytes('RGB', grab.size, grab.bgra, 'raw', 'BGRX')
except Exception as ex:
print(f"⚠️ [FAST/capture] Erreur: {ex}")
return None

View File

@@ -0,0 +1,216 @@
"""
core/grounding/fast_pipeline.py — Pipeline FAST → SMART → THINK
Orchestrateur central : détecte les éléments (FAST), matche avec la cible (SMART),
et demande au VLM de trancher si le score est trop bas (THINK).
Seuils de confiance :
≥ 0.90 → action directe (FAST/SMART)
0.60-0.90 → VLM confirme (THINK)
< 0.60 → VLM cherche seul (THINK)
L'ancien GroundingPipeline est utilisé en fallback si tout échoue.
Utilisation :
from core.grounding.fast_pipeline import FastSmartThinkPipeline
from core.grounding.target import GroundingTarget
pipeline = FastSmartThinkPipeline()
result = pipeline.locate(GroundingTarget(text="Valider"))
if result:
print(f"({result.x}, {result.y}) via {result.method} en {result.time_ms:.0f}ms")
"""
from __future__ import annotations
import time
import threading
from typing import Optional
from core.grounding.target import GroundingTarget, GroundingResult
from core.grounding.fast_types import LocateResult
from core.grounding.fast_detector import FastDetector
from core.grounding.smart_matcher import SmartMatcher
from core.grounding.think_arbiter import ThinkArbiter
from core.grounding.element_signature import SignatureStore
# Singleton
_instance: Optional[FastSmartThinkPipeline] = None
_instance_lock = threading.Lock()
class FastSmartThinkPipeline:
"""Pipeline FAST → SMART → THINK pour la localisation d'éléments UI.
Chaque appel à locate() suit la cascade :
1. FAST : détection RF-DETR + OCR enrichissement (~120ms+1s)
2. SMART : matching texte/type/position/voisins (< 1ms)
3. THINK : VLM arbitre si score insuffisant (~3-5s)
4. Fallback : ancien pipeline si tout échoue
"""
def __init__(
self,
confidence_direct: float = 0.90,
confidence_think: float = 0.60,
enable_think: bool = True,
enable_learning: bool = True,
):
self.confidence_direct = confidence_direct
self.confidence_think = confidence_think
self.enable_think = enable_think
self.enable_learning = enable_learning
self._detector = FastDetector()
self._matcher = SmartMatcher()
self._arbiter = ThinkArbiter()
self._signatures = SignatureStore()
self._fallback_pipeline = None
@classmethod
def get_instance(cls) -> FastSmartThinkPipeline:
"""Retourne l'instance singleton."""
global _instance
if _instance is None:
with _instance_lock:
if _instance is None:
_instance = cls()
return _instance
def set_fallback_pipeline(self, pipeline) -> None:
"""Configure l'ancien pipeline comme safety net."""
self._fallback_pipeline = pipeline
# ------------------------------------------------------------------
# API principale
# ------------------------------------------------------------------
def locate(
self,
target: GroundingTarget,
screenshot_pil=None,
phash: str = "",
window_title: str = "",
) -> Optional[GroundingResult]:
"""Localise un élément UI via la cascade FAST → SMART → THINK.
Args:
target: Ce qu'on cherche (texte, description, bbox d'origine).
screenshot_pil: Image PIL. Si None, capture via mss.
phash: Hash perceptuel pour le cache.
window_title: Titre de la fenêtre active.
Returns:
GroundingResult compatible avec le pipeline existant, ou None.
"""
t0 = time.time()
# --- FAST : détecter tous les éléments ---
snapshot = self._detector.detect(
screenshot_pil=screenshot_pil,
phash=phash,
window_title=window_title,
)
if not snapshot.elements:
print(f"⚡ [Pipeline] FAST : aucun élément détecté")
return self._try_fallback(target)
# --- Lookup signature apprise ---
target_key = SignatureStore.make_target_key(
target.text or "", target.description or ""
)
screen_ctx = SignatureStore.make_screen_context(
window_title, snapshot.resolution
)
signature = self._signatures.lookup(target_key, screen_ctx)
# --- SMART : matcher avec la cible ---
candidate = self._matcher.match(snapshot, target, signature)
if candidate:
dt = (time.time() - t0) * 1000
# Score suffisant → action directe
if candidate.score >= self.confidence_direct:
print(f"✅ [Pipeline] FAST→SMART direct : '{candidate.element.ocr_text}' "
f"score={candidate.score:.3f} ({candidate.method}) "
f"→ ({candidate.element.center[0]}, {candidate.element.center[1]}) "
f"en {dt:.0f}ms")
# Apprentissage
if self.enable_learning:
self._signatures.record_success(
target_key, screen_ctx,
candidate.element, candidate.score,
)
return GroundingResult(
x=candidate.element.center[0],
y=candidate.element.center[1],
method=f"fast_{candidate.method}",
confidence=candidate.score,
time_ms=dt,
)
# Score moyen → demander au VLM de confirmer
if candidate.score >= self.confidence_think and self.enable_think:
print(f"🤔 [Pipeline] SMART score={candidate.score:.3f} — THINK pour confirmer")
think_result = self._arbiter.arbitrate(
target,
candidates=[candidate],
screenshot_pil=screenshot_pil or snapshot.elements[0] if False else screenshot_pil,
)
dt = (time.time() - t0) * 1000
if think_result:
# VLM a confirmé
if self.enable_learning:
self._signatures.record_success(
target_key, screen_ctx,
candidate.element, think_result.confidence,
)
return GroundingResult(
x=think_result.x, y=think_result.y,
method="smart_think_confirmed",
confidence=think_result.confidence,
time_ms=dt,
)
# --- THINK : score trop bas ou pas de candidat → VLM cherche seul ---
if self.enable_think:
score_info = f"score={candidate.score:.3f}" if candidate else "aucun candidat"
print(f"🤔 [Pipeline] {score_info} — THINK recherche complète")
think_result = self._arbiter.arbitrate(
target, candidates=[], screenshot_pil=screenshot_pil,
)
dt = (time.time() - t0) * 1000
if think_result:
return GroundingResult(
x=think_result.x, y=think_result.y,
method="think_vlm",
confidence=think_result.confidence,
time_ms=dt,
)
# --- Fallback : ancien pipeline ---
return self._try_fallback(target)
# ------------------------------------------------------------------
# Fallback
# ------------------------------------------------------------------
def _try_fallback(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""Tente l'ancien pipeline en dernier recours."""
if self._fallback_pipeline is None:
print(f"❌ [Pipeline] Aucune méthode n'a trouvé '{target.text}'")
return None
print(f"⚠️ [Pipeline] Fallback ancien pipeline pour '{target.text}'")
try:
return self._fallback_pipeline.locate(target)
except Exception as ex:
print(f"⚠️ [Pipeline] Fallback échoué: {ex}")
return None

View File

@@ -0,0 +1,81 @@
"""
core/grounding/fast_types.py — Structures de données pour le pipeline FAST→SMART→THINK
Utilisées exclusivement par le pipeline de localisation rapide.
Compatibles avec GroundingTarget/GroundingResult existants via conversion.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
@dataclass
class DetectedUIElement:
"""Élément UI détecté par le layer FAST (RF-DETR) puis enrichi par OCR."""
id: int
bbox: Tuple[int, int, int, int] # (x1, y1, x2, y2) pixels absolus
center: Tuple[int, int] # (cx, cy)
confidence: float # confidence détecteur (0-1)
element_type: str = "element" # "button", "input", "icon", "text", "element"
ocr_text: str = "" # texte OCR extrait de la région
neighbors: List[str] = field(default_factory=list) # textes des éléments proches
relative_position: str = "" # "top_left", "center", "bottom_right", etc.
@property
def width(self) -> int:
return self.bbox[2] - self.bbox[0]
@property
def height(self) -> int:
return self.bbox[3] - self.bbox[1]
@property
def area(self) -> int:
return self.width * self.height
@dataclass
class ScreenSnapshot:
"""État complet de l'écran à un instant t — sortie du layer FAST."""
elements: List[DetectedUIElement]
ocr_words: List[Dict[str, Any]] # mots OCR bruts [{text, bbox}]
resolution: Tuple[int, int] # (width, height)
window_title: str = ""
phash: str = ""
detection_time_ms: float = 0.0
ocr_time_ms: float = 0.0
total_time_ms: float = 0.0
@dataclass
class MatchCandidate:
"""Résultat du matching SMART pour un élément candidat."""
element: DetectedUIElement
score: float # score combiné (0-1)
score_detail: Dict[str, float] = field(default_factory=dict)
method: str = "" # "exact_text", "fuzzy_text", "position", etc.
@dataclass
class LocateResult:
"""Résultat final du pipeline FAST→SMART→THINK."""
x: int
y: int
confidence: float
method: str # "fast_exact", "fast_fuzzy", "smart_vote", "think_vlm"
time_ms: float
tier: str = "fast" # "fast", "smart", "think"
element: Optional[DetectedUIElement] = None
candidates_count: int = 0
def to_grounding_result(self):
"""Conversion vers GroundingResult pour compatibilité."""
from core.grounding.target import GroundingResult
return GroundingResult(
x=self.x, y=self.y,
method=self.method,
confidence=self.confidence,
time_ms=self.time_ms,
)

View File

@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""
core/grounding/infigui_server.py — Service grounding persistant InfiGUI
Charge InfiGUI-G1-3B en 4-bit une fois (~2.4 GB VRAM), puis sert les requêtes
de grounding via un Unix socket. Évite le coût de chargement (~10s) à chaque
appel que paie le subprocess one-shot.
Protocole (length-prefixed JSON) :
Requête : [4 octets uint32 BE = longueur] + payload JSON UTF-8
Réponse : [4 octets uint32 BE = longueur] + payload JSON UTF-8
Opérations supportées (champ "op", défaut "ground") :
- "ping"{"ok": true, "vram_gb": float, "uptime_s": float}
- "ground"{"x": int|None, "y": int|None, "confidence": float, ...}
- "shutdown"{"ok": true} puis arrêt propre du serveur
Le payload "ground" reprend exactement le format de l'ancien worker one-shot :
{"target": str, "description": str, "image_path": str, "anchor_image_path": str}
Les images restent passées via fichiers (/tmp/...) — pas de bytes sur le socket.
Lancement (manuel ou via systemd user unit rpa-grounding.service) :
cd ~/ai/rpa_vision_v3
.venv/bin/python -m core.grounding.infigui_server
Variables d'environnement :
RPA_GROUNDING_SOCKET chemin du socket (défaut: $XDG_RUNTIME_DIR/rpa-grounding.sock
sinon /tmp/rpa-grounding.sock)
RPA_GROUNDING_BACKLOG taille listen backlog (défaut: 4)
"""
from __future__ import annotations
import json
import os
import signal
import socket
import struct
import sys
import threading
import time
import traceback
from typing import Any, Dict, Optional
# Réutilise la logique de chargement et d'inférence du worker one-shot.
# load_model() et infer() sont conçus pour être appelés en process indépendant ;
# on les appelle ici dans un process unique de longue durée.
from core.grounding.infigui_worker import infer, load_model
# ── Configuration ────────────────────────────────────────────────────────
def _default_socket_path() -> str:
# /run/rpa/ est la convention "production" (RuntimeDirectory=rpa partagé
# entre les services systemd). Cohérent avec ui_tars_grounder._default_socket_path.
if os.path.isdir("/run/rpa"):
return "/run/rpa/grounding.sock"
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if runtime_dir and os.path.isdir(runtime_dir):
return os.path.join(runtime_dir, "rpa-grounding.sock")
return "/tmp/rpa-grounding.sock"
SOCKET_PATH = os.environ.get("RPA_GROUNDING_SOCKET") or _default_socket_path()
LISTEN_BACKLOG = int(os.environ.get("RPA_GROUNDING_BACKLOG", "4"))
# Limite raisonnable pour un payload JSON (la requête contient juste des chemins
# et du texte court — 4 MB suffit largement pour parer un client buggé).
MAX_PAYLOAD_BYTES = 4 * 1024 * 1024
# ── Protocole length-prefixed ────────────────────────────────────────────
def _recv_exact(conn: socket.socket, n: int) -> Optional[bytes]:
"""Lit exactement n octets ou retourne None si la connexion ferme avant."""
chunks = []
remaining = n
while remaining > 0:
chunk = conn.recv(remaining)
if not chunk:
return None
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def recv_message(conn: socket.socket) -> Optional[Dict[str, Any]]:
header = _recv_exact(conn, 4)
if header is None:
return None
(length,) = struct.unpack(">I", header)
if length == 0 or length > MAX_PAYLOAD_BYTES:
raise ValueError(f"Longueur payload invalide: {length}")
payload = _recv_exact(conn, length)
if payload is None:
return None
return json.loads(payload.decode("utf-8"))
def send_message(conn: socket.socket, obj: Dict[str, Any]) -> None:
payload = json.dumps(obj, ensure_ascii=False).encode("utf-8")
conn.sendall(struct.pack(">I", len(payload)) + payload)
# ── Serveur ──────────────────────────────────────────────────────────────
class InfiGUIServer:
"""Daemon qui sert les requêtes de grounding sur un Unix socket."""
def __init__(self, socket_path: str = SOCKET_PATH):
self.socket_path = socket_path
self._server_sock: Optional[socket.socket] = None
self._stop = threading.Event()
# CUDA n'est pas thread-safe sur le même modèle ; sérialise les inférences.
self._infer_lock = threading.Lock()
self._model = None
self._processor = None
self._start_time = time.time()
self._request_count = 0
# ── Lifecycle ────────────────────────────────────────────────────────
def start(self) -> None:
# 1. Charger le modèle AVANT d'ouvrir le socket : si le chargement échoue,
# on n'expose pas un endpoint à moitié fonctionnel aux clients.
print(f"[infigui-server] Chargement InfiGUI-G1-3B...")
self._model, self._processor = load_model()
# 2. Ouvrir le Unix socket (suppression d'un éventuel ancien socket fantôme)
try:
os.unlink(self.socket_path)
except FileNotFoundError:
pass
self._server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._server_sock.bind(self.socket_path)
# Permissions : owner-only (rw-------) pour éviter les autres utilisateurs
os.chmod(self.socket_path, 0o600)
self._server_sock.listen(LISTEN_BACKLOG)
# Petit timeout sur accept pour que la boucle réagisse aux signaux
self._server_sock.settimeout(1.0)
print(f"[infigui-server] Écoute sur {self.socket_path}")
# 3. Signaux d'arrêt propre
signal.signal(signal.SIGTERM, self._on_signal)
signal.signal(signal.SIGINT, self._on_signal)
# 4. Boucle accept
try:
while not self._stop.is_set():
try:
conn, _ = self._server_sock.accept()
except socket.timeout:
continue
except OSError:
if self._stop.is_set():
break
raise
# Une requête à la fois (CUDA non thread-safe). On gère néanmoins
# la connexion dans un thread pour pouvoir lire/écrire sans bloquer
# l'accept boucle quand le client traîne — l'inférence elle-même
# est sérialisée par self._infer_lock.
threading.Thread(
target=self._handle_client,
args=(conn,),
daemon=True,
).start()
finally:
self._cleanup()
def _on_signal(self, signum, _frame) -> None:
print(f"[infigui-server] Signal {signum} reçu, arrêt...")
self._stop.set()
# Casse un éventuel accept() bloqué
try:
if self._server_sock is not None:
self._server_sock.close()
except OSError:
pass
def _cleanup(self) -> None:
try:
if self._server_sock is not None:
self._server_sock.close()
except OSError:
pass
try:
os.unlink(self.socket_path)
except FileNotFoundError:
pass
print(f"[infigui-server] Arrêté ({self._request_count} requêtes traitées)")
# ── Gestion d'une connexion ──────────────────────────────────────────
def _handle_client(self, conn: socket.socket) -> None:
try:
# Une connexion = N requêtes (keep-alive). Le client peut envoyer
# plusieurs grounds successifs sans repayer le coût TCP/socket.
while not self._stop.is_set():
try:
req = recv_message(conn)
except (ValueError, json.JSONDecodeError) as e:
self._safe_send(conn, {"error": f"requête invalide: {e}"})
return
if req is None:
return # client a fermé proprement
op = req.get("op", "ground")
if op == "ping":
self._safe_send(conn, self._do_ping())
elif op == "shutdown":
self._safe_send(conn, {"ok": True})
self._stop.set()
try:
if self._server_sock is not None:
self._server_sock.close()
except OSError:
pass
return
elif op == "ground":
resp = self._do_ground(req)
self._safe_send(conn, resp)
else:
self._safe_send(conn, {"error": f"op inconnue: {op}"})
except Exception as e:
traceback.print_exc()
self._safe_send(conn, {"error": str(e)})
finally:
try:
conn.close()
except OSError:
pass
def _safe_send(self, conn: socket.socket, obj: Dict[str, Any]) -> None:
try:
send_message(conn, obj)
except OSError:
# Client parti ; pas de quoi paniquer
pass
# ── Opérations ───────────────────────────────────────────────────────
def _do_ping(self) -> Dict[str, Any]:
try:
import torch
vram_gb = round(torch.cuda.memory_allocated() / 1e9, 2) if torch.cuda.is_available() else 0.0
except Exception:
vram_gb = 0.0
return {
"ok": True,
"vram_gb": vram_gb,
"uptime_s": round(time.time() - self._start_time, 1),
"requests": self._request_count,
}
def _do_ground(self, req: Dict[str, Any]) -> Dict[str, Any]:
with self._infer_lock:
self._request_count += 1
try:
return infer(self._model, self._processor, req)
except Exception as e:
traceback.print_exc()
return {
"x": None,
"y": None,
"error": str(e),
}
def main() -> int:
server = InfiGUIServer()
try:
server.start()
except KeyboardInterrupt:
pass
except Exception as e:
print(f"[infigui-server] Erreur fatale: {e}")
traceback.print_exc()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Worker InfiGUI — process indépendant, communication par fichiers.
Charge le modèle, surveille /tmp/infigui_request.json, infère, écrit /tmp/infigui_response.json.
Lancement :
cd ~/ai/rpa_vision_v3
.venv/bin/python3 -m core.grounding.infigui_worker
"""
import json
import math
import os
import re
import sys
import time
import gc
import warnings
warnings.filterwarnings("ignore")
import torch
REQUEST_FILE = "/tmp/infigui_request.json"
RESPONSE_FILE = "/tmp/infigui_response.json"
READY_FILE = "/tmp/infigui_ready"
def load_model():
"""Charge InfiGUI-G1-3B en 4-bit NF4."""
torch.cuda.empty_cache()
gc.collect()
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor, BitsAndBytesConfig
model_id = "InfiX-ai/InfiGUI-G1-3B"
print(f"[infigui-worker] Chargement {model_id}...")
bnb = BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True,
)
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
model_id, quantization_config=bnb, device_map={"": "cuda:0"},
)
model.eval()
processor = AutoProcessor.from_pretrained(
model_id, padding_side="left",
min_pixels=100 * 28 * 28, max_pixels=5600 * 28 * 28,
)
vram = torch.cuda.memory_allocated() / 1e9
print(f"[infigui-worker] Prêt — VRAM: {vram:.2f}GB")
# Signal "prêt"
with open(READY_FILE, "w") as f:
f.write(f"ready {vram:.2f}GB")
return model, processor
def infer(model, processor, req):
"""Fait une inférence.
Modes :
- texte seul (target/description) : grounding classique
- fusionné (anchor_image_path présent) : on passe en plus le crop d'ancre
comme image de référence et le modèle doit retrouver cet élément sur
le screenshot. Évite la double passe describe→ground.
"""
from PIL import Image
from qwen_vl_utils import process_vision_info
target = req.get("target", "")
description = req.get("description", "")
label = f"{target}{description}" if description else target
# Image principale (screenshot complet)
image_path = req.get("image_path", "")
if image_path and os.path.exists(image_path):
img = Image.open(image_path).convert("RGB")
else:
import mss
with mss.mss() as sct:
grab = sct.grab(sct.monitors[0])
img = Image.frombytes("RGB", grab.size, grab.bgra, "raw", "BGRX")
# Image d'ancre (optionnelle) — mode fusionné describe+ground
anchor_image_path = req.get("anchor_image_path", "")
anchor_img = None
if anchor_image_path and os.path.exists(anchor_image_path):
anchor_img = Image.open(anchor_image_path).convert("RGB")
if not label.strip() and anchor_img is None:
return {"x": None, "y": None, "error": "target ou anchor_image requis"}
W, H = img.size
factor = 28
rH = max(factor, round(H / factor) * factor)
rW = max(factor, round(W / factor) * factor)
system = (
"You FIRST think about the reasoning process as an internal monologue "
"and then provide the final answer.\n"
"The reasoning process MUST BE enclosed within <think> </think> tags."
)
# Construction du prompt selon le mode
if anchor_img is not None:
# Mode fusionné : Image1 = crop d'ancre, Image2 = screenshot
hint = f' Hint: this element looks like "{label}".' if label.strip() else ""
user_text = (
f"The first image is a small crop of a UI element captured previously. "
f"The second image is the current screen ({rW}x{rH}).{hint}\n"
f"Locate on the second image the UI element that visually matches the first image. "
f"Output the coordinates using JSON format: "
f'[{{"point_2d": [x, y]}}, ...]'
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": [
{"type": "image", "image": anchor_img},
{"type": "image", "image": img},
{"type": "text", "text": user_text},
]},
]
else:
# Mode classique : texte seul
user_text = (
f'The screen\'s resolution is {rW}x{rH}.\n'
f'Locate the UI element(s) for "{label}", '
f'output the coordinates using JSON format: '
f'[{{"point_2d": [x, y]}}, ...]'
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": [
{"type": "image", "image": img},
{"type": "text", "text": user_text},
]},
]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
image_inputs, video_inputs = process_vision_info(messages)
inputs = processor(
text=[text], images=image_inputs, videos=video_inputs,
padding=True, return_tensors="pt",
).to(model.device)
t0 = time.time()
with torch.no_grad():
gen = model.generate(**inputs, max_new_tokens=512)
infer_ms = (time.time() - t0) * 1000
trimmed = [o[len(i):] for i, o in zip(inputs.input_ids, gen)]
raw = processor.batch_decode(
trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False,
)[0].strip()
mode_str = "fused" if anchor_img is not None else "text"
print(f"[infigui-worker] [{mode_str}] '{label[:40]}' ({infer_ms:.0f}ms)")
# Parser JSON point_2d
json_part = raw.split("</think>")[-1] if "</think>" in raw else raw
json_part = json_part.replace("```json", "").replace("```", "").strip()
px, py = None, None
try:
parsed = json.loads(json_part)
if isinstance(parsed, list) and len(parsed) > 0:
pt = parsed[0].get("point_2d", [])
if len(pt) >= 2:
px = int(pt[0] * W / rW)
py = int(pt[1] * H / rH)
except json.JSONDecodeError:
m = re.search(r'"point_2d"\s*:\s*\[(\d+),\s*(\d+)\]', raw)
if m:
px = int(int(m.group(1)) * W / rW)
py = int(int(m.group(2)) * H / rH)
return {
"x": px, "y": py,
"method": "infigui",
"confidence": 0.90 if px else 0.0,
"time_ms": round(infer_ms, 1),
}
def main():
"""Mode one-shot : lit une requête sur stdin, infère, écrit le résultat sur stdout."""
# Lire la requête
input_data = sys.stdin.read().strip()
if not input_data:
print(json.dumps({"x": None, "y": None, "error": "pas de requête"}))
return
try:
req = json.loads(input_data)
except json.JSONDecodeError:
print(json.dumps({"x": None, "y": None, "error": "JSON invalide"}))
return
model, processor = load_model()
result = infer(model, processor, req)
print(json.dumps(result))
if __name__ == "__main__":
main()

190
core/grounding/pipeline.py Normal file
View File

@@ -0,0 +1,190 @@
"""
core/grounding/pipeline.py — Pipeline de grounding en cascade
Orchestre les methodes de localisation dans l'ordre :
1. Template matching (TemplateMatcher, local, ~80ms)
2. OCR (docTR via input_handler, local, ~1s)
3. UI-TARS (HTTP vers serveur grounding, ~3s)
4. Static fallback (coordonnees d'origine du workflow)
Chaque methode est essayee dans l'ordre. Des qu'une reussit, on retourne
le resultat. Cela permet un equilibre entre vitesse (template) et robustesse
(UI-TARS pour les elements qui ont change de position/apparence).
Utilisation :
from core.grounding.pipeline import GroundingPipeline
from core.grounding.target import GroundingTarget
pipeline = GroundingPipeline()
result = pipeline.locate(GroundingTarget(
text="Valider",
description="bouton vert en bas",
template_b64=screenshot_b64,
original_bbox={"x": 100, "y": 200, "width": 80, "height": 30},
))
if result:
print(f"Trouve a ({result.x}, {result.y}) via {result.method}")
"""
from __future__ import annotations
import time
from typing import Optional
from core.grounding.target import GroundingTarget, GroundingResult
class GroundingPipeline:
"""Pipeline de localisation en cascade : template -> OCR -> UI-TARS -> static."""
def __init__(self, template_threshold: float = 0.75, enable_uitars: bool = True):
self.template_threshold = template_threshold
self.enable_uitars = enable_uitars
def locate(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""Localise un element UI en essayant les methodes en cascade.
Args:
target: description de l'element a localiser
Returns:
GroundingResult ou None si aucune methode ne trouve l'element
"""
t0 = time.time()
# --- Methode 1 : Template matching (~80ms) ---
result = self._try_template(target)
if result:
print(f"[GroundingPipeline] Localise via {result.method} en "
f"{(time.time() - t0) * 1000:.0f}ms")
return result
# --- Methode 2 : OCR texte (~1s) ---
result = self._try_ocr(target)
if result:
print(f"[GroundingPipeline] Localise via {result.method} en "
f"{(time.time() - t0) * 1000:.0f}ms")
return result
# --- Methode 3 : UI-TARS via serveur HTTP (~3s) ---
if self.enable_uitars:
result = self._try_uitars(target)
if result:
print(f"[GroundingPipeline] Localise via {result.method} en "
f"{(time.time() - t0) * 1000:.0f}ms")
return result
# --- Methode 4 : Fallback statique ---
result = self._try_static(target)
if result:
print(f"[GroundingPipeline] Localise via {result.method} en "
f"{(time.time() - t0) * 1000:.0f}ms")
return result
print(f"[GroundingPipeline] ECHEC: '{target.text}' introuvable "
f"(toutes methodes epuisees, {(time.time() - t0) * 1000:.0f}ms)")
return None
# ------------------------------------------------------------------
# Methodes individuelles
# ------------------------------------------------------------------
def _try_template(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""Template matching — rapide, exact, mais sensible aux changements visuels."""
if not target.template_b64:
return None
try:
from core.grounding.template_matcher import TemplateMatcher
matcher = TemplateMatcher(threshold=self.template_threshold)
match = matcher.match_screen(anchor_b64=target.template_b64)
if match:
print(f"[GroundingPipeline/template] score={match.score:.3f} "
f"pos=({match.x},{match.y}) ({match.time_ms:.0f}ms)")
return GroundingResult(
x=match.x,
y=match.y,
method='template',
confidence=match.score,
time_ms=match.time_ms,
)
else:
diag = matcher.match_screen_diagnostic(anchor_b64=target.template_b64)
print(f"[GroundingPipeline/template] pas de match — best={diag}")
except Exception as e:
print(f"[GroundingPipeline/template] ERREUR: {e}")
return None
def _try_ocr(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""OCR : cherche le texte cible sur l'ecran via docTR."""
if not target.text:
return None
try:
from core.execution.input_handler import _grounding_ocr
bbox = target.original_bbox if target.original_bbox else None
result = _grounding_ocr(target.text, anchor_bbox=bbox)
if result:
print(f"[GroundingPipeline/OCR] '{target.text}' -> ({result['x']}, {result['y']})")
return GroundingResult(
x=result['x'],
y=result['y'],
method='ocr',
confidence=result.get('confidence', 0.80),
time_ms=result.get('time_ms', 0),
)
else:
print(f"[GroundingPipeline/OCR] '{target.text}' non trouve")
except Exception as e:
print(f"[GroundingPipeline/OCR] ERREUR: {e}")
return None
def _try_uitars(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""UI-TARS via serveur HTTP — robust, gere les changements de layout."""
if not target.text and not target.description:
return None
try:
from core.grounding.ui_tars_grounder import UITarsGrounder
grounder = UITarsGrounder.get_instance()
result = grounder.ground(
target_text=target.text,
target_description=target.description,
)
if result:
print(f"[GroundingPipeline/UI-TARS] ({result.x}, {result.y}) "
f"conf={result.confidence:.2f} ({result.time_ms:.0f}ms)")
return result
else:
print(f"[GroundingPipeline/UI-TARS] pas de resultat")
except Exception as e:
print(f"[GroundingPipeline/UI-TARS] ERREUR: {e}")
return None
def _try_static(self, target: GroundingTarget) -> Optional[GroundingResult]:
"""Fallback : coordonnees d'origine du workflow (centre du bounding box)."""
bbox = target.original_bbox
if not bbox:
return None
w = bbox.get('width', 0)
h = bbox.get('height', 0)
if not w or not h:
return None
x = int(bbox.get('x', 0) + w / 2)
y = int(bbox.get('y', 0) + h / 2)
print(f"[GroundingPipeline/static] fallback ({x}, {y}) "
f"depuis bbox {bbox}")
return GroundingResult(
x=x,
y=y,
method='static_fallback',
confidence=0.30,
time_ms=0.0,
)

113
core/grounding/server.py Normal file
View File

@@ -0,0 +1,113 @@
"""Serveur grounding minimaliste — Flask single-thread, même contexte CUDA."""
import base64, io, json, math, os, re, time, gc
import torch
from flask import Flask, request, jsonify
from PIL import Image
app = Flask(__name__)
MODEL_ID = os.environ.get("GROUNDING_MODEL", "InfiX-ai/InfiGUI-G1-3B")
MIN_PIXELS = 100 * 28 * 28
MAX_PIXELS = 5600 * 28 * 28
_model = None
_processor = None
def _smart_resize(h, w, factor=28):
h_bar = max(factor, round(h/factor)*factor)
w_bar = max(factor, round(w/factor)*factor)
if h_bar*w_bar > MAX_PIXELS:
beta = math.sqrt((h*w)/MAX_PIXELS)
h_bar = math.floor(h/beta/factor)*factor
w_bar = math.floor(w/beta/factor)*factor
elif h_bar*w_bar < MIN_PIXELS:
beta = math.sqrt(MIN_PIXELS/(h*w))
h_bar = math.ceil(h*beta/factor)*factor
w_bar = math.ceil(w*beta/factor)*factor
return h_bar, w_bar
def load_model():
global _model, _processor
if _model is not None:
return
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor, BitsAndBytesConfig
torch.cuda.empty_cache(); gc.collect()
print(f"[grounding] Chargement {MODEL_ID}...")
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
_model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
MODEL_ID, quantization_config=bnb, device_map="auto")
_model.eval()
_processor = AutoProcessor.from_pretrained(MODEL_ID, min_pixels=MIN_PIXELS, max_pixels=MAX_PIXELS, padding_side="left")
print(f"[grounding] Prêt — VRAM: {torch.cuda.memory_allocated()/1e9:.2f}GB")
@app.route('/health')
def health():
return jsonify({"status": "ok", "model": MODEL_ID, "model_loaded": _model is not None,
"cuda_available": torch.cuda.is_available(),
"vram_allocated_gb": round(torch.cuda.memory_allocated()/1e9, 2)})
@app.route('/ground', methods=['POST'])
def ground():
if _model is None:
return jsonify({"error": "Modèle pas chargé"}), 503
from qwen_vl_utils import process_vision_info
data = request.json
target = data.get('target_text', '')
desc = data.get('target_description', '')
label = f"{target}{desc}" if desc else target
if not label.strip():
return jsonify({"error": "target_text requis"}), 400
# Image
if data.get('image_b64'):
raw = data['image_b64'].split(',')[1] if ',' in data['image_b64'] else data['image_b64']
img = Image.open(io.BytesIO(base64.b64decode(raw))).convert('RGB')
else:
import mss
with mss.mss() as sct:
grab = sct.grab(sct.monitors[0])
img = Image.frombytes('RGB', grab.size, grab.bgra, 'raw', 'BGRX')
W, H = img.size
rH, rW = _smart_resize(H, W)
user_text = f'The screen\'s resolution is {rW}x{rH}.\nLocate the UI element(s) for "{label}", output the coordinates using JSON format: [{{"point_2d": [x, y]}}, ...]'
system = "You FIRST think about the reasoning process as an internal monologue and then provide the final answer.\nThe reasoning process MUST BE enclosed within <think> </think> tags."
messages = [{"role": "system", "content": system},
{"role": "user", "content": [{"type": "image", "image": img}, {"type": "text", "text": user_text}]}]
text = _processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
image_inputs, video_inputs = process_vision_info(messages)
inputs = _processor(text=[text], images=image_inputs, videos=video_inputs, padding=True, return_tensors="pt").to(_model.device)
t0 = time.time()
with torch.no_grad():
gen = _model.generate(**inputs, max_new_tokens=512)
infer_ms = (time.time()-t0)*1000
trimmed = [o[len(i):] for i,o in zip(inputs.input_ids, gen)]
raw = _processor.batch_decode(trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False)[0].strip()
print(f"[grounding] '{label[:40]}'{raw[:100]} ({infer_ms:.0f}ms)")
# Parser JSON point_2d
json_part = raw.split("</think>")[-1] if "</think>" in raw else raw
json_part = json_part.replace("```json","").replace("```","").strip()
px, py = None, None
try:
parsed = json.loads(json_part)
if isinstance(parsed, list) and len(parsed) > 0:
pt = parsed[0].get("point_2d", [])
if len(pt) >= 2:
px, py = int(pt[0]*W/rW), int(pt[1]*H/rH)
except json.JSONDecodeError:
m = re.search(r'"point_2d"\s*:\s*\[(\d+),\s*(\d+)\]', raw)
if m:
px, py = int(int(m.group(1))*W/rW), int(int(m.group(2))*H/rH)
return jsonify({"x": px, "y": py, "method": "infigui", "confidence": 0.90 if px else 0.0,
"time_ms": round(infer_ms, 1), "raw_output": raw[:300]})
if __name__ == '__main__':
load_model()
app.run(host='0.0.0.0', port=8200, threaded=False)

View File

@@ -0,0 +1,156 @@
"""
core/grounding/shadow_learning_hook.py — Hook d'apprentissage Shadow
Connecte le ShadowObserver au SignatureStore : chaque clic observé pendant
une session Shadow enrichit la base de signatures d'éléments.
L'humain clique quelque part → on détecte quel élément UI est sous le clic →
on stocke sa signature (texte, type, position, voisins) pour le replay.
Ce module est un HOOK optionnel — il ne modifie pas le ShadowObserver,
il s'y branche via callback.
Utilisation :
from core.grounding.shadow_learning_hook import ShadowLearningHook
hook = ShadowLearningHook()
# Dans le ShadowObserver ou l'API de capture :
hook.on_click_observed(
click_x=542, click_y=318,
screenshot_pil=screen,
window_title="Bloc-notes",
target_label="Bouton Valider",
)
"""
from __future__ import annotations
import threading
import time
from typing import Any, Dict, Optional
from core.grounding.element_signature import SignatureStore
from core.grounding.fast_types import DetectedUIElement
class ShadowLearningHook:
"""Hook d'apprentissage pour le mode Shadow.
À chaque clic humain observé, détecte l'élément sous le clic
et enrichit le SignatureStore.
"""
def __init__(self, signature_store: Optional[SignatureStore] = None):
self._store = signature_store or SignatureStore()
self._detector = None # Lazy load pour ne pas charger RF-DETR au startup
self._lock = threading.Lock()
def on_click_observed(
self,
click_x: int,
click_y: int,
screenshot_pil: Optional[Any] = None,
window_title: str = "",
target_label: str = "",
target_description: str = "",
) -> Optional[Dict[str, Any]]:
"""Appelé quand un clic humain est observé pendant le Shadow.
Args:
click_x, click_y: Position du clic (pixels écran).
screenshot_pil: Capture d'écran PIL au moment du clic.
window_title: Titre de la fenêtre active.
target_label: Label de l'étape (si connu).
target_description: Description de l'élément (si connue).
Returns:
Dict avec la signature créée/enrichie, ou None si échec.
"""
t0 = time.time()
try:
# Lazy load du détecteur
if self._detector is None:
from core.grounding.fast_detector import FastDetector
self._detector = FastDetector()
# Détecter les éléments sur l'écran
snapshot = self._detector.detect(screenshot_pil=screenshot_pil)
if not snapshot.elements:
print(f"📝 [Shadow/learn] Aucun élément détecté à ({click_x}, {click_y})")
return None
# Trouver l'élément sous le clic
clicked_element = self._find_element_at(click_x, click_y, snapshot.elements)
if clicked_element is None:
print(f"📝 [Shadow/learn] Aucun élément sous ({click_x}, {click_y})")
return None
# Construire la clé de la cible
target_key = SignatureStore.make_target_key(
target_label or clicked_element.ocr_text,
target_description,
)
screen_ctx = SignatureStore.make_screen_context(
window_title, snapshot.resolution,
)
# Enregistrer la signature
self._store.record_success(
target_key=target_key,
screen_context=screen_ctx,
element=clicked_element,
confidence=1.0, # L'humain a cliqué → confiance maximale
)
dt = (time.time() - t0) * 1000
print(f"📝 [Shadow/learn] Signature '{clicked_element.ocr_text}' "
f"type={clicked_element.element_type} "
f"pos={clicked_element.relative_position} "
f"voisins={clicked_element.neighbors[:3]} ({dt:.0f}ms)")
return {
"target_key": target_key,
"text": clicked_element.ocr_text,
"element_type": clicked_element.element_type,
"relative_position": clicked_element.relative_position,
"neighbors": clicked_element.neighbors,
"center": clicked_element.center,
}
except Exception as e:
print(f"⚠️ [Shadow/learn] Erreur: {e}")
return None
@staticmethod
def _find_element_at(
x: int, y: int,
elements: list,
margin: int = 20,
) -> Optional[DetectedUIElement]:
"""Trouve l'élément dont la bbox contient le point (x, y).
Si aucun match exact, prend le plus proche dans un rayon de `margin` pixels.
"""
# Match exact : le clic est dans la bbox
for elem in elements:
x1, y1, x2, y2 = elem.bbox
if x1 <= x <= x2 and y1 <= y <= y2:
return elem
# Match par proximité : le clic est proche du centre
best_elem = None
best_dist = float('inf')
for elem in elements:
dx = abs(elem.center[0] - x)
dy = abs(elem.center[1] - y)
dist = (dx**2 + dy**2) ** 0.5
if dist < margin and dist < best_dist:
best_dist = dist
best_elem = elem
return best_elem

View File

@@ -0,0 +1,263 @@
"""
core/grounding/smart_matcher.py — Layer SMART : matching déterministe/probabiliste
Étant donné un ScreenSnapshot (tous les éléments détectés) et un GroundingTarget
(ce qu'on cherche), trouve l'élément correspondant avec un score de confiance.
Pipeline de matching (court-circuit au premier match haute confiance) :
1. Texte exact (2ms) → score 0.95
2. Texte fuzzy ratio (5ms) → score 0.70-0.90
3. Type + position (2ms) → bonus/malus
4. Voisins contextuels (5ms) → bonus
5. Score combiné → MatchCandidate
Utilisation :
from core.grounding.smart_matcher import SmartMatcher
from core.grounding.fast_types import ScreenSnapshot
from core.grounding.target import GroundingTarget
matcher = SmartMatcher()
candidate = matcher.match(snapshot, GroundingTarget(text="Valider"))
if candidate and candidate.score >= 0.90:
print(f"Match direct : ({candidate.element.center}) score={candidate.score}")
"""
from __future__ import annotations
import re
from difflib import SequenceMatcher
from typing import Dict, List, Optional
from core.grounding.fast_types import DetectedUIElement, MatchCandidate, ScreenSnapshot
from core.grounding.target import GroundingTarget
class SmartMatcher:
"""Matching intelligent entre une cible et les éléments détectés.
Combine plusieurs signaux (texte, type, position, voisins) en un score
de confiance unique pour chaque candidat.
"""
def __init__(
self,
weight_text: float = 0.50,
weight_type: float = 0.10,
weight_position: float = 0.15,
weight_neighbors: float = 0.25,
):
self.w_text = weight_text
self.w_type = weight_type
self.w_position = weight_position
self.w_neighbors = weight_neighbors
def match(
self,
snapshot: ScreenSnapshot,
target: GroundingTarget,
signature: Optional[Dict] = None,
) -> Optional[MatchCandidate]:
"""Trouve le MEILLEUR élément correspondant à la cible.
Returns:
Le MatchCandidate avec le score le plus élevé, ou None si aucun match.
"""
candidates = self.match_all(snapshot, target, signature)
if not candidates:
return None
return candidates[0]
def match_all(
self,
snapshot: ScreenSnapshot,
target: GroundingTarget,
signature: Optional[Dict] = None,
) -> List[MatchCandidate]:
"""Trouve TOUS les candidats triés par score décroissant.
Args:
snapshot: État de l'écran (éléments détectés + OCR).
target: Ce qu'on cherche (texte, description, bbox d'origine).
signature: Signature apprise (optionnel, enrichit le matching).
Returns:
Liste de MatchCandidate triée par score décroissant.
"""
if not snapshot.elements:
return []
target_text = (target.text or "").strip()
target_desc = (target.description or "").strip()
search_text = target_text or target_desc
if not search_text:
return []
candidates = []
search_lower = self._normalize(search_text)
for elem in snapshot.elements:
score_detail: Dict[str, float] = {}
method = ""
# --- 1. Score texte ---
text_score = self._score_text(search_lower, elem.ocr_text)
score_detail["text"] = text_score
if text_score >= 0.95:
method = "exact_text"
elif text_score >= 0.70:
method = "fuzzy_text"
# --- 2. Score type (si signature connue) ---
type_score = 0.5 # neutre par défaut
if signature and signature.get("element_type"):
if elem.element_type == signature["element_type"]:
type_score = 1.0
elif elem.element_type == "element":
type_score = 0.5 # non classifié, neutre
else:
type_score = 0.2
score_detail["type"] = type_score
# --- 3. Score position (si bbox d'origine connue) ---
position_score = 0.5 # neutre
if target.original_bbox:
position_score = self._score_position(
elem.center, target.original_bbox,
snapshot.resolution[0], snapshot.resolution[1],
)
elif signature and signature.get("relative_position"):
if elem.relative_position == signature["relative_position"]:
position_score = 0.9
else:
position_score = 0.3
score_detail["position"] = position_score
# --- 4. Score voisins (si signature connue) ---
neighbor_score = 0.5 # neutre
if signature and signature.get("neighbors"):
neighbor_score = self._score_neighbors(
elem.neighbors, signature["neighbors"]
)
score_detail["neighbors"] = neighbor_score
# --- Score combiné ---
combined = (
self.w_text * text_score
+ self.w_type * type_score
+ self.w_position * position_score
+ self.w_neighbors * neighbor_score
)
# Seuil minimum : pas de candidat si le texte ne matche pas du tout
if text_score < 0.30:
continue
if not method:
method = "combined"
candidates.append(MatchCandidate(
element=elem,
score=combined,
score_detail=score_detail,
method=method,
))
# Trier par score décroissant
candidates.sort(key=lambda c: c.score, reverse=True)
return candidates
# ------------------------------------------------------------------
# Scoring texte
# ------------------------------------------------------------------
def _score_text(self, search: str, ocr_text: str) -> float:
"""Score de similarité textuelle (0-1)."""
if not ocr_text:
return 0.0
ocr_lower = self._normalize(ocr_text)
# Match exact
if search == ocr_lower:
return 1.0
# Inclusion (l'un contient l'autre)
if search in ocr_lower or ocr_lower in search:
overlap = min(len(search), len(ocr_lower))
total = max(len(search), len(ocr_lower))
if total > 0:
return 0.70 + 0.25 * (overlap / total)
# Fuzzy matching (SequenceMatcher, standard library)
ratio = SequenceMatcher(None, search, ocr_lower).ratio()
if ratio >= 0.60:
return 0.50 + 0.40 * ratio
return ratio * 0.3
# ------------------------------------------------------------------
# Scoring position
# ------------------------------------------------------------------
@staticmethod
def _score_position(
center: tuple,
original_bbox: dict,
screen_w: int,
screen_h: int,
) -> float:
"""Score de proximité par rapport à la position d'origine (0-1)."""
if not original_bbox:
return 0.5
orig_x = original_bbox.get("x", 0) + original_bbox.get("width", 0) / 2
orig_y = original_bbox.get("y", 0) + original_bbox.get("height", 0) / 2
dx = abs(center[0] - orig_x) / max(screen_w, 1)
dy = abs(center[1] - orig_y) / max(screen_h, 1)
distance_norm = (dx**2 + dy**2) ** 0.5
# distance 0 = score 1.0, distance 0.5 (demi-écran) = score ~0.2
return max(0.0, 1.0 - distance_norm * 2.0)
# ------------------------------------------------------------------
# Scoring voisins
# ------------------------------------------------------------------
@staticmethod
def _score_neighbors(
current_neighbors: List[str],
expected_neighbors: List[str],
) -> float:
"""Score Jaccard sur les ensembles de mots voisins (0-1)."""
if not expected_neighbors:
return 0.5
current_set = {n.lower().strip() for n in current_neighbors if n}
expected_set = {n.lower().strip() for n in expected_neighbors if n}
if not current_set and not expected_set:
return 0.5
intersection = current_set & expected_set
union = current_set | expected_set
if not union:
return 0.5
return len(intersection) / len(union)
# ------------------------------------------------------------------
# Utilitaires
# ------------------------------------------------------------------
@staticmethod
def _normalize(text: str) -> str:
"""Normalise un texte pour la comparaison."""
text = text.lower().strip()
text = re.sub(r'[_\-\./\\]', ' ', text)
text = re.sub(r'\s+', ' ', text)
return text

View File

@@ -0,0 +1,77 @@
"""
Smart resize officiel Qwen3-VL (algorithme commun Qwen2-VL/Qwen3-VL pour images).
Source de référence : transformers.models.qwen2_vl.image_processing_qwen2_vl.smart_resize
(transformers 4.57.3). Qwen3-VL utilise Qwen2VLImageProcessor pour les images via
Qwen3VLProcessor.image_processor_class — la formule est donc commune Qwen2-VL/Qwen3-VL
sur le pipeline image.
Conditions garanties par smart_resize :
1. height et width retournés divisibles par `factor` (par défaut 28).
2. Total pixels dans l'intervalle [min_pixels, max_pixels].
3. Aspect ratio conservé au plus près.
Module image-only. Pour traitement vidéo Qwen3-VL (factor=32, autres bornes),
module dédié à créer si besoin futur.
"""
# DETTE-007 — Trois implémentations smart_resize coexistent dans le repo
# (core/grounding/server.py:15, core/grounding/infigui_worker.py:99, ce module).
# Unification post-démo Kerella.
import math
FACTOR_DEFAULT = 28
MIN_PIXELS_DEFAULT = 56 * 56 # 3136
MAX_PIXELS_DEFAULT = 14 * 14 * 4 * 1280 # 1_003_520
MAX_RATIO_DEFAULT = 200
def _round_by_factor(number: int, factor: int) -> int:
"""Closest integer to `number` divisible by `factor`."""
return round(number / factor) * factor
def _floor_by_factor(number: int, factor: int) -> int:
"""Largest integer ≤ `number` divisible by `factor`."""
return math.floor(number / factor) * factor
def _ceil_by_factor(number: int, factor: int) -> int:
"""Smallest integer ≥ `number` divisible by `factor`."""
return math.ceil(number / factor) * factor
def smart_resize(
height: int,
width: int,
factor: int = FACTOR_DEFAULT,
min_pixels: int = MIN_PIXELS_DEFAULT,
max_pixels: int = MAX_PIXELS_DEFAULT,
) -> tuple[int, int]:
"""Rescale (height, width) to satisfy the three conditions of the module docstring.
Raises:
ValueError: if max(height, width) / min(height, width) > MAX_RATIO_DEFAULT
(aspect ratio out of supported domain).
Returns:
(resized_height, resized_width).
"""
if max(height, width) / min(height, width) > MAX_RATIO_DEFAULT:
raise ValueError(
f"absolute aspect ratio must be smaller than {MAX_RATIO_DEFAULT}, "
f"got {max(height, width) / min(height, width)}"
)
h_bar = round(height / factor) * factor
w_bar = round(width / factor) * factor
if h_bar * w_bar > max_pixels:
beta = math.sqrt((height * width) / max_pixels)
h_bar = max(factor, math.floor(height / beta / factor) * factor)
w_bar = max(factor, math.floor(width / beta / factor) * factor)
elif h_bar * w_bar < min_pixels:
beta = math.sqrt(min_pixels / (height * width))
h_bar = math.ceil(height * beta / factor) * factor
w_bar = math.ceil(width * beta / factor) * factor
return h_bar, w_bar

48
core/grounding/target.py Normal file
View File

@@ -0,0 +1,48 @@
"""
core/grounding/target.py — Types partagés pour le grounding visuel
Dataclasses décrivant une cible à localiser (GroundingTarget) et
le résultat d'une localisation (GroundingResult).
Ces types sont la brique commune pour tous les modules de grounding :
template matching, OCR, VLM, CLIP, etc.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class GroundingTarget:
"""Description d'un élément UI à localiser sur l'écran.
Attributs :
text : texte visible de l'élément (bouton, label, etc.)
description : description sémantique libre (ex: "le bouton Valider en bas à droite")
template_b64 : capture visuelle de l'élément, encodée en base64 PNG/JPEG
original_bbox : position d'origine lors de la capture {x, y, width, height}
"""
text: str = ""
description: str = ""
template_b64: str = ""
original_bbox: Optional[Dict[str, int]] = field(default=None)
@dataclass
class GroundingResult:
"""Résultat d'une localisation d'élément UI.
Attributs :
x : coordonnée X du centre de l'élément trouvé (pixels écran)
y : coordonnée Y du centre de l'élément trouvé (pixels écran)
method : méthode ayant produit le résultat ('template', 'ocr', 'vlm', 'clip', etc.)
confidence : score de confiance [0.0 1.0]
time_ms : temps de recherche en millisecondes
"""
x: int
y: int
method: str
confidence: float
time_ms: float

View File

@@ -0,0 +1,350 @@
"""
core/grounding/template_matcher.py — Template matching centralisé
Fournit une classe TemplateMatcher qui localise une ancre visuelle (image template)
dans un screenshot via cv2.matchTemplate. Supporte single-scale et multi-scale.
Remplace les implémentations dupliquées dans :
- core/execution/observe_reason_act.py (~1348-1375)
- visual_workflow_builder/backend/api_v3/execute.py (~930-963)
- visual_workflow_builder/backend/catalog_routes_v2_vlm.py (~339-381)
- visual_workflow_builder/backend/services/intelligent_executor.py (~131-210)
- core/detection/omniparser_adapter.py (~330)
Utilisation :
from core.grounding import TemplateMatcher, MatchResult
matcher = TemplateMatcher(threshold=0.75)
result = matcher.match_screen(anchor_b64="...")
if result:
print(f"Trouvé à ({result.x}, {result.y}) score={result.score:.3f}")
"""
from __future__ import annotations
import base64
import io
import logging
import time
from dataclasses import dataclass
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
# Imports optionnels — le module se charge même sans cv2/PIL/mss
try:
import cv2
_CV2 = True
except ImportError:
_CV2 = False
try:
import numpy as np
_NP = True
except ImportError:
_NP = False
try:
from PIL import Image
_PIL = True
except ImportError:
_PIL = False
try:
import mss as mss_lib
_MSS = True
except ImportError:
_MSS = False
# ---------------------------------------------------------------------------
# Résultat d'un match
# ---------------------------------------------------------------------------
@dataclass
class MatchResult:
"""Résultat d'un template matching."""
x: int
y: int
score: float
method: str # 'template' | 'template_multiscale'
time_ms: float
scale: float = 1.0 # Échelle à laquelle le meilleur match a été trouvé
# ---------------------------------------------------------------------------
# TemplateMatcher
# ---------------------------------------------------------------------------
class TemplateMatcher:
"""Localise une ancre visuelle dans un screenshot via template matching.
Paramètres :
threshold : score minimum pour accepter un match (défaut 0.75)
multiscale : active le matching multi-échelle (défaut False)
scales : liste d'échelles à tester en mode multi-scale
method : méthode cv2 (défaut cv2.TM_CCOEFF_NORMED)
grayscale : convertir en niveaux de gris avant matching (défaut False)
"""
# Échelles par défaut pour le mode multi-scale, ordonnées par
# probabilité décroissante (1.0 en premier = rapide si ça matche)
DEFAULT_SCALES: List[float] = [1.0, 0.95, 1.05, 0.9, 1.1, 0.85, 1.15, 0.8, 1.2]
def __init__(
self,
threshold: float = 0.75,
multiscale: bool = False,
scales: Optional[List[float]] = None,
grayscale: bool = False,
):
self.threshold = threshold
self.multiscale = multiscale
self.scales = scales or self.DEFAULT_SCALES
self.grayscale = grayscale
# cv2.TM_CCOEFF_NORMED est la méthode utilisée partout dans le projet
self._cv2_method = cv2.TM_CCOEFF_NORMED if _CV2 else None
# ------------------------------------------------------------------
# API publique
# ------------------------------------------------------------------
def match_screen(
self,
anchor_b64: Optional[str] = None,
anchor_pil: Optional["Image.Image"] = None,
screen_pil: Optional["Image.Image"] = None,
) -> Optional[MatchResult]:
"""Cherche l'ancre dans le screenshot courant (ou fourni).
L'ancre peut être passée en base64 ou en PIL Image.
Le screenshot est capturé via mss si non fourni.
Retourne un MatchResult ou None si aucun match >= seuil.
"""
if not (_CV2 and _NP and _PIL):
logger.debug("[TemplateMatcher] cv2/numpy/PIL non disponible")
return None
# --- Préparer l'ancre ---
anchor_img = self._decode_anchor(anchor_b64, anchor_pil)
if anchor_img is None:
return None
# --- Préparer le screenshot ---
if screen_pil is None:
screen_pil = self._capture_screen()
if screen_pil is None:
return None
# --- Convertir en arrays cv2 ---
screen_cv = cv2.cvtColor(np.array(screen_pil), cv2.COLOR_RGB2BGR)
anchor_cv = cv2.cvtColor(np.array(anchor_img), cv2.COLOR_RGB2BGR)
# --- Matching ---
if self.multiscale:
return self._match_multiscale(screen_cv, anchor_cv)
else:
return self._match_single(screen_cv, anchor_cv)
def match_in_region(
self,
region_cv: "np.ndarray",
anchor_cv: "np.ndarray",
threshold: Optional[float] = None,
) -> Optional[MatchResult]:
"""Match dans une région déjà découpée (arrays BGR).
Utilisé par les pipelines qui font leur propre capture/découpe.
"""
if not (_CV2 and _NP):
return None
thr = threshold if threshold is not None else self.threshold
if self.multiscale:
return self._match_multiscale(region_cv, anchor_cv, threshold_override=thr)
else:
return self._match_single(region_cv, anchor_cv, threshold_override=thr)
def match_screen_diagnostic(
self,
anchor_b64: Optional[str] = None,
anchor_pil: Optional["Image.Image"] = None,
screen_pil: Optional["Image.Image"] = None,
) -> str:
"""Retourne un diagnostic textuel (score + position) même sans match."""
if not (_CV2 and _NP and _PIL):
return "cv2/numpy/PIL non dispo"
anchor_img = self._decode_anchor(anchor_b64, anchor_pil)
if anchor_img is None:
return "ancre non décodable"
if screen_pil is None:
screen_pil = self._capture_screen()
if screen_pil is None:
return "capture écran échouée"
screen_cv = cv2.cvtColor(np.array(screen_pil), cv2.COLOR_RGB2BGR)
anchor_cv = cv2.cvtColor(np.array(anchor_img), cv2.COLOR_RGB2BGR)
if anchor_cv.shape[0] >= screen_cv.shape[0] or anchor_cv.shape[1] >= screen_cv.shape[1]:
return f"ancre {anchor_cv.shape[:2]} >= écran {screen_cv.shape[:2]}"
s_img, a_img = self._maybe_grayscale(screen_cv, anchor_cv)
result_tm = cv2.matchTemplate(s_img, a_img, self._cv2_method)
_, max_val, _, max_loc = cv2.minMaxLoc(result_tm)
return f"{max_val:.3f} pos={max_loc}"
# ------------------------------------------------------------------
# Méthodes internes
# ------------------------------------------------------------------
def _match_single(
self,
screen_cv: "np.ndarray",
anchor_cv: "np.ndarray",
threshold_override: Optional[float] = None,
) -> Optional[MatchResult]:
"""Template matching single-scale."""
threshold = threshold_override if threshold_override is not None else self.threshold
if anchor_cv.shape[0] >= screen_cv.shape[0] or anchor_cv.shape[1] >= screen_cv.shape[1]:
logger.debug("[TemplateMatcher] Ancre plus grande que le screen")
return None
s_img, a_img = self._maybe_grayscale(screen_cv, anchor_cv)
t0 = time.time()
result_tm = cv2.matchTemplate(s_img, a_img, self._cv2_method)
_, max_val, _, max_loc = cv2.minMaxLoc(result_tm)
elapsed_ms = (time.time() - t0) * 1000
logger.debug(
"[TemplateMatcher] score=%.3f pos=%s (%.0fms)",
max_val, max_loc, elapsed_ms,
)
if max_val >= threshold:
cx = max_loc[0] + anchor_cv.shape[1] // 2
cy = max_loc[1] + anchor_cv.shape[0] // 2
return MatchResult(
x=cx,
y=cy,
score=float(max_val),
method='template',
time_ms=elapsed_ms,
scale=1.0,
)
return None
def _match_multiscale(
self,
screen_cv: "np.ndarray",
anchor_cv: "np.ndarray",
threshold_override: Optional[float] = None,
) -> Optional[MatchResult]:
"""Template matching multi-scale."""
threshold = threshold_override if threshold_override is not None else self.threshold
best_score = -1.0
best_loc = None
best_scale = 1.0
best_anchor_shape = anchor_cv.shape
t0 = time.time()
for scale in self.scales:
if scale == 1.0:
scaled = anchor_cv
else:
new_w = int(anchor_cv.shape[1] * scale)
new_h = int(anchor_cv.shape[0] * scale)
if new_w < 8 or new_h < 8:
continue
if new_h >= screen_cv.shape[0] or new_w >= screen_cv.shape[1]:
continue
scaled = cv2.resize(anchor_cv, (new_w, new_h), interpolation=cv2.INTER_AREA)
if scaled.shape[0] >= screen_cv.shape[0] or scaled.shape[1] >= screen_cv.shape[1]:
continue
s_img, a_img = self._maybe_grayscale(screen_cv, scaled)
result_tm = cv2.matchTemplate(s_img, a_img, self._cv2_method)
_, max_val, _, max_loc = cv2.minMaxLoc(result_tm)
if max_val > best_score:
best_score = max_val
best_loc = max_loc
best_scale = scale
best_anchor_shape = scaled.shape
elapsed_ms = (time.time() - t0) * 1000
logger.debug(
"[TemplateMatcher/multiscale] best_score=%.3f scale=%.2f (%.0fms)",
best_score, best_scale, elapsed_ms,
)
if best_score >= threshold and best_loc is not None:
cx = best_loc[0] + best_anchor_shape[1] // 2
cy = best_loc[1] + best_anchor_shape[0] // 2
return MatchResult(
x=cx,
y=cy,
score=float(best_score),
method='template_multiscale',
time_ms=elapsed_ms,
scale=best_scale,
)
return None
def _maybe_grayscale(
self,
screen: "np.ndarray",
anchor: "np.ndarray",
) -> Tuple["np.ndarray", "np.ndarray"]:
"""Convertit en niveaux de gris si self.grayscale est True."""
if not self.grayscale:
return screen, anchor
s = cv2.cvtColor(screen, cv2.COLOR_BGR2GRAY) if len(screen.shape) == 3 else screen
a = cv2.cvtColor(anchor, cv2.COLOR_BGR2GRAY) if len(anchor.shape) == 3 else anchor
return s, a
@staticmethod
def _decode_anchor(
anchor_b64: Optional[str],
anchor_pil: Optional["Image.Image"],
) -> Optional["Image.Image"]:
"""Décode l'ancre depuis base64 ou retourne le PIL directement."""
if anchor_pil is not None:
return anchor_pil
if anchor_b64 is None:
logger.debug("[TemplateMatcher] Ni anchor_b64 ni anchor_pil fourni")
return None
try:
raw = anchor_b64.split(',')[1] if ',' in anchor_b64 else anchor_b64
data = base64.b64decode(raw)
return Image.open(io.BytesIO(data))
except Exception as e:
logger.debug("[TemplateMatcher] Erreur décodage ancre: %s", e)
return None
@staticmethod
def _capture_screen() -> Optional["Image.Image"]:
"""Capture l'écran complet via mss (moniteur 0 = tous les écrans)."""
if not _MSS:
logger.debug("[TemplateMatcher] mss non disponible")
return None
try:
with mss_lib.mss() as sct:
mon = sct.monitors[0]
grab = sct.grab(mon)
return Image.frombytes('RGB', grab.size, grab.bgra, 'raw', 'BGRX')
except Exception as e:
logger.debug("[TemplateMatcher] Erreur capture écran: %s", e)
return None

View File

@@ -0,0 +1,103 @@
"""
core/grounding/think_arbiter.py — Layer THINK : VLM arbitre (InfiGUI via subprocess)
Appelé UNIQUEMENT quand le SmartMatcher n'a pas assez confiance.
Utilise le subprocess worker InfiGUI (pas de serveur HTTP).
Utilisation :
from core.grounding.think_arbiter import ThinkArbiter
arbiter = ThinkArbiter()
result = arbiter.arbitrate(target, candidates, screenshot)
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
from core.grounding.fast_types import LocateResult, MatchCandidate
from core.grounding.target import GroundingTarget
class ThinkArbiter:
"""Arbitre VLM — appelle InfiGUI via subprocess worker."""
def __init__(self):
self._grounder = None
def _get_grounder(self):
if self._grounder is None:
from core.grounding.ui_tars_grounder import UITarsGrounder
self._grounder = UITarsGrounder.get_instance()
return self._grounder
@property
def available(self) -> bool:
"""Toujours disponible — le worker se lance à la demande."""
return True
def arbitrate(
self,
target: GroundingTarget,
candidates: List[MatchCandidate],
screenshot_pil: Optional[Any] = None,
) -> Optional[LocateResult]:
"""Demande au VLM de trancher.
Si target.template_b64 est fourni, on bascule en mode fusionné :
le crop est passé comme image de référence à InfiGUI, ce qui évite
une description Ollama qwen2.5vl coûteuse en VRAM.
"""
t0 = time.time()
# Décodage du crop d'ancre si disponible (mode fusionné)
anchor_pil = None
if target.template_b64:
try:
import base64
import io
from PIL import Image
raw_b64 = target.template_b64
if ',' in raw_b64:
raw_b64 = raw_b64.split(',', 1)[1]
anchor_pil = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGB")
except Exception as ex:
print(f"⚠️ [THINK] Décodage anchor échoué: {ex}")
anchor_pil = None
try:
grounder = self._get_grounder()
result = grounder.ground(
target_text=target.text or "",
target_description=target.description or "",
screen_pil=screenshot_pil,
anchor_pil=anchor_pil,
)
dt = (time.time() - t0) * 1000
if result is None:
label = target.text or "<crop>"
print(f"🤔 [THINK] VLM n'a pas trouvé '{label}' ({dt:.0f}ms)")
return None
method = "think_vlm_fused" if anchor_pil is not None else "think_vlm"
locate = LocateResult(
x=result.x,
y=result.y,
confidence=result.confidence,
method=method,
time_ms=dt,
tier="think",
candidates_count=len(candidates),
)
print(f"🤔 [THINK/{method}] ({result.x}, {result.y}) conf={result.confidence:.2f} ({dt:.0f}ms)")
return locate
except Exception as ex:
dt = (time.time() - t0) * 1000
print(f"⚠️ [THINK] Erreur: {ex} ({dt:.0f}ms)")
return None

View File

@@ -0,0 +1,174 @@
"""
core/grounding/title_verifier.py — Vérification post-action par titre de fenêtre
Après chaque action (clic, double-clic), vérifie que la fenêtre active
a changé de manière attendue en lisant le titre via OCR sur un crop
de 45px en haut de l'écran.
Léger (~120ms), non-bloquant (échec = warning + retry, pas stop).
Utilisation :
from core.grounding.title_verifier import TitleVerifier
verifier = TitleVerifier()
title = verifier.read_title(screenshot_pil)
changed = verifier.has_title_changed(title_before, title_after)
"""
from __future__ import annotations
import time
from difflib import SequenceMatcher
from typing import Optional
class TitleVerifier:
"""Vérifie le titre de la fenêtre active via OCR sur crop."""
# Hauteur du crop pour la barre de titre Windows
TITLE_BAR_HEIGHT = 45
def __init__(self):
self._ocr_fn = None # Lazy load
def read_title(self, screenshot_pil) -> str:
"""Lit le titre de la fenêtre active via OCR sur le crop supérieur.
Args:
screenshot_pil: Image PIL du screenshot complet.
Returns:
Texte du titre (peut être vide si OCR échoue).
"""
t0 = time.time()
try:
w, h = screenshot_pil.size
# Crop la barre de titre (45px du haut)
title_crop = screenshot_pil.crop((0, 0, w, min(self.TITLE_BAR_HEIGHT, h)))
# OCR sur le petit crop
ocr_fn = self._get_ocr()
if ocr_fn is None:
return ""
text = ocr_fn(title_crop)
dt = (time.time() - t0) * 1000
# Nettoyer le texte
title = text.strip() if text else ""
if title:
print(f"📋 [TitleVerify] Titre lu: '{title[:60]}' ({dt:.0f}ms)")
return title
except Exception as e:
print(f"⚠️ [TitleVerify] Erreur lecture titre: {e}")
return ""
def has_title_changed(self, title_before: str, title_after: str) -> bool:
"""Vérifie si le titre a changé de manière significative."""
if not title_before and not title_after:
return False
if not title_before or not title_after:
return True # Un des deux est vide = changement
# Comparaison fuzzy — les titres peuvent avoir des variations mineures
ratio = SequenceMatcher(None, title_before.lower(), title_after.lower()).ratio()
return ratio < 0.85 # Changement si < 85% similaire
def verify_action(
self,
screenshot_before,
screenshot_after,
action_type: str,
) -> dict:
"""Vérifie qu'une action a produit l'effet attendu sur le titre.
Args:
screenshot_before: Screenshot PIL avant l'action.
screenshot_after: Screenshot PIL après l'action.
action_type: Type d'action ("double_click", "click", "type", "hotkey").
Returns:
Dict avec success, title_before, title_after, changed.
"""
# Les actions qui ne changent pas le titre
if action_type in ('type_text', 'keyboard_shortcut', 'wait_for_anchor', 'hover'):
return {
'success': True,
'title_before': '',
'title_after': '',
'changed': False,
'reason': f"Action '{action_type}' — vérification titre non requise",
}
title_before = self.read_title(screenshot_before)
title_after = self.read_title(screenshot_after)
changed = self.has_title_changed(title_before, title_after)
# Pour un double-clic (ouverture fichier/dossier), le titre DOIT changer
# Mais seulement si les titres lus sont significatifs (> 3 chars)
# docTR sur un crop 45px dans une VM peut donner du bruit ('o', 'a', etc.)
if action_type in ('double_click_anchor',) and not changed:
if len(title_before) > 3 and len(title_after) > 3:
return {
'success': False,
'title_before': title_before,
'title_after': title_after,
'changed': False,
'reason': f"Double-clic sans changement de titre ('{title_after[:40]}')",
}
# Titres trop courts = bruit OCR, on ne peut pas conclure
return {
'success': True,
'title_before': title_before,
'title_after': title_after,
'changed': False,
'reason': f"Titre trop court pour vérifier ('{title_after}')",
}
# Pour un clic simple, le changement est optionnel
return {
'success': True,
'title_before': title_before,
'title_after': title_after,
'changed': changed,
'reason': 'Titre changé' if changed else 'Titre identique (acceptable)',
}
_easyocr_reader = None # Singleton partagé
def _get_ocr(self):
"""Lazy load de la fonction OCR (EasyOCR prioritaire, fallback docTR)."""
if self._ocr_fn is not None:
return self._ocr_fn
# EasyOCR (rapide, bonne qualité GUI)
try:
import easyocr
import numpy as np
if TitleVerifier._easyocr_reader is None:
TitleVerifier._easyocr_reader = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
)
def _easyocr_extract_text(img):
results = TitleVerifier._easyocr_reader.readtext(np.array(img))
return ' '.join(r[1] for r in results if r[1].strip())
self._ocr_fn = _easyocr_extract_text
return self._ocr_fn
except ImportError:
pass
# Fallback docTR
try:
import sys
sys.path.insert(0, 'visual_workflow_builder/backend')
from services.ocr_service import ocr_extract_text
self._ocr_fn = ocr_extract_text
return self._ocr_fn
except ImportError:
return None

View File

@@ -0,0 +1,288 @@
"""
core/grounding/ui_tars_grounder.py — Grounding InfiGUI (service persistant + fallback subprocess)
Stratégie d'appel (avril 2026) :
1. Tenter d'abord le service persistant via Unix socket (rpa-grounding.service).
→ ~1-2 s par requête (modèle déjà en VRAM).
2. Si le socket n'est pas disponible (service down, env dev sans systemd),
retomber sur l'ancien subprocess one-shot (~15 s, mais fiable).
Le fallback subprocess est conservé en filet de sécurité pendant 2-3 semaines
le temps de valider la stabilité du service en production.
"""
from __future__ import annotations
import json
import os
import socket
import struct
import subprocess
import sys
import threading
import time
from typing import Any, Dict, Optional
from core.grounding.target import GroundingResult
_instance: Optional["UITarsGrounder"] = None
_instance_lock = threading.Lock()
# ── Configuration du client socket ──────────────────────────────────────
def _default_socket_path() -> str:
# /run/rpa/ est la convention "production" (RuntimeDirectory=rpa partagé
# entre les services systemd). On la préfère au XDG_RUNTIME_DIR pour qu'un
# client lancé en CLI sur la même machine retrouve le socket du service.
if os.path.isdir("/run/rpa"):
return "/run/rpa/grounding.sock"
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if runtime_dir and os.path.isdir(runtime_dir):
return os.path.join(runtime_dir, "rpa-grounding.sock")
return "/tmp/rpa-grounding.sock"
# Timeout connexion court : si le service est down, on bascule vite sur subprocess.
SOCKET_CONNECT_TIMEOUT = 0.5
# Timeout réponse plus large : l'inférence peut prendre 1-3s.
SOCKET_REQUEST_TIMEOUT = 30.0
def _image_dir() -> str:
"""Répertoire pour les images partagées entre client et serveur grounding.
En production systemd, tous les services concernés ont PrivateTmp=true →
leur /tmp est isolé et les fichiers ne sont pas visibles côté serveur.
Solution : utiliser /run/rpa/ via RuntimeDirectory=rpa (partagé entre
services qui déclarent ce RuntimeDirectory). En dev, fallback sur /tmp.
"""
candidate = os.environ.get("RPA_GROUNDING_IMG_DIR")
if candidate and os.path.isdir(candidate):
return candidate
if os.path.isdir("/run/rpa"):
return "/run/rpa"
return "/tmp"
def _send_socket_request(req: Dict[str, Any], socket_path: str) -> Optional[Dict[str, Any]]:
"""Envoie une requête au service persistant. Retourne None si le service
n'est pas joignable (socket absent / connexion refusée / timeout)."""
if not os.path.exists(socket_path):
return None
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(SOCKET_CONNECT_TIMEOUT)
sock.connect(socket_path)
sock.settimeout(SOCKET_REQUEST_TIMEOUT)
payload = json.dumps(req, ensure_ascii=False).encode("utf-8")
sock.sendall(struct.pack(">I", len(payload)) + payload)
# Lecture réponse length-prefixed
header = b""
while len(header) < 4:
chunk = sock.recv(4 - len(header))
if not chunk:
return None
header += chunk
(length,) = struct.unpack(">I", header)
body = b""
while len(body) < length:
chunk = sock.recv(min(65536, length - len(body)))
if not chunk:
return None
body += chunk
try:
sock.close()
except OSError:
pass
return json.loads(body.decode("utf-8"))
except (socket.timeout, ConnectionRefusedError, FileNotFoundError, OSError):
return None
except Exception as e: # pragma: no cover
print(f"⚠️ [InfiGUI/socket] Erreur inattendue: {e}")
return None
class UITarsGrounder:
"""Grounding InfiGUI : socket persistant en priorité, subprocess en secours."""
def __init__(self):
self._lock = threading.Lock()
self._project_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
self._socket_path = os.environ.get("RPA_GROUNDING_SOCKET") or _default_socket_path()
# On marque l'absence du service après un échec pour éviter de payer le
# coût "tester le socket" à chaque appel pendant une session sans service.
# Re-test toutes les 30s au cas où le service serait relancé.
self._service_unavailable_until: float = 0.0
@classmethod
def get_instance(cls) -> UITarsGrounder:
global _instance
if _instance is None:
with _instance_lock:
if _instance is None:
_instance = cls()
return _instance
@property
def available(self) -> bool:
return True # Toujours disponible — le script se lance à la demande
def ground(
self,
target_text: str = "",
target_description: str = "",
screen_pil=None,
anchor_pil=None,
) -> Optional[GroundingResult]:
"""Localise un élément UI via un script one-shot InfiGUI.
Args:
target_text: nom textuel de la cible (peut être vide si anchor_pil fourni).
target_description: description sémantique libre.
screen_pil: screenshot complet (PIL.Image).
anchor_pil: crop visuel de l'ancre capturée précédemment (PIL.Image).
Si fourni, le worker passe en mode fusionné : Image1=crop, Image2=screen,
"trouve sur l'image 2 l'élément visuel de l'image 1".
"""
t0 = time.time()
try:
with self._lock:
# Répertoire partagé client/serveur (cf. _image_dir)
img_dir = _image_dir()
# Sauver l'image principale
image_path = os.path.join(img_dir, "infigui_screen.png")
if screen_pil is not None:
screen_pil.save(image_path)
# Sauver l'image d'ancre (mode fusionné)
anchor_image_path = ""
if anchor_pil is not None:
anchor_image_path = os.path.join(img_dir, "infigui_anchor.png")
anchor_pil.save(anchor_image_path)
req_dict = {
"target": target_text,
"description": target_description,
"image_path": image_path,
"anchor_image_path": anchor_image_path,
}
mode_str = "fused" if anchor_pil is not None else "text"
label_short = target_text[:30] if target_text else "<crop only>"
# ── 1) Tenter le service persistant (socket Unix) ─────────
if time.time() >= self._service_unavailable_until:
sock_result = _send_socket_request(req_dict, self._socket_path)
if sock_result is not None:
return self._build_result(sock_result, anchor_pil, t0, source="server")
# Service down : on note pour 30s et on bascule subprocess
self._service_unavailable_until = time.time() + 30.0
print(f" [InfiGUI] Service indisponible, fallback subprocess pour 30s")
# ── 2) Fallback subprocess one-shot ──────────────────────
req = json.dumps(req_dict)
print(f"🎯 [InfiGUI/subprocess] Lancement one-shot [{mode_str}]: '{label_short}'")
# Lancer le script one-shot
# IMPORTANT: depuis un service systemd où le parent a déjà chargé CUDA,
# le subprocess hérite d'un état GPU cassé (No CUDA GPUs available).
# Solutions : start_new_session=True (nouveau cgroup) + forcer
# CUDA_VISIBLE_DEVICES=0 explicitement pour bypass l'héritage parent.
_child_env = {**os.environ}
_child_env["PYTHONDONTWRITEBYTECODE"] = "1"
_child_env["CUDA_VISIBLE_DEVICES"] = "0"
_child_env["NVIDIA_VISIBLE_DEVICES"] = "all"
# Supprimer les variables Python qui pourraient pointer sur l'état parent
_child_env.pop("PYTORCH_NVML_BASED_CUDA_CHECK", None)
result = subprocess.run(
[sys.executable, "-m", "core.grounding.infigui_worker"],
input=req + "\n",
capture_output=True,
text=True,
timeout=60,
cwd=self._project_root,
env=_child_env,
start_new_session=True, # nouveau session group, isole du parent
close_fds=True,
)
if result.returncode != 0:
stderr_lines = (result.stderr or '').strip().split('\n')
# Afficher les dernières lignes significatives du stderr
last_err = [l for l in stderr_lines[-5:] if l.strip()]
print(f"⚠️ [InfiGUI] Script échoué (code {result.returncode})")
for l in last_err:
print(f"{l}")
return None
# Parser la sortie — chercher la ligne JSON de résultat
data = None
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
parsed = json.loads(line)
if "x" in parsed:
data = parsed
except json.JSONDecodeError:
continue
if data is None:
print(f"⚠️ [InfiGUI] Pas de réponse JSON dans la sortie")
return None
return self._build_result(data, anchor_pil, t0, source="subprocess")
except subprocess.TimeoutExpired:
print(f"⚠️ [InfiGUI] Timeout 60s")
return None
except Exception as e:
print(f"⚠️ [InfiGUI] Erreur: {e}")
return None
# ──────────────────────────────────────────────────────────────────
# Helpers internes
# ──────────────────────────────────────────────────────────────────
def _build_result(
self,
data: Dict[str, Any],
anchor_pil: Any,
t0: float,
source: str,
) -> Optional[GroundingResult]:
"""Convertit une réponse JSON (server ou subprocess) en GroundingResult."""
dt = (time.time() - t0) * 1000
if data.get("error"):
print(f"⚠️ [InfiGUI/{source}] Erreur: {data['error']} ({dt:.0f}ms)")
return None
if data.get("x") is None:
print(f"⚠️ [InfiGUI/{source}] Pas trouvé ({dt:.0f}ms)")
return None
method_name = "infigui_fused" if anchor_pil is not None else "infigui"
print(
f"🎯 [InfiGUI/{source}/{method_name}] ({data['x']}, {data['y']}) "
f"conf={data.get('confidence', 0):.2f} ({dt:.0f}ms)"
)
return GroundingResult(
x=data["x"],
y=data["y"],
method=method_name,
confidence=data.get("confidence", 0.90),
time_ms=dt,
)

View File

View File

@@ -0,0 +1,523 @@
"""
Base de connaissances des patterns d'interface utilisateur.
Donne à Léa des "réflexes natifs" : quand elle reconnaît un pattern UI
connu (dialogue OK/Annuler, menu, barre d'outils), elle sait immédiatement
quoi faire sans avoir besoin de l'apprendre par observation.
Sources :
- GUI-R1 dataset (3K exemples annotés, ritzzai/GUI-R1)
- Patterns Windows/Linux courants
- Conventions UI universelles
Utilisation :
from core.knowledge.ui_patterns import UIPatternLibrary
lib = UIPatternLibrary()
match = lib.find_pattern("Voulez-vous enregistrer ?")
# → {'action': 'click', 'target': 'Enregistrer', 'zone': 'dialog_center', ...}
"""
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@dataclass
class UIPattern:
"""Un pattern d'interface connu."""
name: str
category: str
triggers: List[str]
action: str
target: str
typical_zone: str
typical_bbox: Optional[List[float]] = None
os: str = "any"
confidence: float = 0.9
metadata: Dict[str, Any] = field(default_factory=dict)
# Patterns Windows natifs — réflexes de base
BUILTIN_PATTERNS: List[Dict[str, Any]] = [
# === DIALOGUES DE CONFIRMATION ===
{
"name": "dialog_save",
"category": "dialog",
"triggers": [
"voulez-vous enregistrer", "do you want to save",
"save changes", "enregistrer les modifications",
"enregistrer sous", "save as",
"sauvegarder", "unsaved changes",
],
"action": "click",
"target": "Enregistrer",
"alternatives": ["Save", "Oui", "Yes"],
"typical_zone": "dialog_center",
"typical_bbox": [0.35, 0.55, 0.50, 0.65],
"os": "any",
},
{
"name": "dialog_cancel",
"category": "dialog",
"triggers": [
"annuler", "cancel", "abandonner", "discard",
],
"action": "click",
"target": "Annuler",
"alternatives": ["Cancel", "Non", "No"],
"typical_zone": "dialog_center",
"typical_bbox": [0.50, 0.55, 0.65, 0.65],
"os": "any",
},
{
"name": "dialog_ok",
"category": "dialog",
"triggers": [
"ok", "d'accord", "compris", "information",
"erreur", "error", "warning", "avertissement",
],
"action": "click",
"target": "OK",
"alternatives": ["Fermer", "Close", "Compris"],
"typical_zone": "dialog_center",
"typical_bbox": [0.45, 0.60, 0.55, 0.70],
"os": "any",
},
{
"name": "dialog_yes_no",
"category": "dialog",
"triggers": [
"êtes-vous sûr", "are you sure", "confirmer",
"confirm", "supprimer", "delete",
],
"action": "click",
"target": "Oui",
"alternatives": ["Yes", "Confirmer", "Confirm"],
"typical_zone": "dialog_center",
"typical_bbox": [0.35, 0.60, 0.45, 0.68],
"os": "any",
},
{
"name": "dialog_overwrite",
"category": "dialog",
"triggers": [
"voulez-vous remplacer", "voulez-vous écraser",
"remplacer le fichier", "replace existing",
"fichier existe déjà", "already exists",
"overwrite", "écraser",
],
"action": "click",
"target": "Oui",
"alternatives": ["Yes", "Remplacer", "Replace", "Confirmer"],
"typical_zone": "dialog_center",
"os": "any",
},
{
"name": "dialog_dont_save",
"category": "dialog",
"triggers": [
"ne pas enregistrer", "don't save",
"ne pas sauvegarder", "quitter sans enregistrer",
"discard changes",
],
"action": "click",
"target": "Ne pas enregistrer",
"alternatives": ["Don't Save", "Ne pas sauvegarder", "Non"],
"typical_zone": "dialog_center",
"os": "any",
},
# === NAVIGATION FENÊTRE ===
{
"name": "window_close",
"category": "window",
"triggers": ["fermer la fenêtre", "close window"],
"action": "click",
"target": "X",
"typical_zone": "titlebar",
"typical_bbox": [0.96, 0.0, 1.0, 0.04],
"os": "windows",
},
{
"name": "window_minimize",
"category": "window",
"triggers": ["minimiser", "minimize"],
"action": "click",
"target": "_",
"typical_zone": "titlebar",
"typical_bbox": [0.90, 0.0, 0.94, 0.04],
"os": "windows",
},
{
"name": "window_maximize",
"category": "window",
"triggers": ["maximiser", "maximize", "agrandir"],
"action": "click",
"target": "",
"typical_zone": "titlebar",
"typical_bbox": [0.94, 0.0, 0.96, 0.04],
"os": "windows",
},
# === MENUS ===
{
"name": "menu_file",
"category": "menu",
"triggers": ["menu fichier", "menu file", "ouvrir fichier", "open file"],
"action": "click",
"target": "Fichier",
"alternatives": ["File"],
"typical_zone": "menu_toolbar",
"typical_bbox": [0.0, 0.03, 0.06, 0.06],
"os": "any",
},
{
"name": "menu_edit",
"category": "menu",
"triggers": ["édition", "edit", "modifier"],
"action": "click",
"target": "Édition",
"alternatives": ["Edit"],
"typical_zone": "menu_toolbar",
"typical_bbox": [0.06, 0.03, 0.12, 0.06],
"os": "any",
},
# === FORMULAIRES ===
{
"name": "form_submit",
"category": "form",
"triggers": [
"valider", "submit", "envoyer", "send",
"connexion", "login", "se connecter", "sign in",
],
"action": "click",
"target": "Valider",
"alternatives": ["Submit", "Envoyer", "Connexion", "Login", "OK"],
"typical_zone": "content",
"typical_bbox": [0.35, 0.70, 0.65, 0.80],
"os": "any",
},
{
"name": "form_search",
"category": "form",
"triggers": ["rechercher", "search", "chercher", "find"],
"action": "click",
"target": "Rechercher",
"alternatives": ["Search", "🔍", "Go"],
"typical_zone": "menu_toolbar",
"typical_bbox": [0.30, 0.03, 0.70, 0.06],
"os": "any",
},
# === NAVIGATION WEB ===
{
"name": "cookie_accept",
"category": "popup",
"triggers": [
"accepter les cookies", "accept cookies",
"utilise des cookies", "uses cookies",
"j'accepte", "accept all", "tout accepter",
"consent", "consentement",
],
"action": "click",
"target": "Accepter",
"alternatives": ["Accept", "Accept All", "Tout accepter", "J'accepte"],
"typical_zone": "content",
"typical_bbox": [0.30, 0.80, 0.70, 0.90],
"os": "any",
},
# === RACCOURCIS UNIVERSELS ===
{
"name": "shortcut_save",
"category": "shortcut",
"triggers": ["sauvegarder", "enregistrer", "save"],
"action": "hotkey",
"target": "ctrl+s",
"typical_zone": "keyboard",
"os": "any",
},
{
"name": "shortcut_undo",
"category": "shortcut",
"triggers": ["annuler action", "undo", "défaire"],
"action": "hotkey",
"target": "ctrl+z",
"typical_zone": "keyboard",
"os": "any",
},
{
"name": "shortcut_copy",
"category": "shortcut",
"triggers": ["copier", "copy"],
"action": "hotkey",
"target": "ctrl+c",
"typical_zone": "keyboard",
"os": "any",
},
{
"name": "shortcut_paste",
"category": "shortcut",
"triggers": ["coller", "paste"],
"action": "hotkey",
"target": "ctrl+v",
"typical_zone": "keyboard",
"os": "any",
},
]
class UIPatternLibrary:
"""Bibliothèque de patterns UI connus.
Fournit des "réflexes natifs" à Léa : quand un pattern
est reconnu dans le texte OCR ou le contexte visuel,
elle sait immédiatement quoi faire.
"""
# Chemins par défaut des fichiers de patterns additionnels
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_GUI_R1_PATTERNS_PATH = _PROJECT_ROOT / "data" / "gui_r1_ui_patterns.json"
_LEARNED_PATTERNS_PATH = _PROJECT_ROOT / "data" / "learned_patterns.json"
def __init__(self, extra_patterns_path: Optional[str] = None):
self._patterns: List[UIPattern] = []
self._load_builtin()
# Charger les patterns extraits de GUI-R1 (statiques, générés une fois)
self._load_from_file(str(self._GUI_R1_PATTERNS_PATH))
# Charger les patterns appris par observation Shadow (dynamiques)
self._load_from_file(str(self._LEARNED_PATTERNS_PATH))
# Fichier custom fourni explicitement
if extra_patterns_path:
self._load_from_file(extra_patterns_path)
logger.info(f"UIPatternLibrary: {len(self._patterns)} patterns chargés")
def _load_builtin(self):
for p in BUILTIN_PATTERNS:
self._patterns.append(UIPattern(
name=p["name"],
category=p["category"],
triggers=p["triggers"],
action=p["action"],
target=p["target"],
typical_zone=p.get("typical_zone", "content"),
typical_bbox=p.get("typical_bbox"),
os=p.get("os", "any"),
metadata={
"alternatives": p.get("alternatives", []),
"source": "builtin",
},
))
def _load_from_file(self, path: str):
filepath = Path(path)
if not filepath.exists():
logger.debug(f"Fichier patterns non trouvé (OK si premier lancement): {path}")
return
try:
with open(filepath) as f:
data = json.load(f)
for p in data.get("patterns", []):
# Construire metadata en incluant source/learned_at/gui_r1_id si présents
meta = dict(p.get("metadata", {}))
if "source" in p:
meta["source"] = p["source"]
if "learned_at" in p:
meta["learned_at"] = p["learned_at"]
if "gui_r1_id" in p:
meta["gui_r1_id"] = p["gui_r1_id"]
self._patterns.append(UIPattern(
name=p["name"],
category=p.get("category", "custom"),
triggers=p.get("triggers", []),
action=p.get("action", "click"),
target=p.get("target", ""),
typical_zone=p.get("typical_zone", "content"),
typical_bbox=p.get("typical_bbox"),
os=p.get("os", "any"),
confidence=p.get("confidence", 0.9),
metadata=meta,
))
logger.info(f"Chargé {len(data.get('patterns', []))} patterns depuis {path}")
except Exception as e:
logger.error(f"Erreur chargement patterns: {e}")
def find_pattern(
self,
text: str,
os_filter: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Cherche un pattern UI dans du texte (OCR, titre fenêtre, etc.).
Args:
text: Texte à analyser (peut contenir du bruit OCR)
os_filter: Filtrer par OS ("windows", "linux", None=tous)
Returns:
Dict avec action, target, confidence, etc. ou None
"""
text_lower = text.lower()
best_match = None
best_score = 0
for pattern in self._patterns:
if os_filter and pattern.os not in ("any", os_filter):
continue
score = 0
matched_trigger = None
for trigger in pattern.triggers:
if len(trigger) <= 3:
import re
if re.search(r'\b' + re.escape(trigger) + r'\b', text_lower):
trigger_score = len(trigger) / max(len(text_lower), 1)
if trigger_score > score:
score = trigger_score
matched_trigger = trigger
elif trigger in text_lower:
trigger_score = len(trigger) / max(len(text_lower), 1)
if trigger_score > score:
score = trigger_score
matched_trigger = trigger
if score > best_score and matched_trigger is not None:
best_score = score
best_match = {
"pattern": pattern.name,
"category": pattern.category,
"action": pattern.action,
"target": pattern.target,
"alternatives": pattern.metadata.get("alternatives", []),
"typical_zone": pattern.typical_zone,
"typical_bbox": pattern.typical_bbox,
"confidence": min(pattern.confidence * (1 + score), 1.0),
"matched_trigger": matched_trigger,
"os": pattern.os,
}
return best_match
def find_by_category(self, category: str) -> List[Dict[str, Any]]:
"""Retourne tous les patterns d'une catégorie."""
return [
{
"name": p.name,
"action": p.action,
"target": p.target,
"triggers": p.triggers,
"typical_zone": p.typical_zone,
}
for p in self._patterns
if p.category == category
]
def get_dialog_handler(self, dialog_text: str) -> Optional[Dict[str, Any]]:
"""Raccourci : cherche un pattern de dialogue."""
match = self.find_pattern(dialog_text)
if match and match["category"] == "dialog":
return match
return self.find_pattern(dialog_text)
def add_pattern(self, pattern_dict: Dict[str, Any]):
"""Ajoute un pattern dynamiquement (ex: appris par observation)."""
self._patterns.append(UIPattern(
name=pattern_dict["name"],
category=pattern_dict.get("category", "learned"),
triggers=pattern_dict.get("triggers", []),
action=pattern_dict.get("action", "click"),
target=pattern_dict.get("target", ""),
typical_zone=pattern_dict.get("typical_zone", "content"),
typical_bbox=pattern_dict.get("typical_bbox"),
os=pattern_dict.get("os", "any"),
confidence=pattern_dict.get("confidence", 0.7),
metadata={"source": "learned"},
))
def save_to_file(self, path: str):
"""Sauvegarde tous les patterns (builtin + appris) dans un fichier."""
data = {
"patterns": [
{
"name": p.name,
"category": p.category,
"triggers": p.triggers,
"action": p.action,
"target": p.target,
"typical_zone": p.typical_zone,
"typical_bbox": p.typical_bbox,
"os": p.os,
"confidence": p.confidence,
"metadata": p.metadata,
}
for p in self._patterns
]
}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(f"Sauvegardé {len(self._patterns)} patterns dans {path}")
def save_learned_pattern(self, pattern_dict: Dict[str, Any]):
"""Persiste un pattern appris par observation Shadow dans learned_patterns.json.
Le pattern est ajouté en mémoire ET sauvegardé sur disque.
Le fichier est créé s'il n'existe pas, ou les patterns existants sont préservés.
"""
from datetime import datetime as dt
# Charger le fichier existant ou créer la structure
filepath = self._LEARNED_PATTERNS_PATH
filepath.parent.mkdir(parents=True, exist_ok=True)
existing: Dict[str, Any] = {"patterns": []}
if filepath.exists():
try:
with open(filepath, encoding="utf-8") as f:
existing = json.load(f)
except (json.JSONDecodeError, OSError):
logger.warning(f"Fichier {filepath} corrompu, recréation")
# Vérifier qu'on ne duplique pas (même trigger + même target)
new_triggers = set(t.lower() for t in pattern_dict.get("triggers", []))
new_target = pattern_dict.get("target", "").lower()
for existing_p in existing.get("patterns", []):
existing_triggers = set(t.lower() for t in existing_p.get("triggers", []))
if existing_triggers == new_triggers and existing_p.get("target", "").lower() == new_target:
logger.debug(f"Pattern déjà connu, skip: triggers={new_triggers}, target={new_target}")
return
# Numéroter automatiquement et construire l'entrée complète
count = len(existing.get("patterns", []))
entry = {
"name": pattern_dict.get("name", f"learned_dialog_{count + 1:03d}"),
"category": pattern_dict.get("category", "dialog"),
"triggers": pattern_dict.get("triggers", []),
"action": pattern_dict.get("action", "click"),
"target": pattern_dict.get("target", ""),
"os": pattern_dict.get("os", "windows"),
"source": "shadow_learning",
"learned_at": dt.now().isoformat(timespec="seconds"),
"confidence": pattern_dict.get("confidence", 0.8),
}
# Ajouter en mémoire (avec le nom auto-généré)
self.add_pattern(entry)
existing.setdefault("patterns", []).append(entry)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(existing, f, indent=2, ensure_ascii=False)
logger.info(f"Pattern appris sauvegardé: {entry['name']}{entry['target']}")
@property
def stats(self) -> Dict[str, int]:
from collections import Counter
cats = Counter(p.category for p in self._patterns)
return {"total": len(self._patterns), "by_category": dict(cats)}

18
core/llm/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
"""Modules LLM (clients Ollama et décisionnels métier) + extracteur OCR."""
from .t2a_decision import (
PROMPT_TEMPLATE,
DEFAULT_MODEL,
analyze_dpi,
build_dpi_enriched,
)
from .ocr_extractor import extract_table_from_image, extract_text_from_image
__all__ = [
"PROMPT_TEMPLATE",
"DEFAULT_MODEL",
"analyze_dpi",
"build_dpi_enriched",
"extract_text_from_image",
"extract_table_from_image",
]

140
core/llm/ocr_extractor.py Normal file
View File

@@ -0,0 +1,140 @@
"""Extracteur OCR — texte depuis une image (screenshot d'écran).
Utilise EasyOCR fr+en. Singleton (chargement modèle ~3s au premier appel).
Conçu pour le pipeline streaming serveur (actions `extract_text` /
`extract_table`) : récupère un screenshot fresh (dernier heartbeat ou
capture forcée), applique l'OCR, retourne le texte ou une liste structurée
pour analyse downstream (ex: t2a_decision, boucle sur N patients).
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
_easyocr_reader = None
def _get_reader():
"""Initialise EasyOCR fr+en au premier appel (singleton, CPU forcé).
CPU forcé : le streaming server partage la VRAM avec Ollama (qwen2.5:7b ~5GB)
et les modèles CLIP/FAISS — pas assez de marge pour EasyOCR GPU (~1GB).
"""
global _easyocr_reader
if _easyocr_reader is None:
import easyocr
_easyocr_reader = easyocr.Reader(['fr', 'en'], gpu=False, verbose=False)
logger.info("EasyOCR initialisé (fr+en, CPU)")
return _easyocr_reader
def extract_text_from_image(
image_path: str,
region: Optional[Tuple[int, int, int, int]] = None,
paragraph: bool = True,
) -> str:
"""Extrait le texte d'une image via EasyOCR.
Args:
image_path: chemin du PNG sur disque.
region: (x, y, w, h) pour cropper avant OCR. None = image entière.
paragraph: True pour regrouper les lignes en paragraphes (lisible),
False pour blocs séparés (granulaire).
Returns:
Texte concaténé. Chaque ligne / paragraphe est séparé par un saut de ligne.
En cas d'erreur, retourne une chaîne vide et log un warning.
"""
path = Path(image_path)
if not path.exists():
logger.warning("extract_text: fichier introuvable %s", image_path)
return ""
try:
from PIL import Image
import numpy as np
img = Image.open(path)
if region:
x, y, w, h = region
img = img.crop((x, y, x + w, y + h))
reader = _get_reader()
results = reader.readtext(np.array(img), detail=0, paragraph=paragraph)
return "\n".join(str(r).strip() for r in results if r)
except Exception as e:
logger.warning("extract_text échoué sur %s : %s", image_path, e)
return ""
def extract_table_from_image(
image_path: str,
region: Optional[Tuple[int, int, int, int]] = None,
pattern: Optional[str] = None,
limit: Optional[int] = None,
) -> List[str]:
"""Extrait une liste de valeurs d'un tableau via OCR.
Cas d'usage principal : lire la liste des IPP d'un tableau de patients
pour boucler dessus. EasyOCR retourne tous les tokens avec leur bbox,
on filtre par regex puis on trie par position (y croissant).
Args:
image_path: chemin du PNG sur disque.
region: (x, y, w, h) pour cropper avant OCR. None = image entière.
pattern: regex Python ; seuls les tokens qui matchent sont conservés.
Si None : tous les tokens non vides sont retournés.
Exemple IPP : r"^\\d{8,10}$" ou r"^25\\d{6}$"
limit: nombre maximal d'entrées à retourner (None = sans limite).
Returns:
Liste de strings dans l'ordre top → bottom (par y de bbox).
En cas d'erreur, retourne une liste vide et log un warning.
"""
path = Path(image_path)
if not path.exists():
logger.warning("extract_table: fichier introuvable %s", image_path)
return []
try:
from PIL import Image
import numpy as np
img = Image.open(path)
if region:
x, y, w, h = region
img = img.crop((x, y, x + w, y + h))
reader = _get_reader()
# detail=1 ⇒ chaque résultat = (bbox, text, confidence)
# bbox est une liste de 4 points [tl, tr, br, bl]
results = reader.readtext(np.array(img), detail=1, paragraph=False)
compiled = re.compile(pattern) if pattern else None
rows: List[Tuple[float, str]] = []
for bbox, text, _conf in results:
t = str(text).strip()
if not t:
continue
if compiled and not compiled.match(t):
continue
# y moyen pour tri vertical (top→bottom)
ys = [p[1] for p in bbox]
y_mean = sum(ys) / len(ys)
rows.append((y_mean, t))
rows.sort(key=lambda r: r[0])
values = [t for _y, t in rows]
if limit:
values = values[:limit]
return values
except Exception as e:
logger.warning("extract_table échoué sur %s : %s", image_path, e)
return []

456
core/llm/t2a_decision.py Normal file
View File

@@ -0,0 +1,456 @@
"""Aide à la décision de facturation urgences T2A/PMSI via LLM local.
Décide si un passage aux urgences relève :
- du FORFAIT_URGENCE (passage simple, retour à domicile)
- de la REQUALIFICATION_HOSPITALISATION (séjour MCO, valorisation 1k-5k€+)
Le prompt impose une extraction littérale des faits du DPI (pas d'invention)
et une modulation honnête de la confiance. Validé sur 15 DPI synthétiques :
qwen2.5:7b atteint 100 % d'accuracy en ~5 s/cas avec 4,7 Go VRAM.
Voir docs/clients/ght_sud_95/ et demo/facturation_urgences/RESULTATS.md pour le
bench comparatif des 11 LLMs évalués.
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import urllib.error
import urllib.request
from datetime import datetime
from typing import Any, Dict, Optional, Tuple
from dateutil.relativedelta import relativedelta
logger = logging.getLogger(__name__)
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/generate")
DEFAULT_MODEL = os.environ.get("T2A_MODEL", "qwen2.5:7b")
DEFAULT_TIMEOUT = 60 # secondes
PROMPT_TEMPLATE = """Tu es médecin DIM (Département d'Information Médicale), expert en facturation T2A/PMSI aux urgences hospitalières en France.
Analyse le dossier patient ci-dessous pour déterminer si le passage relève :
- FORFAIT_URGENCE : passage simple, retour à domicile, sans surveillance prolongée ni soins continus
- REQUALIFICATION_HOSPITALISATION : séjour MCO requis selon les 3 critères PMSI/ATIH
LES 3 CRITÈRES UHCD (au moins 2 sur 3 validés ⇒ REQUALIFICATION) :
1. Pathologie potentiellement évolutive (instabilité hémodynamique, terrain à risque, traitement nécessitant adaptation)
2. Surveillance médicale et paramédicale prolongée (constantes itératives, observations IDE/médecin, durée > 6 h)
3. Examens complémentaires ou actes thérapeutiques (biologie, imagerie, sutures, gestes techniques)
INSTRUCTIONS STRICTES :
1. N'utilise QUE des éléments littéralement présents dans le dossier patient. N'invente AUCUN critère.
2. Pour CHAQUE critère (1, 2, 3), tu DOIS produire un texte de preuve qui contient AU MOINS UNE CITATION LITTÉRALE du dossier entre guillemets français « ... ». Exemple : « FC à 110 bpm, TA 92/60 ».
3. Si le critère est NON validé, ne renvoie JAMAIS un fallback creux : explique factuellement ce qui manque, en citant le dossier (ex: « Sortie à H+2 », « Aucun acte technique au compte-rendu »).
4. Le texte de chaque preuve fait 2-3 phrases : (i) la citation littérale, (ii) l'analyse PMSI, (iii) la conclusion validé/non validé.
5. Calcule la durée totale du passage en heures (admission → sortie/transfert) à partir des horaires du dossier.
6. Module ta confiance honnêtement :
- "elevee" uniquement si tous les indices convergent
- "moyenne" si éléments ambivalents
- "faible" si information manquante ou très atypique
Réponds STRICTEMENT en JSON valide, sans texte avant ni après :
{{
"duree_passage_heures": <nombre>,
"elements_pour_hospitalisation": [<phrases littéralement extraites du dossier>],
"elements_pour_forfait": [<phrases littéralement extraites du dossier>],
"decision": "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION",
"decision_court": "UHCD" | "Forfait Urgences",
"preuve_critere1": "<2-3 phrases incluant AU MOINS UNE citation littérale entre « » (motif, symptôme, terrain à risque, traitement). Si non validé : factualise ce qui manque en citant le dossier.>",
"critere1_valide": true | false,
"preuve_critere2": "<2-3 phrases incluant AU MOINS UNE citation littérale entre « » (constantes, observations IDE, durée surveillance). Si non validé : factualise.>",
"critere2_valide": true | false,
"preuve_critere3": "<2-3 phrases incluant AU MOINS UNE citation littérale entre « » (actes/examens : biologie, imagerie, suture, etc.). Si non validé : factualise.>",
"critere3_valide": true | false,
"justification": "<2-3 phrases synthétiques s'appuyant explicitement sur les preuves ci-dessus, avec au moins une citation>",
"confiance": "elevee" | "moyenne" | "faible"
}}
DOSSIER PATIENT :
{dpi}
"""
def analyze_dpi(
dpi_text: str,
model: str = DEFAULT_MODEL,
timeout: int = DEFAULT_TIMEOUT,
ollama_url: str = OLLAMA_URL,
) -> Dict[str, Any]:
"""Soumet un DPI urgences à un LLM Ollama et retourne la décision JSON.
Args:
dpi_text: Texte du dossier patient (concaténation des onglets ou DPI brut).
model: Modèle Ollama à utiliser (default qwen2.5:7b — 100% accuracy bench).
timeout: Timeout HTTP en secondes.
ollama_url: Endpoint Ollama (default localhost:11434/api/generate).
Returns:
Dict avec :
decision: "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION"
elements_pour_hospitalisation: List[str]
elements_pour_forfait: List[str]
duree_passage_heures: float
justification: str
confiance: "elevee" | "moyenne" | "faible"
_elapsed_s: float (latence)
_model: str
En cas d'erreur :
{"_error": str, "_elapsed_s": float} (réseau / Ollama indisponible)
{"_parse_error": True, "_raw": str, "_elapsed_s": float} (JSON invalide)
"""
payload = {
"model": model,
"prompt": PROMPT_TEMPLATE.format(dpi=dpi_text),
"stream": False,
"format": "json",
"keep_alive": "5m",
"options": {
"temperature": 0.1,
"num_predict": 1500,
"num_ctx": 16384,
},
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
ollama_url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
elapsed = round(time.time() - t0, 1)
logger.warning("analyze_dpi: Ollama indisponible (%s) après %.1fs", e, elapsed)
return {"_error": str(e), "_elapsed_s": elapsed, "_model": model}
elapsed = time.time() - t0
raw_response = body.get("response", "").strip()
raw_thinking = body.get("thinking", "").strip()
candidates = [raw_response]
if not raw_response and raw_thinking:
last_close = raw_thinking.rfind("}")
last_open = raw_thinking.rfind("{", 0, last_close)
if last_open != -1 and last_close != -1:
candidates.append(raw_thinking[last_open:last_close + 1])
parsed = None
for cand in candidates:
cleaned = cand
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[-1]
if cleaned.endswith("```"):
cleaned = cleaned.rsplit("```", 1)[0]
cleaned = cleaned.strip()
try:
parsed = json.loads(cleaned)
break
except json.JSONDecodeError:
continue
if parsed is None:
return {
"_parse_error": True,
"_raw": (raw_response or raw_thinking)[:500],
"_elapsed_s": round(elapsed, 1),
"_model": model,
}
parsed["_elapsed_s"] = round(elapsed, 1)
parsed["_model"] = model
parsed["_eval_count"] = body.get("eval_count")
return parsed
# ---------------------------------------------------------------------------
# build_dpi_enriched — extraction déterministe horaires + classifications
# ---------------------------------------------------------------------------
#
# Voir docs/handoffs/2026-05-12_brief_S1_build_dpi_enriched.md pour le contexte
# complet (bug hallucination durée "23h" sur cas MOREL, plan P0 [S1]/[S2]).
# Libellés connus de la section "Synthèse Urgences" (Easily Assure).
# Servent d'ancres pour le parsing : la valeur d'un champ s'étend depuis son
# libellé jusqu'au prochain libellé connu (gère le wrap multi-ligne CCMU/GEMSA).
# Trier par longueur décroissante côté regex pour éviter qu'un préfixe partiel
# (ex: "Décision médicale" matche "Médecin de la décision médicale").
_LIBELLES_SYNTHESE = [
"Episode - Date",
"Mode de transport à l'arrivée",
"Médicalisation du transport",
"Mode d'entrée",
"Origine du transfert",
"Date d'orientation",
"IAO",
"Priorité",
"Episode - Sous-type",
"Circonstances",
"Motif de prise en charge",
"Observ. IDE Urg",
"Médecin de la prise en charge médicale",
"Date de la prise en charge médicale",
"CCMU",
"GEMSA",
"Diagnostics",
"Médecin de la décision médicale",
"Date de décision médicale",
"Décision médicale",
"Orientation du patient",
"US de destination",
]
def _extract_synthese_field(text: str, label: str) -> Optional[str]:
"""Extrait la valeur du champ `label` dans la Synthèse Urgences.
Stratégie : trouve la 1re ligne qui commence par `label`, capture la suite
jusqu'au prochain libellé connu (en début de ligne) ou fin de texte.
Gère le wrap multi-ligne (CCMU 3 + libellé long sur plusieurs lignes).
Normalise les espaces multiples internes en un seul espace.
"""
other_labels = sorted(
(l for l in _LIBELLES_SYNTHESE if l != label),
key=len,
reverse=True,
)
other_alt = "|".join(re.escape(l) for l in other_labels)
# Exigence : la valeur doit commencer sur la même ligne que le label
# ([^\n]). Cela évite qu'une ligne titre de section ("Décision médicale\n"
# seule) capture la ligne suivante (qui serait un autre champ).
# Le wrap multi-ligne CCMU/GEMSA reste supporté : 1er char sur la même
# ligne que le label, suite via DOTALL jusqu'au prochain libellé connu.
pattern = (
rf"^[\s\|]*{re.escape(label)}[ \t]*[:\|]?[ \t]+"
rf"([^\n].*?)(?=\n[\s\|]*(?:{other_alt})\b|\Z)"
)
m = re.search(pattern, text, re.DOTALL | re.MULTILINE)
if not m:
return None
value = re.sub(r"\s+", " ", m.group(1)).strip()
return value or None
def _parse_dt(date_str: str, time_str: str) -> Optional[datetime]:
"""Parse 'DD/MM/YYYY' + 'HH:MM' → datetime. Retourne None si échec."""
try:
return datetime.strptime(f"{date_str} {time_str}", "%d/%m/%Y %H:%M")
except ValueError:
return None
def _parse_synthese_datetime(value: Optional[str]) -> Optional[datetime]:
"""Parse une valeur de type 'JJ/MM/AAAA à HH:MM' (format Synthèse Urgences).
Tolère absence du séparateur ' à ' (OCR peut linéariser autrement).
"""
if not value:
return None
m = re.search(r"(\d{2}/\d{2}/\d{4})\s*(?:à\s*)?(\d{2}:\d{2})", value)
if not m:
return None
return _parse_dt(m.group(1), m.group(2))
def build_dpi_enriched(dpi_raw: str) -> Tuple[str, Dict[str, Any]]:
"""Enrichit le DPI brut avec un bloc FAITS_CALCULÉS en tête.
Extrait de manière déterministe (Python, pas LLM) :
- âge du patient (depuis date de naissance bandeau + date d'admission)
- durée totale du passage (depuis horaires admission/sortie)
- CCMU, GEMSA, priorité IAO, mode de venue, diagnostic principal
- décision médicale terrain + orientation (metadata uniquement, NON injectés
dans le bloc FAITS_CALCULÉS pour ne pas biaiser le LLM)
Le bloc FAITS_CALCULÉS est concaténé en tête du DPI retourné.
Le metadata dict permet au handler serveur d'effectuer les garde-fous
Python ↔ LLM au commit 2.
Args:
dpi_raw: DPI brut concaténé (5 onglets OCR scroll auto + bandeau répété).
Returns:
Tuple (dpi_enriched, metadata).
dpi_enriched: str — FAITS_CALCULÉS + "\\n\\n" + dpi_raw.
metadata: dict — toutes les valeurs Python extraites (None si parsing
échoué pour un champ), + parsing_warnings: list[str].
"""
metadata: Dict[str, Any] = {
"age_ans": None,
"date_admission": None,
"date_sortie": None,
"duree_heures_decimales": None,
"ccmu": None,
"gemsa": None,
"priorite_iao": None,
"mode_venue": None,
"mode_medicalisation": None,
"mode_entree": None,
"diagnostic_principal": None,
"decision_terrain": None,
"orientation_terrain": None,
"parsing_warnings": [],
}
# ── 1. Bandeau Easily Assure (re.search = 1re occurrence) ────────────────
# Le bandeau est répété ~5 fois dans dpi_raw (un par extract_text_scroll
# de chaque onglet). On prend la 1re occurrence et on signale une éventuelle
# divergence inter-occurrences (symptôme d'OCR instable).
date_naissance: Optional[datetime] = None
date_adm_bandeau: Optional[datetime] = None
date_sortie_bandeau: Optional[datetime] = None
m_naissance = re.search(r"\(e\)\s+le\s+(\d{2}/\d{2}/\d{4})", dpi_raw)
if m_naissance:
try:
date_naissance = datetime.strptime(m_naissance.group(1), "%d/%m/%Y")
except ValueError:
metadata["parsing_warnings"].append(
f"date_naissance non parsable : {m_naissance.group(1)!r}"
)
arrivees = re.findall(
r"Arriv[ée]e\s*:\s*(\d{2}/\d{2}/\d{4})\s+(\d{2}:\d{2})", dpi_raw
)
if arrivees:
date_adm_bandeau = _parse_dt(*arrivees[0])
if len({a for a in arrivees}) > 1:
logger.warning(
"[build_dpi_enriched] Bandeau détecté %d fois avec divergences "
"sur Arrivée — prise de la 1re occurrence : %s",
len(arrivees), arrivees[0],
)
sorties = re.findall(
r"Sortie\s*:\s*(\d{2}/\d{2}/\d{4})\s+(\d{2}:\d{2})", dpi_raw
)
if sorties:
date_sortie_bandeau = _parse_dt(*sorties[0])
if len({s for s in sorties}) > 1:
logger.warning(
"[build_dpi_enriched] Bandeau détecté %d fois avec divergences "
"sur Sortie — prise de la 1re occurrence : %s",
len(sorties), sorties[0],
)
# ── 2. Synthèse Urgences (priorité sur le bandeau) ───────────────────────
syn_episode_date = _parse_synthese_datetime(
_extract_synthese_field(dpi_raw, "Episode - Date")
)
syn_pec_medicale = _parse_synthese_datetime(
_extract_synthese_field(dpi_raw, "Date de la prise en charge médicale")
)
syn_decision_medicale = _parse_synthese_datetime(
_extract_synthese_field(dpi_raw, "Date de décision médicale")
)
# Priorité d'admission : Episode-Date > PEC médicale > bandeau Arrivée
metadata["date_admission"] = (
syn_episode_date or syn_pec_medicale or date_adm_bandeau
)
# Priorité de sortie : Date décision médicale > bandeau Sortie
metadata["date_sortie"] = syn_decision_medicale or date_sortie_bandeau
# Champs structurés Synthèse Urgences (libellé complet, multi-ligne ok)
metadata["ccmu"] = _extract_synthese_field(dpi_raw, "CCMU")
metadata["gemsa"] = _extract_synthese_field(dpi_raw, "GEMSA")
metadata["priorite_iao"] = _extract_synthese_field(dpi_raw, "Priorité")
metadata["mode_venue"] = _extract_synthese_field(
dpi_raw, "Mode de transport à l'arrivée"
)
metadata["mode_medicalisation"] = _extract_synthese_field(
dpi_raw, "Médicalisation du transport"
)
metadata["mode_entree"] = _extract_synthese_field(dpi_raw, "Mode d'entrée")
metadata["diagnostic_principal"] = _extract_synthese_field(dpi_raw, "Diagnostics")
metadata["decision_terrain"] = _extract_synthese_field(dpi_raw, "Décision médicale")
metadata["orientation_terrain"] = _extract_synthese_field(
dpi_raw, "US de destination"
)
# ── 3. Calculs dérivés ───────────────────────────────────────────────────
if date_naissance and metadata["date_admission"]:
metadata["age_ans"] = relativedelta(
metadata["date_admission"], date_naissance
).years
elif date_naissance is None:
metadata["parsing_warnings"].append("date_naissance non détectée dans bandeau")
duree_format_humain: Optional[str] = None
if metadata["date_admission"] and metadata["date_sortie"]:
delta = metadata["date_sortie"] - metadata["date_admission"]
total_seconds = delta.total_seconds()
if total_seconds <= 0:
metadata["parsing_warnings"].append(
f"durée invalide : sortie {metadata['date_sortie']} <= "
f"admission {metadata['date_admission']}"
)
else:
metadata["duree_heures_decimales"] = round(total_seconds / 3600, 2)
heures = int(total_seconds // 3600)
minutes = int((total_seconds % 3600) // 60)
duree_format_humain = f"{heures} heures et {minutes} minutes"
else:
if metadata["date_admission"] is None:
metadata["parsing_warnings"].append("date_admission non détectée")
if metadata["date_sortie"] is None:
metadata["parsing_warnings"].append("date_sortie non détectée")
logger.warning(
"[build_dpi_enriched] Durée non calculable — admission=%s sortie=%s",
metadata["date_admission"], metadata["date_sortie"],
)
# ── 4. Construction du bloc FAITS_CALCULÉS ───────────────────────────────
lignes = ["FAITS_CALCULÉS (déterministes, ne pas recalculer) :"]
if metadata["age_ans"] is not None:
lignes.append(f"- Âge du patient : {metadata['age_ans']} ans")
if metadata["date_admission"]:
lignes.append(
f"- Date admission : "
f"{metadata['date_admission'].strftime('%d/%m/%Y à %H:%M')}"
)
if metadata["date_sortie"]:
lignes.append(
f"- Date sortie : "
f"{metadata['date_sortie'].strftime('%d/%m/%Y à %H:%M')}"
)
if duree_format_humain and metadata["duree_heures_decimales"] is not None:
lignes.append(
f"- Durée totale du passage : {duree_format_humain} "
f"(soit {metadata['duree_heures_decimales']} heures décimales)"
)
elif metadata["date_admission"] is None or metadata["date_sortie"] is None:
lignes.append(
"- Durée totale du passage : NON CALCULABLE (horaires non détectés)"
)
if metadata["ccmu"]:
lignes.append(f"- CCMU : {metadata['ccmu']}")
if metadata["gemsa"]:
lignes.append(f"- GEMSA : {metadata['gemsa']}")
if metadata["priorite_iao"]:
lignes.append(f"- Priorité IAO : {metadata['priorite_iao']}")
if metadata["mode_venue"] or metadata["mode_medicalisation"]:
mode_parts = [
p for p in (metadata["mode_venue"], metadata["mode_medicalisation"]) if p
]
lignes.append(f"- Mode de venue : {', '.join(mode_parts)}")
if metadata["mode_entree"]:
lignes.append(f"- Mode d'entrée : {metadata['mode_entree']}")
if metadata["diagnostic_principal"]:
lignes.append(f"- Diagnostic principal : {metadata['diagnostic_principal']}")
bloc_faits = "\n".join(lignes)
dpi_enriched = f"{bloc_faits}\n\n{dpi_raw}"
return dpi_enriched, metadata

29
core/llm/t2a_mappings.py Normal file
View File

@@ -0,0 +1,29 @@
"""Mappings métier pour la décision T2A (libellé terrain → catégorie T2A).
Table validée par Dom le 12 mai 2026 sur les 11 dossiers POC GHT Sud 95.
Module partagé : importé par `_handle_t2a_decision_action` (garde-fou serveur,
commit 2) ET par `scripts/bench_t2a_dryrun.py` (mini-bench standalone).
Cas spéciaux non mappés volontairement :
- "Retour structure d'origine" : à arbitrer cliniquement (email DIM
Pauline/Amina en cours). Loguera `Libellé terrain non mappé` sans erreur.
- "" (chaîne vide) : dossier en attente. Skip en amont par l'appelant.
Si un nouveau libellé apparaît hors POC actuel (déploiement futur autre
établissement), le bench le logguera `Libellé terrain non mappé` et il sera
ajouté ici après validation Dom.
Voir docs/handoffs/2026-05-12_brief_S1_build_dpi_enriched.md.
"""
from __future__ import annotations
from typing import Dict
TERRAIN_VERS_T2A: Dict[str, str] = {
"Consultation externe": "FORFAIT_URGENCE",
"Hospitalisation": "REQUALIFICATION_HOSPITALISATION",
"Sortie après surveillance UHCD": "REQUALIFICATION_HOSPITALISATION",
"Transfert intra-hospitalier": "REQUALIFICATION_HOSPITALISATION",
}

View File

@@ -1,100 +0,0 @@
{
"workflow_id": "demo_calculator",
"name": "Demo - Calculatrice",
"description": "Ouvre la calculatrice et effectue un calcul simple",
"version": "1.0.0",
"created_at": "2024-11-29T10:00:00",
"updated_at": "2024-11-29T10:00:00",
"learning_state": "OBSERVATION",
"execution_count": 0,
"entry_nodes": ["start"],
"end_nodes": ["end"],
"nodes": [
{
"node_id": "start",
"name": "Desktop",
"description": "Écran de départ",
"template": {
"title_pattern": ".*"
},
"is_entry": true,
"is_end": false,
"metadata": {}
},
{
"node_id": "calc_open",
"name": "Calculatrice ouverte",
"description": "La calculatrice est visible",
"template": {
"title_pattern": ".*(calc|gnome-calculator).*"
},
"is_entry": false,
"is_end": false,
"metadata": {}
},
{
"node_id": "end",
"name": "Calcul effectué",
"description": "Le calcul est affiché",
"template": {
"title_pattern": ".*"
},
"is_entry": false,
"is_end": true,
"metadata": {}
}
],
"edges": [
{
"edge_id": "open_calc",
"source_node": "start",
"target_node": "calc_open",
"action": {
"type": "compound",
"target": {
"by_role": null,
"selection_policy": "first"
},
"parameters": {
"steps": [
{"type": "key_press", "key": "super"},
{"type": "wait", "duration_ms": 500},
{"type": "text_input", "text": "calculator"},
{"type": "key_press", "key": "Return"}
]
}
},
"constraints": {
"timeout_ms": 5000
},
"confidence_threshold": 0.7
},
{
"edge_id": "do_calc",
"source_node": "calc_open",
"target_node": "end",
"action": {
"type": "text_input",
"target": {
"by_role": "button",
"selection_policy": "first"
},
"parameters": {
"text": "${expression}=",
"defaults": {
"expression": "2+2"
}
}
},
"constraints": {
"timeout_ms": 3000
},
"confidence_threshold": 0.8
}
],
"metadata": {
"author": "RPA Vision V3",
"tags": ["demo", "calculator"],
"difficulty": "easy"
}
}

View File

@@ -0,0 +1,184 @@
# Évaluation comparative de LLMs pour la décision de facturation urgences
> Démo aiva-vision — décision automatisée *forfait urgences vs requalification en hospitalisation MCO*
> Date : 28 avril 2026
## Contexte et enjeu business
À la sortie d'un passage aux urgences, deux régimes de facturation T2A/PMSI sont possibles :
- **Forfait urgences** (FFU/ATU) : passage simple, retour à domicile — valorisation ~30-200 €
- **Requalification en hospitalisation MCO** (GHM) : séjour court avec surveillance prolongée, soins continus, transfert spécialisé — valorisation 1 000 à 5 000 €+
L'écart financier est massif et la décision repose sur des critères PMSI/ATIH lisibles dans le DPI urgences (durée de présence, surveillance scopée, oxygénothérapie, soins IV itératifs, transferts, etc.). Aujourd'hui cette décision est prise manuellement par les médecins DIM, avec un risque significatif de **sous-codage** (manque à gagner) ou de **sur-codage** (risque de contrôle ATIH).
L'objectif de cette évaluation est d'identifier le LLM le plus pertinent pour assister cette décision dans le cadre d'aiva-vision, en respectant la contrainte d'un **déploiement 100 % local** (RGPD/HDS).
## Méthodologie
### Jeu d'évaluation
15 DPI urgences synthétiques en français médical réaliste, structurés en trois catégories :
| Catégorie | N | Description |
|---|---|---|
| Simple (forfait clair) | 5 | Entorse, plaie suturée, colique néphrétique soulagée, fièvre virale, asthme léger |
| Complexe (hospit. évidente) | 5 | Pneumopathie hypoxémiante, OAP, AVC thrombolysé, sepsis, SCA NSTEMI |
| Borderline | 5 | Douleur thoracique tropos négatives, gériatrie post-malaise UHCD 27 h, intoxication médicamenteuse, vertige rotatoire, pyélonéphrite simple |
Les cas borderline sont la **vraie métrique business** — ils représentent les situations où la décision est ambiguë et où le LLM apporte de la valeur.
### Tâche évaluée
Pour chaque DPI, le modèle doit produire un JSON structuré contenant :
- `decision` : `FORFAIT_URGENCE` ou `REQUALIFICATION_HOSPITALISATION`
- `elements_pour_hospitalisation` : faits littéralement extraits du DPI
- `elements_pour_forfait` : faits littéralement extraits du DPI
- `duree_passage_heures` : calculée depuis les horaires du dossier
- `justification` : 2-3 phrases
- `confiance` : `elevee` / `moyenne` / `faible`
Le prompt impose une extraction littérale (pas d'invention) et une modulation honnête de la confiance.
### Modèles évalués
Onze modèles, locaux et cloud, balayant le spectre des architectures et tailles disponibles fin avril 2026 :
- **Médicaux spécialisés** : `medgemma:4b`, `medgemma:27b-it` (Q4_K_S), `Apollo2-9B`
- **Fine-tune T2A maison** : `t2a-gemma3-27b-q4`
- **Généralistes locaux** : `qwen2.5:7b`, `qwen2.5:14b`, `gemma4`
- **Reasoning models** : `DeepSeek-R1`, `qwen3-next:80b-cloud`
- **Top tier cloud** : `gemma3:27b-cloud`, `gpt-oss:120b-cloud`
## Résultats
### Classement global
| # | Modèle | VRAM | Type | Accuracy | Simple | Complex | Border | FN | Latence |
|---|---|---|---|---|---|---|---|---|---|
| 1 | t2a-gemma3-27b-q4 (fine-tune maison) | 16 GB* | Local FT | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 52 s |
| 2 | gpt-oss:120b-cloud | ~70 GB | Cloud | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 7,5 s |
| 3 | gemma3:27b-cloud | ~16 GB | Cloud | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 5,6 s |
| 4 | medgemma:27b-it (Q4_K_S) | 16 GB* | Local | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 61 s |
| 5 | qwen2.5:7b | 4,7 GB | Local | **15/15 (100 %)** | 5/5 | 5/5 | 5/5 | 0 | 5,1 s |
| 6 | qwen2.5:14b | 9,0 GB | Local | 14/15 (93 %) | 5/5 | 5/5 | 4/5 | 1 | 10,5 s |
| 7 | gemma4:latest | 9,6 GB | Local | 14/15 (93 %) | 5/5 | 5/5 | 4/5 | 0 | 3,0 s |
| 8 | medgemma:4b | 3,3 GB | Local | 13/15 (87 %) | 5/5 | 4/5 | 4/5 | 1 | 3,5 s |
| 9 | Apollo2-9B Q4_K_S | 5,5 GB | Local | 12/15 (80 %) | 5/5 | 3/5 | 4/5 | 3 | 9,9 s |
| 10 | DeepSeek-R1:latest (distill 5 GB) | 5,2 GB | Local | 12/15 (80 %) | 5/5 | 3/5 | 4/5 | 3 | 4,2 s |
| 11 | qwen3-next:80b-cloud | ~50 GB | Cloud | 8/15 (53 %) | 2/5 | 4/5 | 2/5 | 0 | 20 s |
*16 GB sur disque, déborde de la 12 GB VRAM de la machine de test → offload CPU. Sur DGX Spark (128 GB unified) : estimation 5-8 s/cas.
### Insights majeurs
**1. La spécialisation médicale anglophone n'aide pas sur cette tâche.**
`medgemma:4b` (13/15) et `Apollo2-9B` (12/15) sont battus par des généralistes plus petits ou non médicaux. Apollo2-9B, pourtant le seul modèle médical avec un support français explicite (FrenchMedMCQA, MMLU_FR), rate **deux cas complexes tranchés** (Pneumopathie hypoxémiante, SCA NSTEMI) — ce qui est rédhibitoire pour un déploiement.
Le levier de performance n'est pas la connaissance médicale générique, mais **la connaissance des règles T2A/PMSI/ATIH françaises**.
**2. Le fine-tune T2A maison atteint 100 % et égale GPT-OSS 120B.**
`t2a-gemma3-27b-q4` (fine-tune sur le domaine T2A) est au niveau d'un modèle 5 fois plus gros tournant en cloud. C'est la confirmation que **l'effort de fine-tuning sur le domaine PMSI paie clairement**.
**3. Reasoning models trop heavy pour une décision binaire structurée.**
`qwen3-next:80b-cloud` (53 %) "over-thinks" : le budget tokens est consommé par le raisonnement avant la sortie JSON. Quand il répond, le raisonnement est de très haute qualité (CURB-65, IDSA/ATS cités), mais le coût n'est pas justifié pour cette tâche.
`DeepSeek-R1:latest` (distill 5 GB local, 80 %) souffre du même problème en plus léger — 3 faux négatifs sur des urgences évidentes.
**4. qwen2.5:7b écrase le rapport perf/coût.**
Avec **4,7 GB VRAM, 5,1 s/cas et 15/15**, ce modèle généraliste atteint la performance des 27B et 120B sur cette tâche. C'est le candidat évident pour la démo immédiate sur la machine actuelle.
### Calibration de la confiance
| Modèle | Élevée | Moyenne | Faible | Commentaire |
|---|---|---|---|---|
| qwen2.5:14b | 7 | 8 | 0 | Meilleure modulation |
| gemma3:27b-cloud | 13 | 2 | 0 | Acceptable |
| qwen2.5:7b | 10 | 5 | 0 | Acceptable |
| t2a-gemma3-27b-q4 | 15 | 0 | 0 | Aucune modulation (effet du fine-tuning) |
| medgemma:27b-it | 15 | 0 | 0 | Aucune modulation |
| gpt-oss:120b-cloud | 15 | 0 | 0 | Aucune modulation |
Les modèles 100 % accuracy ont tous tendance à sortir « élevée » systématiquement. C'est un point d'amélioration à intégrer dans une v2 du fine-tune (ajout d'exemples avec confiance modulée dans le dataset d'entraînement).
## Choix retenu pour la démo aiva-vision
**Modèle : `qwen2.5:7b`**
Justification selon les trois critères opérationnels :
| Critère | qwen2.5:7b | Détail |
|---|---|---|
| Aucune erreur diag | 15/15 (100 %) | À égalité avec les top tiers (27B+, 120B) |
| Latence | 5,1 s/cas | La plus basse parmi les 100 % |
| Ressources | 4,7 GB VRAM | La plus légère parmi les 100 % |
Le modèle tient sur GPU consumer (12 GB VRAM), laisse 7 GB libres pour les services aiva-vision concurrents (CLIP, FAISS, grounder UI), et atteint la performance des modèles 5-25 fois plus gros sur cette tâche. C'est l'optimum pour la démonstration.
## Recommandation pour le déploiement production
**Cible : DGX Spark (128 GB unified memory)**
Sur la machine cible de production, deux options crédibles :
**Option A — Fine-tune T2A maison (`t2a-gemma3-27b-q4`)**
- 100 % local, IP propriétaire, contrôle total
- Performance équivalente à GPT-OSS 120B et MedGemma 27B sur ce benchmark
- Latence estimée 5-8 s/cas sur DGX Spark (sans offload CPU)
- Conforme RGPD/HDS sans réserve
- **À privilégier** pour la production hospitalière
**Option B — qwen2.5:7b en première ligne, fine-tune en escalade**
- qwen2.5:7b répond immédiatement (5 s)
- Fine-tune T2A appelé uniquement sur les cas où qwen2.5 sort en confiance « moyenne » ou « faible »
- Compromis débit/qualité optimal
## Limites de l'évaluation
- **Échantillon petit** : 15 cas. Les écarts entre 14/15 et 15/15 ne sont pas statistiquement significatifs. Pour une validation prod, viser 100-300 cas labellisés couvrant l'intégralité de la grille ATIH.
- **Cas synthétiques** : les DPI ont été générés à partir de la connaissance médicale, pas extraits de production. La validation finale nécessite des cas réels anonymisés.
- **Calibration de confiance** : les modèles 100 % sortent tous « élevée » — leur capacité à *signaler* l'incertitude n'a pas pu être discriminée sur ce jeu.
- **PMSI 2026** : le format **V016** (en vigueur depuis le 1er janvier 2026) introduit de nouvelles règles non couvertes ici (champ « Nombre de disciplines de service » étendu). À intégrer dans la v2 du dataset.
## Pistes d'amélioration
1. Étendre le jeu d'évaluation à 100+ cas réels anonymisés couvrant les 28 GHM les plus fréquents en MCO.
2. Recoller le fine-tune T2A avec un dataset incluant la modulation de confiance (cas borderline annotés « moyenne »).
3. Intégrer les évolutions PMSI 2026 (V016, nouvelles règles SMR) dans le prompt et le training.
4. Évaluer un système hybride à deux étages (qwen2.5:7b → t2a-gemma3-27b-q4 sur escalade).
5. Mettre en place un eval harness automatique tournant en CI sur chaque mise à jour de modèle.
## Annexe — Détail par cas
Les 15 cas et les prédictions par modèle sont consolidés dans `resultats_v2.json`.
Cas qui ont discriminé les modèles :
- **Cas 6 (Pneumopathie hypoxémiante)** : raté par medgemma:4b et Apollo2-9B — pourtant tous les critères HOSPIT sont présents (O2, scope, ATB IV, transfert pneumo, durée 22 h). Cas-test pivot.
- **Cas 12 (Personne âgée UHCD 27 h)** : raté par qwen2.5:14b. Borderline gériatrique avec durée > 24 h comme seul critère décisif. Cas-test pivot.
- **Cas 10 (SCA NSTEMI)** : raté par Apollo2-9B et DeepSeek-R1. Devrait être trivial pour un modèle médical.
## Reproductibilité
Tous les scripts et données sont dans `/home/dom/ai/rpa_vision_v3/demo/facturation_urgences/` :
```
cas_dpi.py 15 DPI synthétiques (Python)
run_simulation.py v1 mono-modèle (référence historique)
run_simulation_v2.py v2 multi-modèles, prompt durci, support reasoning
run_qwen3_only.py rerun ciblé qwen3-next:80b-cloud
run_extra_models.py batch t2a + DeepSeek + gpt-oss
run_medgemma27b.py test MedGemma 27B (thiagomoraes)
run_apollo2.py test Apollo2-9B
resultats_v2.json résultats consolidés des 11 modèles (source de vérité)
RESULTATS.md ce document
```
Lancement : `python3 -u run_simulation_v2.py`. Prérequis : Ollama démarré sur `localhost:11434`.

View File

@@ -0,0 +1,416 @@
"""
10 dossiers patients informatisés (DPI) urgences synthétiques.
Mix simple (forfait urgence) / complexe (requalification hospitalisation MCO).
Données entièrement fictives.
"""
from textwrap import dedent
CAS = [
# ---------- 5 cas SIMPLES → FORFAIT_URGENCE ----------
{
"id": 1,
"type": "simple",
"titre": "Entorse cheville droite",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_cles": ["durée < 4h", "pas de surveillance scopée", "retour domicile"],
"dpi": dedent("""
Mme D., 28 ans, sportive, sans antécédent.
Admission 14h12. Motif : torsion cheville droite il y a 2h en jogging,
douleur 6/10, marche difficile, pas de craquement.
Constantes : TA 122/74, FC 78, SpO2 99% AA, T° 36.8, FR 14.
Examen : œdème malléole externe, douleur palpation LLE, pas de douleur osseuse,
critères d'Ottawa négatifs. Mobilité conservée.
RX cheville droite : pas de fracture.
Conduite : antalgie palier 1 (paracétamol), strapping, attelle Aircast,
arrêt sport 10 jours, consultation médecin traitant J7.
Sortie 16h05, autonomie marche avec attelle. Score douleur sortie 3/10.
Durée passage : 1h53.
""").strip(),
},
{
"id": 2,
"type": "simple",
"titre": "Plaie main suturée",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_cles": ["geste ponctuel", "durée < 3h", "retour domicile"],
"dpi": dedent("""
M. L., 42 ans, menuisier, à jour vaccinations (VAT 2023).
Admission 09h40. Motif : plaie face dorsale main gauche par cutter
il y a 30 min, saignement modéré contrôlé par compression.
Constantes : TA 130/80, FC 82, SpO2 99% AA, T° 36.6.
Examen : plaie linéaire 4 cm, profondeur sous-cutanée,
absence d'atteinte tendineuse (extension 2è/3è doigts conservée),
sensibilité distale normale, pouls radial bien perçu.
Désinfection Bétadine, anesthésie locale Xylocaïne 1%, parage,
suture par 6 points séparés Ethilon 4/0. Pansement.
Pas de rappel VAT (à jour). Conseils plaie remise.
Ablation fils J10 chez médecin traitant. Sortie 11h25.
Durée passage : 1h45.
""").strip(),
},
{
"id": 3,
"type": "simple",
"titre": "Colique néphrétique non compliquée",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_cles": ["soulagement obtenu", "pas de complication", "retour domicile"],
"dpi": dedent("""
M. K., 38 ans, sans antécédent.
Admission 02h15. Motif : douleur lombaire droite brutale irradiant fosse iliaque,
EVA 9/10, agitation, nausées, premier épisode.
Constantes : TA 145/88, FC 96, SpO2 98%, T° 37.0, BU : hématurie ++.
Examen : pas de défense, fosse lombaire droite douloureuse, pas de fièvre.
Bio : créat 88 µmol/L, CRP < 5, leucos 8.5 G/L. ECBU envoyé.
Scanner abdo-pelvien sans injection : calcul urétéral droit 4 mm, dilatation
pyélocalicielle modérée, pas de complication.
Antalgie : kétoprofène 100 mg IV puis paracétamol 1 g + phloroglucinol IV.
Évolution : EVA 1/10 à H+2, reprise alimentation OK, miction spontanée.
Conseils filtration urines, ordonnance AINS + alpha-bloquant (tamsulosine),
consultation urologie sous 8 jours. Sortie 06h50.
Durée passage : 4h35.
""").strip(),
},
{
"id": 4,
"type": "simple",
"titre": "Fièvre virale enfant",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_cles": ["bilan rassurant", "examen normal", "retour domicile"],
"dpi": dedent("""
Enfant T., 4 ans, vaccinations à jour, accompagné des parents.
Admission 19h30. Motif : fièvre 39°C depuis 24h, rhinorrhée, toux sèche,
asthénie modérée, alimentation conservée, pas de vomissement.
Constantes : TA 100/60, FC 130 (fébrile), SpO2 99% AA, T° 38.7, FR 24.
Examen : pharynx érythémateux, pas de purulence, tympans normaux,
auscultation pulmonaire libre, abdomen souple, pas de syndrome méningé,
pas d'éruption, comportement conservé après antipyrétique.
Pas de bilan biologique réalisé (clinique rassurante).
Paracétamol 15 mg/kg PO, recontrôle T° à H+1 : 37.4°C.
Diagnostic : virose ORL probable.
Conseils parents : hydratation, paracétamol alterné si besoin,
reconsulter si signe de gravité (geignement, marbrures, refus boisson).
Sortie 21h00. Durée : 1h30.
""").strip(),
},
{
"id": 5,
"type": "simple",
"titre": "Crise d'asthme légère",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_cles": ["réponse rapide aérosols", "DEP > 80% post", "retour domicile"],
"dpi": dedent("""
Mme R., 22 ans, asthme connu, traitement de fond Symbicort.
Admission 17h45. Motif : dyspnée sifflante depuis 3h après effort,
toux, oppression thoracique, première crise de la saison pollens.
Constantes : TA 128/76, FC 102, SpO2 95% AA, T° 36.8, FR 22.
Examen : sibilants expiratoires diffus, pas de tirage, pas de cyanose,
parle en phrases complètes. DEP entrée : 280 L/min (60% théorique).
Aérosols : salbutamol 5 mg + ipratropium 0.5 mg x 2 à 20 min d'intervalle,
corticoïdes PO prednisolone 60 mg.
Évolution H+1 : SpO2 99%, FR 16, auscultation libre, DEP 420 L/min (90%).
Ordonnance : poursuite Symbicort, salbutamol PRN, prednisolone 5j,
consultation pneumologue. Sortie 19h50. Durée : 2h05.
""").strip(),
},
# ---------- 5 cas COMPLEXES → REQUALIFICATION_HOSPITALISATION ----------
{
"id": 6,
"type": "complexe",
"titre": "Pneumopathie hypoxémiante",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_cles": ["O2 thérapie", "antibiothérapie IV", "surveillance scopée > 24h", "transfert service"],
"dpi": dedent("""
M. B., 71 ans, BPCO stade II, ex-tabac 40 PA, HTA, diabète T2.
Admission 21h10. Motif : fièvre 39.5°C depuis 48h, toux productive
verdâtre, dyspnée croissante, douleur basithoracique droite.
Constantes entrée : TA 105/62, FC 118, SpO2 86% AA → 92% sous O2 4 L/min,
T° 39.4, FR 28, marbrures genoux.
Examen : foyer crépitant base droite, tirage modéré,
confusion légère (GCS 14).
Bio : CRP 287 mg/L, leucos 18.2 G/L, PCT 4.8 ng/mL, lactates 2.6,
créat 132 µmol/L (base 90), iono normal, gaz : pH 7.42 / PaO2 58 sous O2.
RX thorax : opacité alvéolaire systématisée lobe inférieur droit.
Antigénurie pneumocoque +.
CRB-65 : 3 (confusion, FR 28, âge ≥ 65).
PEC : O2 thérapie continue, ceftriaxone 2 g IV + spiramycine,
remplissage NaCl 1 L, scope continu, surveillance toutes 2h.
Évolution H+18 : SpO2 95% sous O2 2 L, T° 38.0, FR 22, lactates 1.4.
UHCD puis transfert pneumologie à H+22 pour poursuite ATB IV
et sevrage O2. Durée passage urgences : 22h.
""").strip(),
},
{
"id": 7,
"type": "complexe",
"titre": "OAP cardiogénique",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_cles": ["VNI", "diurétiques IV répétés", "USC/USIC", "surveillance > 24h"],
"dpi": dedent("""
Mme V., 79 ans, cardiopathie ischémique (IDM 2019), FEVG 35%,
HTA, FA paroxystique sous AVK, insuffisance rénale chronique stade III.
Admission 03h25. Motif : dyspnée brutale en décubitus, orthopnée
+++, expectoration mousseuse rosée, anxiété majeure.
Constantes : TA 178/102, FC 124 irrégulier (FA rapide), SpO2 82% AA
→ 90% sous O2 MHC 12 L/min, T° 36.5, FR 36, sueurs.
Examen : crépitants bilatéraux jusqu'aux sommets, turgescence jugulaire,
OMI bilatéraux godet +.
ECG : FA 130 bpm, ondes T négatives en latéral, pas de sus-décalage.
Bio : tropos 0.08 puis 0.12 µg/L (légère ascension), BNP 4 850 pg/mL,
créat 168 µmol/L (base 130), iono : Na 132, K 4.8.
RX thorax : surcharge alvéolo-interstitielle bilatérale.
ETT urgences : FEVG 25%, IM modérée, OG dilatée.
PEC : VNI (CPAP) PEP 8 cmH2O FiO2 60%, furosémide 80 mg IV bolus
puis 40 mg x 2, dérivés nitrés IVSE, ralentissement FA par amiodarone IVSE.
Évolution H+6 : sevrage VNI, SpO2 94% sous O2 3 L, FC 95.
Bilan diurèse : -1.8 L à H+12. Tropos H+6 : 0.18 (stable légèrement).
Transfert USIC à H+14 pour surveillance, gestion FA et coronarographie
programmée. Durée passage : 14h.
""").strip(),
},
{
"id": 8,
"type": "complexe",
"titre": "AVC ischémique thrombolysé",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_cles": ["thrombolyse IV", "filière AVC", "transfert UNV", "surveillance neuro rapprochée"],
"dpi": dedent("""
M. P., 68 ans, HTA, dyslipidémie, tabagisme actif, FA paroxystique
non anticoagulée (refus patient).
Admission 11h08, début symptômes 10h30 (témoin direct épouse).
Motif : déficit moteur hémicorps droit + aphasie d'expression brutale.
NIHSS entrée : 14 (motricité 4, langage 3, négligence, dysarthrie).
Constantes : TA 184/96, FC 88 irrégulier, SpO2 98%, T° 36.7, glycémie 7.2.
ECG : FA 90 bpm.
Activation filière AVC (thrombolyse).
Scanner cérébral non injecté : pas d'hémorragie, ASPECTS 9.
AngioTDM : occlusion M1 sylvienne gauche.
PEC : altéplase 0.9 mg/kg IV (10% bolus + 90% sur 1h) débutée à 11h45
(door-to-needle 37 min), surveillance neuro et TA toutes 15 min.
Indication thrombectomie : transfert UNV de référence à 12h40
(médicalisé SMUR), récupération partielle déficit pendant transport.
Durée passage urgences : 1h32 — mais relève d'une hospitalisation
(filière AVC complète avec UNV + thrombectomie, surveillance ≥ 24h).
""").strip(),
},
{
"id": 9,
"type": "complexe",
"titre": "Sepsis sur pyélonéphrite obstructive",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_cles": ["antibiothérapie IV", "remplissage", "drainage urinaire", "surveillance > 48h"],
"dpi": dedent("""
Mme F., 64 ans, diabète T2 mal équilibré (HbA1c 9.2),
antécédent de calculs rénaux.
Admission 23h40. Motif : fièvre 40°C avec frissons depuis 24h,
douleur lombaire droite, vomissements, troubles confusionnels
apparus dans l'après-midi.
Constantes : TA 88/52, FC 128, SpO2 96%, T° 39.8, FR 26,
marbrures, GCS 13. qSOFA 3.
Examen : douleur intense fosse lombaire droite, BU leucos +++ nitrites +,
pas de défense abdominale.
Bio : CRP 412, leucos 22, PCT 28 ng/mL (élevé +++), lactates 3.9,
créat 198 µmol/L (base 75 → IRA), glycémie 4.1 g/L.
ECBU : direct nombreux BGN.
TDM abdo injecté : pyélonéphrite droite sur calcul urétéral 7 mm
avec dilatation cavités, pas d'abcès.
Diagnostic : sepsis sévère sur pyélonéphrite obstructive lithiasique.
PEC : remplissage 30 mL/kg cristalloïdes (2L sur 1h),
ceftriaxone 2 g IV + amikacine 25 mg/kg, scope continu.
Avis urologue : drainage par sonde JJ en urgence (programmée H+4).
Évolution H+3 : TA 105/65 après remplissage, lactates 2.1,
FC 108. Toujours fébrile.
Transfert bloc urgence pour JJ, puis hospitalisation urologie
pour poursuite ATB IV et surveillance. Durée passage : 5h30.
""").strip(),
},
{
"id": 10,
"type": "complexe",
"titre": "SCA NSTEMI",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_cles": ["tropos cinétique +", "anticoagulation", "USIC", "coronarographie"],
"dpi": dedent("""
M. M., 58 ans, tabagique 30 PA, hypercholestérolémie non traitée,
père décédé IDM à 60 ans.
Admission 08h15. Motif : douleur thoracique constrictive rétrosternale
irradiant bras gauche, apparue ce matin au réveil 06h00, durée 1h
puis récidive 07h30 persistante, sueurs.
Constantes : TA 142/86, FC 92, SpO2 98%, T° 36.8, FR 16.
Examen : bruits du cœur réguliers, pas de souffle, auscultation
pulmonaire libre, pas de signe d'IVD.
ECG entrée : ST sous-décalé V4-V6 et DII-DIII-aVF (1 mm), ondes T
négatives latérales, pas de sus-décalage. Pas de BBG.
Bio H0 : tropos US 145 ng/L (N < 14), CK 220, créat 92,
iono normal, NFS normale.
Bio H+3 : tropos US 480 ng/L (cinétique nette), CK-MB 28.
Diagnostic : SCA NSTEMI (NSTE-ACS, GRACE 142, TIMI 4).
PEC : aspirine 250 mg + ticagrelor 180 mg dose de charge,
HBPM enoxaparine 1 mg/kg/12h, atorvastatine 80 mg, IEC, b-bloquant.
Dérivés nitrés IV pour douleur résiduelle.
Avis cardio : coronarographie H+24 (stratification GRACE).
Transfert USIC à H+4 pour surveillance et coro programmée.
Durée passage urgences : 4h20.
""").strip(),
},
# ---------- 5 cas BORDERLINE — la vraie valeur business ----------
{
"id": 11,
"type": "borderline",
"titre": "Douleur thoracique jeune, bilan négatif",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_cles": [
"tropos x2 négatives", "ECG normaux", "patient jeune sans FdR",
"sortie sous 12h sans surveillance scopée", "pas de transfert"
],
"dpi": dedent("""
M. C., 31 ans, sans antécédent, non tabagique, pas d'ATCD familial cardiovasculaire.
Admission 22h40. Motif : douleur thoracique antérieure intermittente depuis 4h,
non irradiante, modulée par les changements de position, contexte de stress
professionnel. EVA 4/10. Pas de dyspnée, pas de palpitations.
Constantes : TA 128/74, FC 78, SpO2 99% AA, T° 36.7, FR 14. État général conservé,
patient anxieux mais cohérent.
Examen : auscultation cardio-pulmonaire normale, pas de douleur reproductible
à la palpation, mollets souples.
ECG H0 : rythme sinusal régulier, pas d'anomalie de la repolarisation,
pas de sus ni sous-décalage. ECG H+3 : superposable.
Bio : tropos US H0 = 6 ng/L (N < 14), tropos US H+3 = 7 ng/L (pas de cinétique).
D-Dimères : 280 ng/mL (négatifs). NFS, iono, créat normaux.
RX thorax : sans particularité.
Score HEART : 2 (faible risque).
Évolution : douleur résiduelle 1/10 sous antalgie palier 1, patient rassuré.
Avis cardio téléphonique : pas d'argument SCA, autorisation sortie.
Conseils reconsulter si récidive avec signes de gravité.
Sortie 09h15 le lendemain (durée passage 10h35) — observation simple sans scope.
""").strip(),
},
{
"id": 12,
"type": "borderline",
"titre": "Personne âgée, malaise, surveillance UHCD > 24h",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_cles": [
"durée UHCD > 24h", "bilan étiologique itératif",
"surveillance neuro répétée", "patient gériatrique fragile"
],
"dpi": dedent("""
Mme G., 84 ans, vit seule à domicile, autonome avec déambulateur.
ATCD : HTA, FA sous AOD (apixaban), insuffisance cardiaque NYHA II,
arthrose, ostéoporose. Polymédication 8 traitements.
Admission 10h15 amenée par les pompiers. Motif : malaise sans prodromes
au lever, chute de sa hauteur, pas de PC clairement objectivée
(vit seule, retrouvée par voisine 30 min plus tard, confuse à l'arrivée).
Hématome pariétal droit, plaie superficielle arcade.
Constantes entrée : TA 102/58, FC 52, SpO2 96%, T° 36.4, glycémie 0.94.
Examen : confusion légère (MMSE non évaluable), pas de déficit neuro
focal, mobilité conservée, douleur hanche droite à la mobilisation.
ECG : FA lente 50 bpm, pas de trouble de conduction. RX bassin/hanche : pas
de fracture. Scanner cérébral non injecté : pas de saignement, atrophie
cortico-sous-corticale. Bio : NFS normale, créat 112 (base 90), iono normal,
tropos négatives, BNP 480, INR non applicable (AOD).
Hypothèse : malaise sur bradycardie médicamenteuse (b-bloquant + AOD)
vs étiologie cardiogénique. Holter ECG demandé.
UHCD pour surveillance neuro et hémodynamique répétée toutes 4h.
Évolution : nouveau malaise H+8 sans PC, FC 42, arrêt b-bloquant en accord
avec cardio. ETT à H+18 : FEVG 50%, pas d'anomalie aiguë.
Sortie envisagée H+24 mais maintien observation jusqu'à H+27 pour stabilité
tensionnelle puis retour domicile avec aide à domicile renforcée.
Durée totale passage : 27h.
""").strip(),
},
{
"id": 13,
"type": "borderline",
"titre": "Intoxication médicamenteuse volontaire",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_cles": [
"surveillance toxico prolongée", "N-acétylcystéine IV",
"avis psy avec hospit en aval", "durée > 18h"
],
"dpi": dedent("""
Mlle T., 22 ans, étudiante, antécédents de troubles anxieux suivis,
rupture sentimentale récente.
Admission 23h05 amenée par sa colocataire. Motif : ingestion volontaire
estimée à 30 cp paracétamol 500 mg (15 g) + 10 cp lorazépam 1 mg
il y a environ 4h, dans un but suicidaire. Verbalisation des idées noires
persistantes à l'arrivée.
Constantes : TA 110/68, FC 84, SpO2 99% AA, T° 36.5, GCS 14
(somnolence légère, réveillable), pupilles intermédiaires symétriques.
Examen : pas de signes hépatiques, abdomen souple.
Bio H+5 post-ingestion : paracétamolémie 188 mg/L (au-dessus du nomogramme
de Rumack-Matthew, ligne de toxicité), ASAT 45, ALAT 38, TP 88%, créat 72.
Toxique urinaire : benzodiazépines +.
ECG : RS 84, QT normal.
PEC : N-acétylcystéine IV protocole 21h (bolus 150 mg/kg puis 50 puis 100),
scope continu, bilan hépatique itératif H+8 H+16 H+24.
Évolution H+12 : ASAT 62, ALAT 58, TP 82%, paracétamolémie en
décroissance. GCS 15 stable.
Avis psychiatre de garde H+18 : hospitalisation en psychiatrie
sectorielle après fin du protocole somatique, risque suicidaire
élevé maintenu, refus de sortie.
Transfert service psychiatrie H+22 après bilan hépato H+24 stable.
Durée passage urgences : 22h.
""").strip(),
},
{
"id": 14,
"type": "borderline",
"titre": "Vertige rotatoire isolé bénin",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_cles": [
"examen neuro normal", "manœuvre de Dix-Hallpike +",
"pas de transfert", "sortie rapide après traitement"
],
"dpi": dedent("""
Mme A., 56 ans, HTA traitée, pas d'autre antécédent.
Admission 08h30. Motif : vertige rotatoire brutal au lever ce matin,
durée environ 30 sec puis récidive aux changements de position,
nausées, pas de vomissement, pas de céphalée, pas de troubles auditifs.
Constantes : TA 142/82, FC 76, SpO2 98%, T° 36.6, glycémie 5.8.
Examen neuro : pas de déficit moteur ni sensitif, pas de syndrome
cérébelleux, paires crâniennes normales, pas de nystagmus spontané,
HINTS test rassurant (pas de skew, saccades de rattrapage normales).
Manœuvre de Dix-Hallpike : nystagmus géotropique torsionnel typique
à droite, latence courte, fatigabilité.
Diagnostic retenu : vertige paroxystique positionnel bénin (VPPB)
canal postérieur droit.
PEC : manœuvre libératoire d'Epley réalisée, disparition des symptômes,
antiémétique métopimazine PO.
Pas de bilan biologique, pas d'imagerie (clinique typique, examen normal).
Sortie 12h00 avec consignes positionnelles et consultation ORL programmée
si récidive. Durée passage : 3h30.
""").strip(),
},
{
"id": 15,
"type": "borderline",
"titre": "Pyélonéphrite simple non sévère",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_cles": [
"pas de sepsis", "ATB IV unique puis relais PO",
"tolérance digestive bonne", "sortie sous 8h"
],
"dpi": dedent("""
Mme N., 34 ans, sans antécédent particulier, pas de grossesse,
pas d'uropathie connue.
Admission 16h20. Motif : douleur lombaire droite + brûlures mictionnelles
depuis 48h, fièvre 38.7°C ce matin, frissons. Pas de vomissement,
alimentation conservée.
Constantes : TA 122/74, FC 92, SpO2 99%, T° 38.5, FR 16. qSOFA 0.
Examen : douleur lombaire droite à la palpation, BU leucos +++ nitrites +,
pas de défense abdominale, état général conservé.
Bio : CRP 88, leucos 13 G/L, créat 76, lactates 1.2 (normaux),
PCT 0.4 ng/mL. ECBU envoyé (en attente).
Échographie réno-vésicale au lit : pas de dilatation, pas d'obstacle.
Diagnostic : pyélonéphrite aiguë simple non compliquée, non sévère.
PEC : ceftriaxone 1 g IV dose unique aux urgences, paracétamol PO,
réhydratation orale.
Évolution H+4 : T° 37.6, douleur EVA 2/10, tolérance alimentaire OK.
Ordonnance : relais PO ciprofloxacine 500 mg x 2/j 7 jours,
consultation médecin traitant 48-72h, ECBU à récupérer.
Sortie 23h45 (durée 7h25) avec critères de reconsultation expliqués.
""").strip(),
},
]

View File

@@ -0,0 +1,238 @@
"""
Démo interactive aiva-vision — Aide à la décision de facturation urgences.
Lance : streamlit run demo_app.py
"""
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
import streamlit as st
sys.path.insert(0, str(Path(__file__).parent))
from cas_dpi import CAS # noqa: E402
OLLAMA_URL = "http://localhost:11434/api/generate"
DEFAULT_MODEL = "qwen2.5:7b"
AVAILABLE_MODELS = [
"qwen2.5:7b",
"qwen2.5:14b",
"gemma4:latest",
"t2a-gemma3-27b-q4:latest",
"thiagomoraes/medgemma-27b-it:Q4_K_S",
"gemma3:27b-cloud",
"gpt-oss:120b-cloud",
]
PROMPT_TEMPLATE = """Tu es médecin DIM (Département d'Information Médicale), expert en facturation T2A/PMSI aux urgences hospitalières en France.
Analyse le dossier patient ci-dessous pour déterminer si le passage relève :
- FORFAIT_URGENCE : passage simple, retour à domicile, sans surveillance prolongée ni soins continus
- REQUALIFICATION_HOSPITALISATION : séjour MCO requis selon les critères PMSI/ATIH
INSTRUCTIONS STRICTES :
1. N'utilise QUE des éléments littéralement présents dans le dossier patient. N'invente AUCUN critère.
2. Identifie d'abord les éléments en faveur d'une hospitalisation, puis ceux en faveur d'un forfait, puis tranche.
3. Calcule la durée totale du passage en heures (admission → sortie/transfert) à partir des horaires du dossier.
4. Module ta confiance honnêtement :
- "elevee" uniquement si tous les indices convergent
- "moyenne" si éléments ambivalents
- "faible" si information manquante ou très atypique
Réponds STRICTEMENT en JSON valide, sans texte avant ni après :
{{
"duree_passage_heures": <nombre>,
"elements_pour_hospitalisation": [<faits littéralement extraits du dossier>],
"elements_pour_forfait": [<faits littéralement extraits du dossier>],
"decision": "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION",
"justification": "<2-3 phrases s'appuyant explicitement sur les faits ci-dessus>",
"confiance": "elevee" | "moyenne" | "faible"
}}
DOSSIER PATIENT :
{dpi}
"""
def query_model(model: str, dpi_text: str, timeout: int = 300) -> dict:
payload = {
"model": model,
"prompt": PROMPT_TEMPLATE.format(dpi=dpi_text),
"stream": False,
"format": "json",
"keep_alive": "5m",
"options": {
"temperature": 0.1,
"num_predict": 4000,
"num_ctx": 4096,
"reasoning_effort": "minimal",
},
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
OLLAMA_URL, data=data, headers={"Content-Type": "application/json"}, method="POST"
)
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
return {"_error": str(e), "_elapsed_s": round(time.time() - t0, 1)}
elapsed = time.time() - t0
raw_response = body.get("response", "").strip()
raw_thinking = body.get("thinking", "").strip()
candidates = [raw_response]
if not raw_response and raw_thinking:
last_close = raw_thinking.rfind("}")
last_open = raw_thinking.rfind("{", 0, last_close)
if last_open != -1 and last_close != -1:
candidates.append(raw_thinking[last_open:last_close + 1])
parsed = None
for cand in candidates:
cleaned = cand
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[-1]
if cleaned.endswith("```"):
cleaned = cleaned.rsplit("```", 1)[0]
cleaned = cleaned.strip()
try:
parsed = json.loads(cleaned)
break
except json.JSONDecodeError:
continue
if parsed is None:
return {"_parse_error": True, "_raw": (raw_response or raw_thinking)[:500], "_elapsed_s": round(elapsed, 1)}
parsed["_elapsed_s"] = round(elapsed, 1)
parsed["_eval_count"] = body.get("eval_count")
return parsed
# ============================== UI ==============================
st.set_page_config(page_title="aiva-vision — Démo facturation urgences", layout="wide")
st.title("aiva-vision — Aide à la décision de facturation urgences")
st.caption("Forfait urgences vs requalification en hospitalisation MCO — décision T2A/PMSI assistée par LLM local")
# Barre latérale
with st.sidebar:
st.subheader("Configuration")
model = st.selectbox(
"Modèle LLM",
AVAILABLE_MODELS,
index=AVAILABLE_MODELS.index(DEFAULT_MODEL),
help="qwen2.5:7b est le choix par défaut (100 % accuracy, 5 s/cas, 4,7 GB VRAM)",
)
st.markdown("---")
st.subheader("Cas pré-chargés")
case_options = ["— Saisie libre —"] + [
f"#{c['id']:>2} [{c['type'][:4]}] {c['titre']}" for c in CAS
]
selected_case = st.selectbox("Charger un cas synthétique", case_options, index=0)
st.markdown("---")
st.markdown(
"**Enjeu business**\n\n"
"Forfait urgences ≈ 30-200 €\n\n"
"Requalification hospit ≈ 1 000-5 000 €+\n\n"
"Écart ≈ × 10-25"
)
# Chargement du DPI sélectionné dans la zone de saisie
default_dpi = ""
ground_truth = None
case_meta = None
if selected_case != "— Saisie libre —":
idx = case_options.index(selected_case) - 1
case_meta = CAS[idx]
default_dpi = case_meta["dpi"]
ground_truth = case_meta["verite_terrain"]
col_input, col_output = st.columns([1, 1])
with col_input:
st.subheader("Dossier patient (DPI urgences)")
dpi_text = st.text_area(
"Coller ou saisir le dossier patient",
value=default_dpi,
height=500,
placeholder="Mme X, 56 ans, admission 14h00...",
)
analyze = st.button("Analyser", type="primary", use_container_width=True)
with col_output:
st.subheader("Décision facturation")
if analyze and dpi_text.strip():
with st.spinner(f"Analyse par {model}..."):
result = query_model(model, dpi_text)
if result.get("_error"):
st.error(f"Erreur Ollama : {result['_error']}")
elif result.get("_parse_error"):
st.warning("Le modèle n'a pas produit de JSON valide.")
with st.expander("Voir la réponse brute"):
st.code(result.get("_raw", ""))
else:
decision = result.get("decision", "?")
confiance = result.get("confiance", "?")
duree = result.get("duree_passage_heures", "?")
# Décision en gros
if decision == "REQUALIFICATION_HOSPITALISATION":
st.error(f"### REQUALIFICATION HOSPITALISATION (GHM)")
st.caption("→ valorisation T2A séjour MCO (1 000-5 000 €+)")
elif decision == "FORFAIT_URGENCE":
st.success(f"### FORFAIT URGENCES (FFU/ATU)")
st.caption("→ valorisation forfaitaire (30-200 €)")
else:
st.warning(f"### Décision : {decision}")
# Comparaison avec vérité-terrain
if ground_truth:
if decision == ground_truth:
st.markdown(f"**Vérité-terrain** : `{ground_truth}` — concordance OK")
else:
st.markdown(f"**Vérité-terrain** : `{ground_truth}` — **divergence** !")
# Métriques
mcol1, mcol2, mcol3 = st.columns(3)
mcol1.metric("Confiance", confiance)
mcol2.metric("Durée passage", f"{duree} h")
mcol3.metric("Latence", f"{result.get('_elapsed_s', '?')} s")
# Justification
st.markdown("**Justification**")
st.info(result.get("justification", ""))
# Critères pour/contre
ccol1, ccol2 = st.columns(2)
with ccol1:
st.markdown("**Éléments pour hospitalisation**")
items = result.get("elements_pour_hospitalisation", [])
if items:
for it in items:
st.markdown(f"- {it}")
else:
st.caption("(aucun)")
with ccol2:
st.markdown("**Éléments pour forfait**")
items = result.get("elements_pour_forfait", [])
if items:
for it in items:
st.markdown(f"- {it}")
else:
st.caption("(aucun)")
# JSON brut (debug)
with st.expander("Réponse JSON complète"):
st.json(result)
else:
st.caption("Saisis ou charge un DPI puis clique sur **Analyser**.")

View File

@@ -0,0 +1,264 @@
[
{
"id": 1,
"titre": "Entorse cheville droite",
"type": "simple",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_attendus": [
"durée < 4h",
"pas de surveillance scopée",
"retour domicile"
],
"prediction": {
"decision": "FORFAIT_URGENCE",
"criteres": [
"Torsion de cheville sans fracture",
"Douleur contrôlée par antalgie",
"Pas de surveillance prolongée",
"Retour à domicile possible"
],
"justification": "La patiente présente une torsion de cheville sans fracture, avec une douleur contrôlée par antalgie et une mobilité conservée. Elle est sortie de l'hôpital avec une attelle et une consultation médicale. Ces éléments indiquent un passage simple et ponctuel, sans nécessité de surveillance prolongée en UHCD.",
"confiance": "elevee",
"_elapsed_s": 3.1,
"_eval_count": 151
},
"match": true
},
{
"id": 2,
"titre": "Plaie main suturée",
"type": "simple",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_attendus": [
"geste ponctuel",
"durée < 3h",
"retour domicile"
],
"prediction": {
"decision": "FORFAIT_URGENCE",
"criteres": [
"Plaie superficielle (4 cm)",
"Pas d'atteinte tendineuse",
"Sensibilité normale",
"Saignement contrôlé",
"Pas de surveillance prolongée",
"Retour à domicile"
],
"justification": "La plaie est superficielle, sans atteinte tendineuse ni perte de sensibilité. Le saignement est contrôlé et le patient est sorti rapidement avec des conseils de soins. Il n'y a pas de signes de gravité nécessitant une hospitalisation prolongée.",
"confiance": "elevee",
"_elapsed_s": 1.3,
"_eval_count": 154
},
"match": true
},
{
"id": 3,
"titre": "Colique néphrétique non compliquée",
"type": "simple",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_attendus": [
"soulagement obtenu",
"pas de complication",
"retour domicile"
],
"prediction": {
"decision": "FORFAIT_URGENCE",
"criteres": [
"Douleur lombaire droite brutale avec EVA 9/10",
"Hématurie ++",
"Calcul urétéral droit 4 mm",
"Réponse rapide à l'antalgie (kétoprofène, paracétamol, phloroglucinol)",
"Reprise alimentation et miction spontanée",
"Pas de signes de gravité (pas de fièvre, pas de défense)"
],
"justification": "Le patient présente une douleur lombaire aiguë avec hématurie et un calcul urétéral. Il a répondu rapidement à l'antalgie et a retrouvé une miction spontanée. Il n'y a pas de signes de complication, de surveillance prolongée ou de besoin de soins continus. La durée du passage est courte (4h35).",
"confiance": "elevee",
"_elapsed_s": 1.8,
"_eval_count": 217
},
"match": true
},
{
"id": 4,
"titre": "Fièvre virale enfant",
"type": "simple",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_attendus": [
"bilan rassurant",
"examen normal",
"retour domicile"
],
"prediction": {
"decision": "FORFAIT_URGENCE",
"criteres": [
"Fièvre résolue après paracétamol",
"Absence de signes de gravité (geignement, marbrures, refus boisson)",
"Retour à domicile sans surveillance prolongée"
],
"justification": "Le patient présente une fièvre résolue après administration de paracétamol, sans signes de gravité. Il est sorti de l'hôpital après une courte durée de séjour (1h30) et sans nécessité de surveillance prolongée. Ces éléments permettent de considérer que le passage relève d'un forfait urgence.",
"confiance": "elevee",
"_elapsed_s": 1.3,
"_eval_count": 155
},
"match": true
},
{
"id": 5,
"titre": "Crise d'asthme légère",
"type": "simple",
"verite_terrain": "FORFAIT_URGENCE",
"criteres_attendus": [
"réponse rapide aérosols",
"DEP > 80% post",
"retour domicile"
],
"prediction": {
"decision": "FORFAIT_URGENCE",
"criteres": [
"Retour à domicile sans surveillance prolongée",
"Amélioration clinique rapide avec les aérosols",
"SpO2 stable et bonne tolérance des traitements"
],
"justification": "La patiente a présenté une crise d'asthme aiguë réversible avec une amélioration clinique rapide après traitement. Elle a été traitée en urgence avec des aérosols et des corticostéroïdes, puis a été mise à disposition à domicile avec une consultation pneumologique. Il n'y a pas de signes de gravité ou de nécessité de surveillance prolongée.",
"confiance": "elevee",
"_elapsed_s": 1.4,
"_eval_count": 160
},
"match": true
},
{
"id": 6,
"titre": "Pneumopathie hypoxémiante",
"type": "complexe",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_attendus": [
"O2 thérapie",
"antibiothérapie IV",
"surveillance scopée > 24h",
"transfert service"
],
"prediction": {
"decision": "REQUALIFICATION_HOSPITALISATION",
"criteres": [
"Séjour MCO requis : surveillance scopée prolongée",
"Soins continus IV (ceftriaxone, spiramycine)",
"Oxygénothérapie/VNI (O2 4L/min puis 2L)",
"Examens itératifs (RX thorax)",
"Transfert vers service spécialisé (pneumologie)",
"Durée > 24h en UHCD"
],
"justification": "Le patient présente une pneumonie lobaire droite avec hypoxémie nécessitant une oxygénothérapie continue et une surveillance étroite. La surveillance scopée, les soins IV, les examens itératifs et le transfert vers le service de pneumologie justifient une requalification en hospitalisation.",
"confiance": "elevee",
"_elapsed_s": 1.7,
"_eval_count": 204
},
"match": true
},
{
"id": 7,
"titre": "OAP cardiogénique",
"type": "complexe",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_attendus": [
"VNI",
"diurétiques IV répétés",
"USC/USIC",
"surveillance > 24h"
],
"prediction": {
"decision": "REQUALIFICATION_HOSPITALISATION",
"criteres": [
"Séjour MCO requis : surveillance scopée prolongée",
"Soins continus IV (VNI, dérivés nitrés)",
"Oxygénothérapie/VNI",
"Examens itératifs (ECG, RX thorax)",
"Transfert vers service spécialisé (USIC)",
"Durée > 24h en UHCD"
],
"justification": "La patiente a nécessité une surveillance continue avec VNI et dérivés nitrés, une oxygénothérapie à haut débit, des examens itératifs et un transfert vers le service d'Unité de Soins Intensifs Coronariens (USIC) pour une gestion de la fibrillation auriculaire et une coronarographie programmée. La durée du séjour en unité hospitalière a dépassé 24 heures, justifiant une requalification en hospitalisation.",
"confiance": "elevee",
"_elapsed_s": 1.8,
"_eval_count": 227
},
"match": true
},
{
"id": 8,
"titre": "AVC ischémique thrombolysé",
"type": "complexe",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_attendus": [
"thrombolyse IV",
"filière AVC",
"transfert UNV",
"surveillance neuro rapprochée"
],
"prediction": {
"decision": "REQUALIFICATION_HOSPITALISATION",
"criteres": [
"Transfert vers UNV de référence pour thrombectomie",
"Surveillance neuro et TA toutes 15 min",
"Durée de la surveillance > 24h",
"Thrombectomie réalisée",
"Filière AVC complète avec UNV"
],
"justification": "Le patient a été transféré vers un UNV de référence pour thrombectomie, nécessitant une surveillance neuro et TA continue pendant plus de 24 heures. La présence d'une occlusion de l'artère sylvienne gauche justifie une hospitalisation complète avec suivi en filière AVC. Le patient a bénéficié d'une thrombectomie, ce qui implique une hospitalisation.",
"confiance": "elevee",
"_elapsed_s": 1.6,
"_eval_count": 192
},
"match": true
},
{
"id": 9,
"titre": "Sepsis sur pyélonéphrite obstructive",
"type": "complexe",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_attendus": [
"antibiothérapie IV",
"remplissage",
"drainage urinaire",
"surveillance > 48h"
],
"prediction": {
"decision": "REQUALIFICATION_HOSPITALISATION",
"criteres": [
"Séjour MCO requis : surveillance scopée prolongée (scope continu), soins continus IV (remplissage), oxygénothérapie/VNI (pas spécifié mais possible), examens itératifs (TDM), transfert vers service spécialisé (urologie), durée > 24h en UHCD (5h30)",
"Critères de gravité : sepsis sévère (qSOFA 3, CRP 412, PCT 28, lactates 3.9, créat 198), diabète mal équilibré",
"Diagnostic : sepsis sévère sur pyélonéphrite obstructive lithiasique nécessitant une prise en charge hospitalière prolongée"
],
"justification": "La patiente présente un sepsis sévère avec pyélonéphrite obstructive lithiasique nécessitant une surveillance scopée prolongée, des soins continus IV, une oxygénothérapie/VNI possible, des examens itératifs, un transfert vers un service spécialisé (urologie) et une durée de séjour supérieure à 24 heures en UHCD. La présence de diabète mal équilibré et de calculs rénaux complique la prise en charge et justifie une hospitalisation prolongée.",
"confiance": "elevee",
"_elapsed_s": 2.4,
"_eval_count": 308
},
"match": true
},
{
"id": 10,
"titre": "SCA NSTEMI",
"type": "complexe",
"verite_terrain": "REQUALIFICATION_HOSPITALISATION",
"criteres_attendus": [
"tropos cinétique +",
"anticoagulation",
"USIC",
"coronarographie"
],
"prediction": {
"decision": "REQUALIFICATION_HOSPITALISATION",
"criteres": [
"Durée du séjour en urgence > 24h",
"Surveillance scopée prolongée (coronarographie programmée)",
"Soins continus IV (HBPM enoxaparine, dérivés nitrés)",
"Transfert vers service spécialisé (USIC)",
"Critères de gravité (SCA NSTEMI, GRACE 142, TIMI 4)"
],
"justification": "Le patient a été admis pour une douleur thoracique, diagnostiqué comme un SCA NSTEMI. Il a été traité avec des médicaments IV, surveillé en urgence et transféré vers l'USIC pour une coronarographie programmée. La durée du séjour en urgence est supérieure à 24h, justifiant une requalification en hospitalisation.",
"confiance": "elevee",
"_elapsed_s": 1.7,
"_eval_count": 212
},
"match": true
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Test Apollo2-9B (Q4_K_S, médical multilingue avec FR explicite)."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from run_simulation_v2 import run_one_model, stats_for_results # noqa: E402
MODEL = "hf.co/mradermacher/Apollo2-9B-GGUF:Q4_K_S"
results = run_one_model(MODEL)
s = stats_for_results(results)
print(f"\n>>> {s['correct']}/{s['n']} ({100*s['accuracy']:.0f}%)")
print(f" S={s['by_type'].get('simple', (0,0))} C={s['by_type'].get('complexe', (0,0))} B={s['by_type'].get('borderline', (0,0))}")
print(f" latence={s['avg_latency_s']:.1f}s parse_err={s['parse_errors']} conf={s['confiance_distribution']}")
results_path = Path(__file__).parent / "resultats_v2.json"
all_data = json.loads(results_path.read_text(encoding="utf-8"))
all_data[MODEL] = [
{
"id": r["cas"]["id"],
"titre": r["cas"]["titre"],
"type": r["cas"]["type"],
"verite_terrain": r["cas"]["verite_terrain"],
"criteres_attendus": r["cas"]["criteres_cles"],
"prediction": r["out"],
"decision": r["decision"],
"match": r["match"],
}
for r in results
]
results_path.write_text(json.dumps(all_data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" → mergé dans {results_path.name}")

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Tester 3 modèles additionnels et merger dans resultats_v2.json :
- t2a-gemma3-27b-q4:latest (fine-tune T2A maison — clé du test)
- DeepSeek-R1:latest (reasoning, FR correct)
- gpt-oss:120b-cloud (top tier 2026)
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from run_simulation_v2 import run_one_model, stats_for_results # noqa: E402
EXTRA_MODELS = [
"t2a-gemma3-27b-q4:latest",
"DeepSeek-R1:latest",
"gpt-oss:120b-cloud",
]
results_path = Path(__file__).parent / "resultats_v2.json"
all_data = json.loads(results_path.read_text(encoding="utf-8"))
for model in EXTRA_MODELS:
print(f"\n>>> Test {model}")
results = run_one_model(model)
s = stats_for_results(results)
print(f"{s['correct']}/{s['n']} ({100*s['accuracy']:.0f}%) "
f"S={s['by_type'].get('simple', (0,0))} "
f"C={s['by_type'].get('complexe', (0,0))} "
f"B={s['by_type'].get('borderline', (0,0))} "
f"latence={s['avg_latency_s']:.1f}s parse_err={s['parse_errors']}")
all_data[model] = [
{
"id": r["cas"]["id"],
"titre": r["cas"]["titre"],
"type": r["cas"]["type"],
"verite_terrain": r["cas"]["verite_terrain"],
"criteres_attendus": r["cas"]["criteres_cles"],
"prediction": r["out"],
"decision": r["decision"],
"match": r["match"],
}
for r in results
]
results_path.write_text(json.dumps(all_data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" → mergé dans {results_path.name}")

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Test du MedGemma 27B (thiagomoraes Q4_K_S) et merge dans resultats_v2.json."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from run_simulation_v2 import run_one_model, stats_for_results # noqa: E402
MODEL = "thiagomoraes/medgemma-27b-it:Q4_K_S"
results = run_one_model(MODEL)
s = stats_for_results(results)
print(f"\n>>> {s['correct']}/{s['n']} ({100*s['accuracy']:.0f}%)")
print(f" S={s['by_type'].get('simple', (0,0))} C={s['by_type'].get('complexe', (0,0))} B={s['by_type'].get('borderline', (0,0))}")
print(f" latence={s['avg_latency_s']:.1f}s parse_err={s['parse_errors']} conf={s['confiance_distribution']}")
results_path = Path(__file__).parent / "resultats_v2.json"
all_data = json.loads(results_path.read_text(encoding="utf-8"))
all_data[MODEL] = [
{
"id": r["cas"]["id"],
"titre": r["cas"]["titre"],
"type": r["cas"]["type"],
"verite_terrain": r["cas"]["verite_terrain"],
"criteres_attendus": r["cas"]["criteres_cles"],
"prediction": r["out"],
"decision": r["decision"],
"match": r["match"],
}
for r in results
]
results_path.write_text(json.dumps(all_data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" → mergé dans {results_path.name}")

Some files were not shown because too many files have changed in this diff Show More