26 Commits

Author SHA1 Message Date
Dom
2cabc6cb7e fix(vwb): propage l'image d'ancre aux substeps compound à l'import (SP-1/U-B)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m43s
tests / Tests unitaires (sans GPU) (push) Failing after 1m48s
tests / Tests sécurité (critique) (push) Has been skipped
Les actions compound passaient par _convert_compound_substep qui ne lisait
jamais l'image d'ancre du parent -> substeps anchor_id NULL, "Ancre requise"
sans image dans le VWB. On pose desormais l'ancre du parent (meme fallback que
la branche action simple) sur le 1er substep cliquable uniquement.

Test: test_learned_workflow_bridge.py (TDD, RED->GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:11:32 +02:00
Dom
d686c3ac22 feat(deploy): installation 1-clic non-IT — raccourci Bureau + Demarrage auto
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m45s
tests / Tests unitaires (sans GPU) (push) Failing after 1m47s
tests / Tests sécurité (critique) (push) Has been skipped
Ajoute Installer-Lea.bat (CRLF/ASCII, chcp 65001) au paquet Lea complet :
- copie le paquet (python-embed inclus) vers %LOCALAPPDATA%\Lea (per-user,
  emplacement stable via robocopy, fallback xcopy) ;
- cree un raccourci Bureau + un raccourci dans le dossier Demarrage
  (lancement auto a l'ouverture de session) via WScript.Shell, cibles
  python-embed\pythonw.exe run_agent_v1.py (pas de console) ;
- icone optionnelle si un .ico est present dans le paquet (best-effort,
  sinon icone par defaut) ;
- lance Lea une premiere fois, message de fin clair.

Application SYSTRAY -> pas de service Windows (session 0 sans UI) :
dossier Demarrage + raccourci, per-user, sans admin/UAC.

LISEZMOI.txt du paquet remplacee par LISEZMOI-autonome.txt (le flux
install.bat + Python systeme n'existe plus dans ce paquet). build_package_full.sh
integre ces deux assets et les valide dans le ZIP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:04:20 +02:00
Dom
e212f4141c fix(dashboard): servir le ZIP Lea complet autoportant à l'enrôlement Fleet
L'endpoint /api/fleet/download/<machine_id> servait deploy/Lea_v1.0.0.zip
(sources seules, suppose Python système) → installation impossible chez un
utilisateur non-IT sans Python. Désormais il sert en priorité le ZIP complet
deploy/build/Lea_full_v1.0.1.zip (python-embed inclus), avec fallback sur
l'ancien ZIP léger s'il est seul. Résolution du template à la volée (le ZIP
complet peut être buildé après le démarrage du dashboard) + message d'erreur
explicite. L'injection de Lea/config.txt est inchangée.

Le title du bouton de téléchargement ne ment plus : 'installation autonome,
sans Python — dézipper puis double-cliquer Lea.bat'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:58:51 +02:00
Dom
33ddb51c3c feat(deploy): script build ZIP Lea complet autoportant (python-embed + source à jour)
Construit deploy/build/Lea_full_v<version>.zip servi par le dashboard Fleet :
runtime Python 3.12 embedded inclus, source Lea du working tree COURANT
(force --clean pour ne pas réutiliser un deploy/build/Lea/ périmé en cache),
Lea.bat embedded extrait de configure_embed.ps1, _pth patché, config.txt
placeholder CONFIGURE_ME. Pas de install.bat : plus aucun Python système requis.

Garde-fous intégrés : refus de builder si config.py embarqué diffère du repo,
si install.bat présent, ou si python-embed incomplet. Extraction de version
robuste (gère AGENT_VERSION littéral OU os.environ.get).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:58:51 +02:00
Dom
1d6efdb1b7 feat(dashboard): enrôlement lit l'adresse serveur depuis system_config.json
Câble l'éditeur adresses/ports du dashboard (services.streaming) vers le
RPA_SERVER_URL généré pour chaque agent Léa. Priorité config > env > défaut ;
host loopback/vide = non configuré (fallback env → pas de régression).
Permet de changer l'IP serveur (labo .45 → clinique .178) depuis l'UI sans
toucher l'env ni le code. +3 tests TDD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:07:27 +02:00
Dom
cf81ce4c7b feat(vwb): Basic auth LAN sur backend 5002 — creds dashboard, loopback exempté
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m52s
tests / Tests unitaires (sans GPU) (push) Failing after 1m52s
tests / Tests sécurité (critique) (push) Has been skipped
VWB backend exposé au LAN sans auth (point pré-clinique). Ajoute HTTP Basic auth
(mêmes identifiants que le dashboard: DASHBOARD_USER/DASHBOARD_PASSWORD) via
@app.before_request ; exempte loopback (intégration dashboard/agent_chat intacte),
/health et OPTIONS. Frontend = Create React App (pas Vite) → auth backend suffit
(navigateur LAN challengé au 1er XHR vers 5002) ; build statique = cible clinique.

Déployé + vérifié DGX: loopback 200, LAN no-creds 401, LAN+creds 200. 10 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:27:15 +02:00
Dom
ec1fb81054 fix(dashboard,worker): vérité produit P0 — dashboard+worker+VWB export
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m46s
tests / Tests unitaires (sans GPU) (push) Failing after 2m0s
tests / Tests sécurité (critique) (push) Has been skipped
War-room clôture DGX 2026-06-18 (recadrage Dom : graphe/apprentissage/mémoire/dashboard = surface produit P0).
Le dashboard et le statut worker affichaient des états faux ; corrige pour refléter la vérité du produit.

- dashboard FAISS: distingue index brut / metadata HMAC invalide / runtime / absent (plus de faux "inactif")
- dashboard process-mining: 503 explicite missing_dependency (plus de message trompeur)
- dashboard /api/workflows + system/status: lecture DB VWB v3 canonique (total réel = 24, plus de 0)
- worker /processing/status: véridique (lit _worker_health.json) + statut "idle/armé (lazy)" distinct de "dégradé (échec)"
- VWB export: N steps -> N actions/edges (dernière action n'est plus perdue)
- tests: dashboard routes, worker status truthfulness, export VWB

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:50:12 +02:00
Dom
6d5ef51c60 fix(server): api_upload load_env_file en setdefault (env systemd prime sur .env.local)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m47s
tests / Tests unitaires (sans GPU) (push) Failing after 1m49s
tests / Tests sécurité (critique) (push) Has been skipped
.env.local etait charge avec override systematique, ecrasant RPA_BIND_HOST
defini par le service systemd -> upload API bindait 0.0.0.0 malgre le drop-in.
setdefault aligne sur la convention dotenv (override=False) : l'env explicite
du service prime, .env.local ne fournit que des defauts. Complete d0c794d92.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:34:43 +02:00
Dom
d0c794d923 fix(systemd): bind upload api to loopback
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m47s
tests / Tests unitaires (sans GPU) (push) Failing after 1m56s
tests / Tests sécurité (critique) (push) Has been skipped
2026-06-17 20:01:27 +02:00
Dom
9605cc9d95 fix(vwb): resolve frontend services from runtime host
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m46s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
2026-06-17 17:53:57 +02:00
Dom
667575c3ad feat(installer): make Lea autonomous for POC 2026-06-17 17:53:46 +02:00
Dom
787dbfb0eb fix(installer): configure_embed saute pip si deps deja embarquees (install offline)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m45s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
Quand l'embed est livre complet (socketio + tkinter pre-embarques),
le bootstrap get-pip.py + pip install echouait hors-ligne. Ajout d'un
guard : si 'import socketio, tkinter' OK -> on saute pip (offline).
Mode online legacy conserve si embed nu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:16:04 +02:00
Dom
86b5ec18c6 chore(installer): prep Lea-Setup-v1.0.1 — socketio dans requirements + exclusion fichiers test du staging
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m43s
tests / Tests unitaires (sans GPU) (push) Failing after 1m47s
tests / Tests sécurité (critique) (push) Has been skipped
- requirements_agent.txt : ajout python-socketio/engineio/websocket-client/simple-websocket
  (FeedbackBus/bulles ; jeu valide en runtime sur la VM)
- build_installer.sh : exclusion test_lea_*, _test_paused_toast.py, tools/test_* du staging
Reste (phase build sur .11) : pre-bundler tkinter+zlib1 dans l'embed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:52:49 +02:00
Dom
b8b963059e fix(vwb): import lit anchor_image_base64 dans target.context_hints
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m44s
tests / Tests unitaires (sans GPU) (push) Failing after 1m47s
tests / Tests sécurité (critique) (push) Has been skipped
Le converter convert_learned_to_vwb_steps ne lisait l'ancre que dans
target/screenshot/action.parameters, jamais dans target.context_hints
où le recorder la range réellement -> anchor_id NULL a l'import.
Ajout de la source context_hints (fallback or, additif, non regressif).
Preuve: import reel 'Explorateur — session' -> 4/5 steps anchor_id non NULL
+ 4 PNG, x_pct/y_pct preserves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:42:03 +02:00
Dom
2b1743c206 fix(poc-agent): ouvrir le chat Lea DGX si Tk est indisponible
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m43s
tests / Tests unitaires (sans GPU) (push) Failing after 1m46s
tests / Tests sécurité (critique) (push) Has been skipped
2026-06-15 21:32:54 +02:00
Dom
48879fb849 fix(vwb): conservation des données de position des anchors Lea lors de l'import
- Supprime le 'pop' de '_anchor_bbox' qui jetait les coordonnées de position (x_pct, y_pct).
- Conserve ces données dans les paramètres du step pour que le frontend puisse les utiliser pour afficher la zone ciblée.
- Évite la création d'une bounding box factice (écran entier) qui rendait le crop de l'ancre inutile.
- Impact isolé à la route d'import, aucun impact sur le runtime d'exécution de Léa ni sur DETTE-015.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-15 18:13:29 +02:00
Dom
c12fd8e1c1 fix(dashboard): VWB import URL dynamique pour éviter hardcoded localhost
- Remplace l'URL hardcodée 'http://localhost:5002' par une construction dynamique basée sur l'origine actuelle.
- Permet les tests d'import depuis la VM ou le poste de test via l'IP du banc (ex: 192.168.1.45) sans échec CORS/routage.
- Respecte la règle POC DGX : pas de localhost comme preuve produit.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-15 18:13:22 +02:00
Dom
cbd3d40e39 fix(poc-installer): rendre l'installateur Lea embedded fonctionnel
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m47s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
Lea.iss (Inno Setup) n'avait jamais compile. Corrections :
- StringChange utilise en in-place (procedure modifiant la variable, retour
  Integer) au lieu d'imbrique/assigne (l.246, 407-408)
- GetTickCount (absent du Pascal Script Inno) -> GetDateTimeString pour le
  fallback machine_id
- skipifsilent retire du [Run] configure_embed : le runtime python-embed est
  desormais configure aussi en installation silencieuse (cas POC)

.gitignore : artefacts de build installateur non versionnes
(python-3.12-embed/, releases/*.exe, build/).

Valide sur VM Win11 : install per-user sans Python systeme, config DGX
(RPA_SERVER_URL=http://192.168.1.45:5005/api/v1), python-embed 3.12.8 + deps OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:14:08 +02:00
Dom
33c1e2e0d1 fix(grounding): confiance grounding dérivée sémantique (DETTE-019)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m48s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped
Le score/confidence figés à 0.85 dans _resolve_by_grounding rendaient le
garde-seuil (_RESOLUTION_MIN_SCORES["grounding"]=0.60) inopérant (0.85>0.60
toujours accepté). Le grounding VLM n'a pas de confiance modèle native (prompt
{"x","y"}, pas de logprob de localisation — confirmé QG Qwen 2026-06-15). On
dérive une confiance SÉMANTIQUE : le texte cible est-il à la position trouvée ?
(_validate_text_at_position). Confirmé→0.90, absent→0.45 (<seuil→rejet),
non vérifiable→0.70. Confiance contextuelle documentée, PAS une proba modèle.

TDD : 5 tests (score varie / présent accepté / absent rejeté / score==confidence
/ sans by_text neutre), RED→GREEN. Non-régression : 24 tests resolve_engine +
câblage qwen3vl + legacy bbox verts. E2E panel inchangé (15/15). Pré-check OCR
non impacté. DETTE-018 (legacy non gardé) reste séparée.

refs DETTE-019

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:17:46 +02:00
Dom
c0e4c382be docs(dette): acte DETTE-018/019 (garde-seuil grounding) + inscrit DETTE-015..017
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m45s
tests / Tests unitaires (sans GPU) (push) Failing after 1m51s
tests / Tests sécurité (critique) (push) Has been skipped
DETTE-018: method="grounding_vlm" legacy non gardé par _RESOLUTION_MIN_SCORES
(seul prefixe memory_ traité ; reste = match exact) → Check-1 seuil jamais appliqué
au chemin legacy. Mode qwen3vl ("grounding", seuil 0.60) correctement gardé.
DETTE-019: confiance figée 0.85 en dur dans _resolve_by_grounding (return) pour les
deux modes → garde-seuil (0.60) reçoit toujours 0.85, filtre inopérant.
Découvertes au câblage qwen3vl (5c5ce747b) + validation E2E 2026-06-13 (15/15, 0 dangereux).
Inscrit aussi DETTE-015/016/017 restées non commitées.

refs DETTE-018 DETTE-019

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:33:58 +02:00
Dom
5c5ce747b0 feat(grounding): câblage Qwen3-VL-4B/vLLM (RPA_GROUNDING_ENGINE, défaut off)
Active via RPA_GROUNDING_ENGINE=qwen3vl_vllm (défaut OFF = legacy Qwen2.5-VL
inchangé, byte-identique). Mode qwen3vl : port 8001/Qwen3-VL-4B, prompt point
0-1, think=false, parse /1000 (dissout DETTE-006), method "grounding" gardée
(seuil 0.60), pas de fallback Ollama (abstention si vLLM down). Grounder validé
au bench Easily réel (0.933, ~1s/cas). TDD : 4 tests (normalisation 0-1000,
think=false, prompt fractions 0-1, gating score bas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:39:29 +02:00
Dom
b20d17882e feat(wp-c): méthode verify_token côté registre (patch 3, inerte)
Ajoute AgentRegistry.verify_token(token) -> machine_id|None : compare le
SHA-256 du token aux token_hash des agents 'active' via hmac.compare_digest
(temps constant). Agent désinstallé/révoqué refusé ; rotation à l'enroll
invalide l'ancien token.

Inerte au runtime : méthode non branchée sur l'auth HTTP (le branchement
derrière flag RPA_FLEET_PER_AGENT_TOKEN sera le Patch 4). api_stream.py
intouché. TDD : 6 tests + non-régression WP-C/WP-B (53 verts). Voir
PLAN-WPC-TDD-EXECUTABLE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:21:04 +02:00
Dom
9fb2c7bfee feat(wp-c): génération token par poste à l'enroll (patch 2, inerte runtime)
Génère un token unique (secrets.token_hex(32)) à chaque (ré)enrôlement,
persiste uniquement son empreinte SHA-256 dans token_hash, renseigne
token_issued_at, retourne le clair une seule fois dans le résultat de
enroll. Le clair n'est jamais journalisé ni persisté.

Inerte au runtime : api_stream.py intouché, l'endpoint /agents/enroll ne
propage ni le clair ni le hash (api_token global inchangé). Auth runtime
non modifiée. Aucun branchement _verify_token. TDD : 8 tests + non-régression
WP-B/WP-C (47 verts). Voir PLAN-WPC-TDD-EXECUTABLE / DETTE-015.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:36:44 +02:00
Dom
f7f6926410 feat(wp-c): migration colonnes token par poste (patch 1, inerte)
Ajoute token_hash + token_issued_at à enrolled_agents via ALTER TABLE
idempotent (_init_db). Colonnes inertes : aucun branchement auth, runtime
inchangé (tests WP-B verts). Base du token par poste (WP-C, cf DETTE-015).

TDD: tests/unit/test_wpc_migration.py (présence, idempotence, préservation
des données d'une base existante). 3 tests + non-régression WP-B = 9 passed.

refs DETTE-015

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:04:18 +02:00
Dom
09f65cecbe fix(security): bind 127.0.0.1 par défaut via RPA_BIND_HOST (plus de host=0.0.0.0 en dur)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m44s
tests / Tests unitaires (sans GPU) (push) Failing after 1m48s
tests / Tests sécurité (critique) (push) Has been skipped
Les 4 entrypoints HTTP (api_stream 5005, api_upload 8000, VWB backend 5002,
dashboard 5001) bindaient host=0.0.0.0 en dur -> exposés sur tout le réseau.
Désormais host=os.environ.get('RPA_BIND_HOST','127.0.0.1') : local-only par
défaut, configurable. Découvert à la mise en service DGX local-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:49:58 +02:00
Dom
0ee54157e5 fix(p1g): garde-fou VRAM adapté à la mémoire unifiée (DGX GB10)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m44s
tests / Tests unitaires (sans GPU) (push) Failing after 1m49s
tests / Tests sécurité (critique) (push) Has been skipped
resolve_device('auto') renvoyait 'cpu' sur le GB10 : le plafond max_total_gb=6
(pensé pour la RTX 12 Go dédiés) voyait used≈99 Go car la mémoire UNIFIÉE compte
la RAM système. Au-dessus de DEFAULT_LARGE_VRAM_GB=24 (grosse carte / mémoire
unifiée), le plafond n'est plus appliqué ; seul free >= min_free_gb décide.
RTX (<=24 Go) inchangée.

Détecté au bench GB10 2026-06-08 (auto->cpu, OCR 10x plus lent). +2 tests (17/17).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:43:12 +02:00
51 changed files with 2768 additions and 217 deletions

8
.gitignore vendored
View File

@@ -126,3 +126,11 @@ tools/codex_windows_correction_rapport.py
docs/clients/
.qw-baseline.log
docs/coordination/.loop_state/
# Runtime Python embedded pour l'installateur Inno Setup (local, ~11M, non versionné)
deploy/installer/python-3.12-embed/
deploy/installer/python-3.12.8-embed-amd64.zip
# Artefacts de build installateur (EXE compilés + staging) — non versionnés
deploy/releases/*.exe
deploy/build/

View File

@@ -27,7 +27,7 @@ if platform.system() == "Windows":
except Exception:
pass
AGENT_VERSION = "1.0.0"
AGENT_VERSION = "1.0.1"
# Identifiant unique de la machine (utilisé pour le multi-machine)
# Configurable via variable d'environnement, sinon auto-généré depuis hostname + OS

View File

@@ -5,6 +5,9 @@ Fenetre de chat Lea integree au systray — version tkinter native.
Remplace l'approche Edge browser par une vraie fenetre tkinter integree.
Design professionnel, theme clair, ancree en bas a droite de l'ecran.
Tourne dans son propre thread daemon pour ne pas bloquer pystray.
Le runtime Python embedded Windows ne contient pas toujours Tcl/Tk. Dans ce
cas, le menu "Discuter avec Lea" ouvre le chat DGX dans le navigateur.
"""
import logging
@@ -13,6 +16,8 @@ import math
import threading
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__)
@@ -219,7 +224,10 @@ class ChatWindow:
def toggle(self) -> None:
"""Afficher/masquer la fenetre de chat."""
if self._destroyed or self._root is None:
if self._destroyed:
return
if self._root is None:
self._open_browser_fallback()
return
if self._visible:
self.hide()
@@ -228,7 +236,10 @@ class ChatWindow:
def show(self) -> None:
"""Afficher la fenetre."""
if self._destroyed or self._root is None:
if self._destroyed:
return
if self._root is None:
self._open_browser_fallback()
return
self._root.after(0, self._do_show)
@@ -257,6 +268,79 @@ class ChatWindow:
"""Mettre a jour le client serveur (appele si cree apres la fenetre)."""
self._server_client = server_client
def _chat_url(self) -> str:
"""Retourne l'URL web du chat, derivee de la config serveur."""
configured_url = self._chat_url_from_server_url(self._configured_server_url())
if self._server_client is not None:
chat_base = getattr(self._server_client, "_chat_base", None)
if chat_base:
chat_base = str(chat_base).rstrip("/")
if not self._is_local_url(chat_base):
return chat_base
if configured_url:
return configured_url
if configured_url:
return configured_url
host = (self._server_host or "localhost").strip()
if host.startswith(("http://", "https://")):
parsed = urlparse(host)
scheme = parsed.scheme or "http"
hostname = parsed.hostname or "localhost"
return f"{scheme}://{hostname}:{self._chat_port}"
return f"http://{host}:{self._chat_port}"
@staticmethod
def _is_local_url(url: str) -> bool:
try:
host = urlparse(url).hostname
except Exception:
return False
return host in {"localhost", "127.0.0.1", "::1"}
def _chat_url_from_server_url(self, server_url: Optional[str]) -> Optional[str]:
if not server_url:
return None
try:
parsed = urlparse(server_url.strip())
except Exception:
return None
if not parsed.hostname or parsed.hostname in {"localhost", "127.0.0.1", "::1"}:
return None
scheme = parsed.scheme or "http"
return f"{scheme}://{parsed.hostname}:{self._chat_port}"
def _configured_server_url(self) -> Optional[str]:
env_url = os.environ.get("RPA_SERVER_URL", "").strip()
if env_url:
return env_url
try:
# Installed layout: <app>/agent_v1/ui/chat_window.py.
for parent in Path(__file__).resolve().parents:
cfg = parent / "config.txt"
if cfg.exists():
for line in cfg.read_text(encoding="utf-8", errors="ignore").splitlines():
if line.startswith("RPA_SERVER_URL="):
return line.split("=", 1)[1].strip()
except Exception:
logger.debug("Lecture config.txt pour chat_url impossible", exc_info=True)
return None
def _open_browser_fallback(self) -> None:
"""Fallback POC quand tkinter est absent du Python embedded."""
url = self._chat_url()
try:
import webbrowser
if webbrowser.open(url, new=1):
logger.info("ChatWindow indisponible, chat ouvert dans le navigateur: %s", url)
else:
logger.warning("ChatWindow indisponible, ouverture navigateur refusee: %s", url)
except Exception as exc:
logger.error("Impossible d'ouvrir le chat dans le navigateur (%s): %s", url, exc)
def _on_shared_state_change(self, state) -> None:
"""Callback appele quand l'etat partage change (depuis le systray ou ailleurs).

View File

@@ -28,13 +28,16 @@ Schema de la table `enrolled_agents` :
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@@ -48,6 +51,19 @@ def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _new_token() -> Tuple[str, str]:
"""WP-C : genere un token poste (clair) et son empreinte SHA-256.
Le clair est retourne UNE seule fois a l'appelant (resultat de enroll) ; seul
le hash est persiste dans `token_hash`. Le clair n'est jamais journalise ni
stocke. L'auth runtime reste inchangee (aucun branchement ici sur la
verification de token cote api_stream).
"""
clear = secrets.token_hex(32)
token_hash = hashlib.sha256(clear.encode("utf-8")).hexdigest()
return clear, token_hash
def _fleet_enroll_locked() -> bool:
"""WP-B : parc verrouille -> aucun NOUVEAU machine_id ne peut s'enroler.
@@ -111,6 +127,20 @@ class AgentRegistry:
"CREATE INDEX IF NOT EXISTS idx_enrolled_agents_machine "
"ON enrolled_agents(machine_id)"
)
# WP-C Patch 1 : colonnes « token par poste », migration additive
# idempotente. Inertes tant que l'auth par poste n'est pas branchée
# (patchs WP-C ultérieurs). Voir DETTE-015.
existing_cols = {
row[1]
for row in conn.execute(
"PRAGMA table_info(enrolled_agents)"
).fetchall()
}
for col in ("token_hash", "token_issued_at"):
if col not in existing_cols:
conn.execute(
f"ALTER TABLE enrolled_agents ADD COLUMN {col} TEXT"
)
# ------------------------------------------------------------------
# Lecture
@@ -143,6 +173,31 @@ class AgentRegistry:
).fetchone()
return int(row["n"]) if row else 0
def verify_token(self, token: str | None) -> Optional[str]:
"""WP-C : verifie un token poste, retourne le machine_id actif ou None.
Compare le SHA-256 du token presente aux `token_hash` des agents
`status='active'` via `hmac.compare_digest` (comparaison a temps
constant, evite les fuites par timing). Un agent desinstalle/revoque
n'est pas 'active' donc refuse ; la rotation a l'enrolement invalide
l'ancien token.
INERTE : non branchee sur l'auth runtime (le branchement derriere flag
sera le Patch 4). Aucun appelant runtime a ce stade.
"""
if not token:
return None
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
with _DB_LOCK, self._connect() as conn:
rows = conn.execute(
"SELECT machine_id, token_hash FROM enrolled_agents "
"WHERE status = 'active' AND token_hash IS NOT NULL"
).fetchall()
for row in rows:
if hmac.compare_digest(str(row["token_hash"]), token_hash):
return str(row["machine_id"])
return None
# ------------------------------------------------------------------
# Ecriture
# ------------------------------------------------------------------
@@ -192,6 +247,8 @@ class AgentRegistry:
if not allow_reactivate:
raise AgentAlreadyEnrolledError(dict(existing))
# WP-C : rotation du token a chaque (re)enrolement.
token, token_hash = _new_token()
conn.execute(
"""
UPDATE enrolled_agents
@@ -205,13 +262,17 @@ class AgentRegistry:
enrolled_at = ?,
last_seen_at = ?,
uninstalled_at = NULL,
uninstall_reason = NULL
uninstall_reason = NULL,
token_hash = ?,
token_issued_at = ?
WHERE machine_id = ?
""",
(
user_name, user_email, user_id,
hostname, os_info, version,
now, now, machine_id,
now, now,
token_hash, now,
machine_id,
),
)
conn.commit()
@@ -219,23 +280,32 @@ class AgentRegistry:
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
return {"created": False, "reactivated": True, "agent": dict(row)}
return {
"created": False,
"reactivated": True,
"agent": dict(row),
"token": token,
}
# Nouvelle inscription — WP-B : refusee si le parc est verrouille
if _fleet_enroll_locked():
raise FleetEnrollLockedError(machine_id)
# WP-C : token poste genere a la creation.
token, token_hash = _new_token()
conn.execute(
"""
INSERT INTO enrolled_agents (
machine_id, user_name, user_email, user_id,
hostname, os_info, version,
status, enrolled_at, last_seen_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
status, enrolled_at, last_seen_at,
token_hash, token_issued_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
""",
(
machine_id, user_name, user_email, user_id,
hostname, os_info, version,
now, now,
token_hash, now,
),
)
conn.commit()
@@ -243,7 +313,12 @@ class AgentRegistry:
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
return {"created": True, "reactivated": False, "agent": dict(row)}
return {
"created": True,
"reactivated": False,
"agent": dict(row),
"token": token,
}
def uninstall(
self,

View File

@@ -555,6 +555,7 @@ LIVE_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
_DATA_DIR = ROOT_DIR / "data" / "training"
WORKER_QUEUE_FILE = _DATA_DIR / "_worker_queue.txt"
REPLAY_LOCK_FILE = _DATA_DIR / "_replay_active.lock"
WORKER_HEALTH_FILE = _DATA_DIR / "_worker_health.json"
# Instance globale partagée (le StreamProcessor reste dans le serveur HTTP
# pour le CLIP, l'indexation FAISS, la gestion des sessions, le replay —
@@ -807,7 +808,7 @@ def _memory_window_title_for_action(action_meta: Dict[str, Any]) -> str:
def _get_worker_queue_status() -> Dict[str, Any]:
"""Retourne l'état de la queue du worker VLM (pour le monitoring)."""
"""Retourne l'état réel de la queue et du worker VLM (pour le monitoring)."""
queue = []
if WORKER_QUEUE_FILE.exists():
try:
@@ -819,16 +820,108 @@ def _get_worker_queue_status() -> Dict[str, Any]:
except Exception:
pass
health = None
health_error = None
health_age_seconds = None
if WORKER_HEALTH_FILE.exists():
try:
health = json.loads(WORKER_HEALTH_FILE.read_text(encoding="utf-8"))
health_age_seconds = max(0.0, time.time() - WORKER_HEALTH_FILE.stat().st_mtime)
except Exception as exc:
health_error = str(exc)
health_stale = health_age_seconds is None or health_age_seconds > 180
components = (health or {}).get("components") or {}
components_ready = bool(components) and all(bool(v) for v in components.values())
health_status = (health or {}).get("status")
running = bool(health) and not health_stale and health_status != "stopped"
# Distinction VEILLE (armé, lazy) vs DÉGRADÉ (vrai échec).
#
# Les composants lourds (ScreenAnalyzer/CLIP/FAISS/StateEmbedding) sont
# chargés en lazy par run_worker : le processor n'est instancié qu'au
# premier _process_session (cf. run_worker._get_processor / _process_session).
# Un worker neuf qui n'a jamais reçu de session écrit donc status="healthy"
# avec tous les composants à false — c'est l'état NORMAL « en veille », pas
# une panne. L'étiqueter "degraded" fait lire une panne là où il n'y en a pas.
#
# Signal retenu pour « init jamais tentée » : TOUS les composants à false ET
# sessions_processed == 0 ET sessions_failed == 0. Justification : run_worker
# n'appelle _get_processor() (donc l'init lazy) que dans _process_session, qui
# incrémente toujours exactement un compteur (processed / failed / skipped).
# Tant que processed == 0 ET failed == 0, aucune session n'a déclenché une
# init suivie d'un traitement — le worker est armé en attente. Un simple skip
# (dossier/shots absents) passe quand même par _get_processor() : les
# composants se chargent, donc tous-à-false devient faux et on n'entre pas ici.
# run_worker._health_components() écrit toujours les 4 clés (jamais un dict
# vide), d'où le test sur les VALEURS et non sur la présence des clés.
# Si run_worker a lui-même forcé status="degraded" (VLM + ScreenAnalyzer
# absent, cf. run_worker._write_health), c'est un VRAI échec : on le conserve.
stats = (health or {}).get("stats") or {}
init_attempted = bool(stats.get("sessions_processed", 0)) or bool(
stats.get("sessions_failed", 0)
)
components_all_false = bool(components) and not any(
bool(v) for v in components.values()
)
armed = (
running
and not components_ready
and health_status == "healthy"
and components_all_false # aucun composant lourd encore chargé
and not init_attempted
)
status = health_status or "unknown"
if not running:
status = "stale" if health else "unknown"
elif armed:
# En veille : worker sain, composants chargés à la 1re session.
status = "idle"
elif not components_ready:
status = "degraded"
return {
"running": True, # On ne sait pas si le worker process tourne, mais la queue existe
"running": running,
"status": status,
"armed": armed,
"queue_length": len(queue),
"queue": queue,
"replay_lock_active": REPLAY_LOCK_FILE.exists(),
"queue_file": str(WORKER_QUEUE_FILE),
"note": "Le worker VLM tourne dans un process séparé (run_worker.py)",
"health_file": str(WORKER_HEALTH_FILE),
"health_error": health_error,
"health_age_seconds": health_age_seconds,
"health_stale": health_stale,
"worker_pid": (health or {}).get("pid"),
"last_cycle": (health or {}).get("last_cycle"),
"current_session": (health or {}).get("current_session"),
"components": components,
"components_ready": components_ready,
"processing_ready": running and not REPLAY_LOCK_FILE.exists() and components_ready,
"status_hint": _worker_status_hint(status, armed),
"stats": stats,
"note": "Le worker VLM tourne dans un process séparé (agent_v0.server_v1.run_worker).",
}
def _worker_status_hint(status: str, armed: bool) -> str:
"""Message humain pour le statut worker (consommé par le dashboard)."""
if armed or status == "idle":
return "En veille — composants chargés à la 1re session."
if status == "degraded":
return "Worker apprentissage dégradé — init des composants en échec."
if status == "stale":
return "Health file périmé (> 180s) — worker peut-être arrêté."
if status == "stopped":
return "Worker arrêté."
if status == "busy":
return "Traitement d'une session en cours."
if status == "healthy":
return "Worker prêt — composants chargés."
return "État worker inconnu."
# =========================================================================
# Compteur d'analyses en cours par session (pour attendre avant finalize)
# =========================================================================
@@ -1614,13 +1707,14 @@ async def startup():
threading.Thread(target=_smoke_model_health, name="model-health-smoke", daemon=True).start()
# Afficher le token API au démarrage pour que l'utilisateur puisse configurer l'agent
# Ne jamais imprimer le token complet dans journald/stdout.
_token_source = "env RPA_API_TOKEN" if os.environ.get("RPA_API_TOKEN") else "auto-généré"
logger.info(f"API Token ({_token_source}): {API_TOKEN}")
_token_hint = f"{API_TOKEN[:8]}{API_TOKEN[-4:]}" if API_TOKEN else "<absent>"
logger.info("API Token (%s): %s — auth Bearer obligatoire", _token_source, _token_hint)
print(f"\n{'='*60}")
print(f" API Token ({_token_source}):")
print(f" {API_TOKEN}")
print(f" Configurer l'agent : export RPA_API_TOKEN={API_TOKEN}")
print(f" {_token_hint} (masqué)")
print(" Configurer l'agent via .env.local ou l'enrollment; ne pas copier depuis les logs.")
print(f"{'='*60}\n")
worker.start(blocking=False)
@@ -7649,4 +7743,5 @@ if __name__ == "__main__":
level=logging.INFO,
format="%(asctime)s [API-STREAM] %(message)s",
)
uvicorn.run(app, host="0.0.0.0", port=5005)
import os as _os
uvicorn.run(app, host=_os.environ.get("RPA_BIND_HOST", "127.0.0.1"), port=5005)

View File

@@ -870,6 +870,50 @@ def _vlm_quick_find(
# Résolution par VLM Grounding Direct (configurable via RPA_VLM_MODEL)
# ---------------------------------------------------------------------------
# DETTE-019 — confiance grounding DÉRIVÉE (et NON une confiance modèle native).
# Le grounding VLM ne fournit aucune confiance exploitable : le prompt demande
# {"x","y"} et aucun logprob de localisation n'est extrait (confirmé QG Qwen
# 2026-06-15). Le seul signal de confiance RÉEL est sémantique : le texte cible
# est-il bien à la position trouvée ? On le dérive via la même vérif OCR que le
# pré-check aval (`_validate_text_at_position`). Approche validée par Dom.
# ⚠ Confiance CONTEXTUELLE, pas une probabilité du modèle : ne pas l'afficher
# comme « confiance du VLM » côté dashboard.
_GROUNDING_CONF_TEXT_CONFIRMED = 0.90 # texte cible retrouvé à la position
_GROUNDING_CONF_UNVERIFIABLE = 0.70 # pas de texte vérifiable → neutre (> seuil 0.60)
_GROUNDING_CONF_TEXT_ABSENT = 0.45 # texte cible absent → < seuil 0.60 → rejeté
def _grounding_semantic_confidence(
screenshot_path: str,
x_pct: float,
y_pct: float,
by_text: str,
screen_width: int,
screen_height: int,
) -> float:
"""Confiance DÉRIVÉE (sémantique) d'un grounding — DETTE-019.
Mesure contextuelle, PAS une confiance du modèle : le texte cible `by_text`
est-il présent à la position (x_pct, y_pct) ? Réutilise la garde OCR du
pré-check aval (`_validate_text_at_position`).
- texte confirmé → CONFIRMED (accepté)
- texte absent → ABSENT (< seuil → rejeté par
`_validate_resolution_quality`)
- pas de by_text / OCR KO → UNVERIFIABLE (neutre, > seuil : pas de faux rejet)
"""
by_text = (by_text or "").strip()
if not by_text:
return _GROUNDING_CONF_UNVERIFIABLE
try:
is_valid, _observed, _ms = _validate_text_at_position(
screenshot_path, x_pct, y_pct, by_text, screen_width, screen_height,
)
except Exception as e: # OCR indisponible : dégradation gracieuse, pas de pénalité
logger.debug("Grounding confidence : vérif sémantique indisponible (%s) → neutre", e)
return _GROUNDING_CONF_UNVERIFIABLE
return _GROUNDING_CONF_TEXT_CONFIRMED if is_valid else _GROUNDING_CONF_TEXT_ABSENT
def _resolve_by_grounding(
screenshot_path: str,
@@ -953,26 +997,58 @@ def _resolve_by_grounding(
import requests as _requests
content = ""
# Port vLLM configurable via env
_vllm_port = os.environ.get("VLLM_PORT", "8100")
_vllm_model = os.environ.get("VLLM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct-AWQ")
# Grounder POC validé (bench Easily réel 12→13/06, 0.933) : Qwen3-VL-4B/vLLM.
# Activé via RPA_GROUNDING_ENGINE=qwen3vl_vllm (défaut OFF = legacy Qwen2.5-VL
# inchangé, byte-identique). Le 0.933 est une propriété de
# (modèle+moteur+prompt+parser+think) → ce mode reproduit le tuple validé :
# prompt point 0-1, think=false, parse /1000 (dissout DETTE-006), method gardée.
# Réf design : inbox_codex/2026-06-13_0210_..._DESIGN-CABLAGE-RESOLVE-ENGINE-QWEN3VL.md
_grounding_engine = os.environ.get("RPA_GROUNDING_ENGINE", "").strip().lower()
_use_qwen3vl = _grounding_engine == "qwen3vl_vllm"
if _use_qwen3vl:
_vllm_port = os.environ.get("VLLM_PORT", "8001")
_vllm_model = os.environ.get("VLLM_MODEL", "Qwen/Qwen3-VL-4B-Instruct")
_sys_prompt = (
"Tu localises une cible sur une capture d'écran d'interface. "
"Si la cible n'est pas clairement visible, réponds par une abstention."
)
_user_text = (
f"Cible : « {description} ». Donne le point de clic en FRACTIONS de "
"l'image : x et y entre 0.0 et 1.0 (0,0 = coin haut-gauche, "
'1,1 = coin bas-droite). Réponds UNIQUEMENT par un JSON '
'{"x":0.xx,"y":0.xx} ou {"abstain":true} si la cible n\'est pas '
"clairement visible."
)
else:
_vllm_port = os.environ.get("VLLM_PORT", "8100")
_vllm_model = os.environ.get("VLLM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct-AWQ")
_sys_prompt = "You locate UI elements on screenshots. Return coordinates."
_user_text = prompt
# Essai 1 : vLLM (API OpenAI-compatible, GPU)
try:
_vllm_payload = {
"model": _vllm_model,
"messages": [
{"role": "system", "content": _sys_prompt},
{"role": "user", "content": [
{"type": "text", "text": _user_text},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{shot_b64}"}},
]},
],
"temperature": 0.1,
"max_tokens": 80,
}
if _use_qwen3vl:
# think=false obligatoire (Qwen3-VL/vLLM) : sinon raisonnement →
# grounding inutilisable (observé au bench).
_vllm_payload["chat_template_kwargs"] = {"enable_thinking": False}
_vllm_payload["temperature"] = 0.0
_vllm_payload["max_tokens"] = 256
vllm_resp = _requests.post(
f"http://localhost:{_vllm_port}/v1/chat/completions",
json={
"model": _vllm_model,
"messages": [
{"role": "system", "content": "You locate UI elements on screenshots. Return coordinates."},
{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{shot_b64}"}},
]},
],
"temperature": 0.1,
"max_tokens": 80,
},
json=_vllm_payload,
timeout=30,
)
if vllm_resp.ok:
@@ -982,8 +1058,11 @@ def _resolve_by_grounding(
except Exception as e:
logger.debug("vLLM non disponible (%s), fallback Ollama", e)
# Essai 2 : Ollama (qwen2.5vl:7b pour le grounding — format bbox_2d natif)
if not content:
# Essai 2 : Ollama (qwen2.5vl:7b pour le grounding — format bbox_2d natif).
# En mode qwen3vl_vllm, PAS de fallback Ollama (modèle non-viable/dangereux
# prouvé au bench) : si vLLM échoue, on abstient (None) et la cascade externe
# (OCR/template/SoM) prend le relais.
if not content and not _use_qwen3vl:
try:
resp = _requests.post("http://localhost:11434/api/chat", json={
"model": _grounding_model,
@@ -1003,12 +1082,19 @@ def _resolve_by_grounding(
elapsed = time.time() - t0
# 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 _use_qwen3vl:
# Qwen3-VL : 0-1 (consigne respectée) OU 0-1000 natif. divisor=1000 gère
# les DEUX (xy_json ≤1 pris tel quel ; bbox_2d / valeurs >1 → ÷1000).
# Résolution-indépendant → dissout le bug d'échelle DETTE-006.
x_pct, y_pct = parse_bbox_to_norm(content, 1000, 1000)
else:
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
# Fallback multi-image : screenshot + crop → grounding sans description.
# Skippé en mode qwen3vl_vllm (le fallback s'appuie sur Ollama qwen2.5vl).
anchor_b64 = target_spec.get("anchor_image_base64", "")
if anchor_b64:
if anchor_b64 and not _use_qwen3vl:
try:
prompt_mi = (
"Image 1 is a screenshot. Image 2 shows a UI element.\n"
@@ -1071,18 +1157,28 @@ def _resolve_by_grounding(
_grounding_model, description[:50], x_pct, y_pct, elapsed,
)
# DETTE-019 : confiance DÉRIVÉE sémantique (le texte cible est-il à la
# position ?), plus de score figé. Cohérence score == confidence.
_conf = _grounding_semantic_confidence(
screenshot_path, round(x_pct, 6), round(y_pct, 6),
by_text, screen_width, screen_height,
)
return {
"resolved": True,
"method": "grounding_vlm",
# method gardée par _RESOLUTION_MIN_SCORES : en mode qwen3vl, "grounding"
# (clé exacte, seuil 0.60) → Check-1 du validateur s'applique. Le legacy
# garde "grounding_vlm" (non gardé aujourd'hui — bug latent, DETTE séparée).
"method": "grounding" if _use_qwen3vl else "grounding_vlm",
"x_pct": round(x_pct, 6),
"y_pct": round(y_pct, 6),
"matched_element": {
"label": description[:60],
"type": "grounding",
"role": "grounding_vlm",
"confidence": 0.85,
"confidence": _conf,
},
"score": 0.85,
"score": _conf,
}

View File

@@ -36,6 +36,12 @@ _VALID = {"cpu", "cuda", "auto"}
# Garde-fous par défaut (Go).
DEFAULT_MIN_FREE_GB = 2.0 # VRAM libre minimale pour autoriser cuda
DEFAULT_MAX_TOTAL_GB = 6.0 # plafond d'usage VRAM total après bascule
# Au-delà de ce total VRAM, on considère une grosse carte (data-center) ou une
# mémoire UNIFIÉE (DGX GB10 : ~121 Go partagés CPU+GPU). Dans ce cas `used`
# (= total - free) inclut la RAM système → le plafond fixe `max_total_gb` (pensé
# pour la RTX 12 Go dédiés) devient un faux positif qui force CPU à tort. On ne
# l'applique donc QUE sous ce seuil ; au-dessus, seul `free ≥ min_free_gb` décide.
DEFAULT_LARGE_VRAM_GB = 24.0
def _env_override() -> Optional[str]:
@@ -135,13 +141,22 @@ def resolve_device(
)
return "cpu"
if used_gb > max_total_gb:
# Plafond d'usage : seulement sur carte dédiée "petite" (type RTX). Sur grosse
# mémoire / mémoire unifiée (GB10), `used` inclut la RAM système → non pertinent.
if total_gb <= DEFAULT_LARGE_VRAM_GB and used_gb > max_total_gb:
logger.info(
"auto: usage VRAM %.1f Go > plafond %.1f Go — CPU",
used_gb, max_total_gb,
"auto: usage VRAM %.1f Go > plafond %.1f Go (carte %.1f Go) — CPU",
used_gb, max_total_gb, total_gb,
)
return "cpu"
if total_gb > DEFAULT_LARGE_VRAM_GB:
logger.info(
"auto: grosse mémoire/unifiée %.1f Go, libre %.1f Go — CUDA (plafond ignoré)",
total_gb, free_gb,
)
return "cuda"
logger.info(
"auto: VRAM libre %.1f Go (usage %.1f/%.1f Go) — CUDA",
free_gb, used_gb, total_gb,

249
deploy/build_package_full.sh Executable file
View File

@@ -0,0 +1,249 @@
#!/bin/bash
# ============================================================
# build_package_full.sh — Construit le ZIP Lea COMPLET autoportant
# ------------------------------------------------------------
#
# Produit : deploy/build/Lea_full_v<version>.zip
#
# Ce ZIP est destine a etre servi par le dashboard Fleet
# (web_dashboard/app.py -> /api/fleet/download/<machine_id>).
# Contrairement a deploy/Lea_v1.0.0.zip (sources seules, suppose
# Python systeme), ce ZIP est 100% autonome :
#
# - Code source Lea A JOUR (working tree courant du repo,
# via build_package.sh : agent_v0/agent_v1, lea_ui, run_agent_v1)
# - Runtime Python 3.12 embedded complet (python-embed/)
# avec toutes les dependances pre-installees (mss, pynput,
# pystray, plyer, requests, PIL, pywin32, socketio...)
# - Lea.bat pointant directement sur python-embed\pythonw.exe
# (version embedded de configure_embed.ps1 : ni venv, ni pip,
# ni reseau, ni Python systeme)
# - python312._pth patche (import site active)
# - Lea/config.txt placeholder (CONFIGURE_ME) que le dashboard
# remplace a la volee par la config de l'agent
# - PAS de install.bat (plus aucune etape d'installation Python)
#
# Experience utilisateur cible (non-IT) :
# dezipper -> double-clic Lea.bat -> Lea demarre dans le systray.
# Aucune installation de Python, aucun UAC.
#
# Usage :
# ./deploy/build_package_full.sh # Build complet
# ./deploy/build_package_full.sh --clean # Nettoyer avant
#
# Pre-requis :
# - bash, rsync, zip
# - deploy/installer/python-3.12-embed/ (runtime embedded, ~80 Mo,
# non versionne — restaure depuis lea_python_embed_working.tgz si absent)
# ============================================================
set -euo pipefail
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # deploy/
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" # racine repo
INSTALLER_DIR="$SCRIPT_DIR/installer"
STAGING_DIR="$SCRIPT_DIR/build/installer_staging"
BUILD_DIR="$SCRIPT_DIR/build"
ASSEMBLY_DIR="$BUILD_DIR/Lea_full_assembly" # arborescence Lea/ temporaire
# Version lue depuis la source courante.
# NB : la ligne peut etre soit AGENT_VERSION = "1.0.1" soit
# AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.1").
# La regex de build_package.sh/build_installer.sh ne gere QUE la 1ere forme
# (et retombe sur 1.0.0 pour la 2e). Ici on prend le DERNIER litteral entre
# guillemets de la ligne AGENT_VERSION (= la valeur par defaut effective),
# pour nommer le ZIP de maniere stable quelle que soit la forme.
VERSION=$(grep -m1 'AGENT_VERSION' "$PROJECT_ROOT/agent_v0/agent_v1/config.py" \
| grep -oP '"[^"]+"' | tr -d '"' | tail -1)
VERSION="${VERSION:-1.0.0}"
OUTPUT_ZIP="$BUILD_DIR/Lea_full_v${VERSION}.zip"
echo -e "${GREEN}============================================================${NC}"
echo -e "${GREEN} Build ZIP Lea COMPLET autoportant v${VERSION}${NC}"
echo -e "${GREEN}============================================================${NC}"
echo ""
CLEAN=0
for arg in "$@"; do
case "$arg" in
--clean) CLEAN=1 ;;
*) echo "Argument inconnu : $arg" ;;
esac
done
# ---------------------------------------------------------------
# 1. Regenerer le staging depuis la SOURCE COURANTE du repo
# build_installer.sh --stage-only appelle build_package.sh
# (qui copie agent_v0/agent_v1, lea_ui, run_agent_v1.py courants)
# puis ajoute python-3.12-embed/ + helpers, et exclut install.bat.
#
# --clean est TOUJOURS force : sans lui, build_installer.sh reutilise
# un deploy/build/Lea/ deja present (cache du build precedent) et ne
# re-execute PAS build_package.sh -> la source embarquee serait perimee.
# On veut au contraire garantir le working tree COURANT du repo.
# ---------------------------------------------------------------
echo "[1/6] Regeneration du staging depuis la source courante (--clean force)..."
bash "$INSTALLER_DIR/build_installer.sh" --stage-only --clean
if [[ ! -d "$STAGING_DIR" ]]; then
echo -e "${RED} ERREUR : staging $STAGING_DIR absent apres build_installer.sh${NC}"
exit 1
fi
echo " Staging pret : $STAGING_DIR"
echo ""
# ---------------------------------------------------------------
# 2. Assembler l'arborescence Lea/ (prefixe attendu par le dashboard
# qui remplace exactement 'Lea/config.txt').
# ---------------------------------------------------------------
echo "[2/6] Assemblage de l'arborescence Lea/..."
rm -rf "$ASSEMBLY_DIR"
mkdir -p "$ASSEMBLY_DIR/Lea"
# Copier le staging, en renommant python-3.12-embed -> python-embed
# (chemin attendu par le Lea.bat embedded : %~dp0python-embed\pythonw.exe)
rsync -a \
--exclude='python-3.12-embed' \
--exclude='install.bat' \
--exclude='config.txt' \
"$STAGING_DIR/" \
"$ASSEMBLY_DIR/Lea/"
rsync -a "$STAGING_DIR/python-3.12-embed/" "$ASSEMBLY_DIR/Lea/python-embed/"
echo " Source + python-embed/ assembles"
echo ""
# ---------------------------------------------------------------
# 3. Lea.bat embedded : extraire le bloc canonique de configure_embed.ps1
# (le here-string $NewLeaBat). C'est la SEULE source de verite du
# Lea.bat embedded ; on ne le duplique pas dans ce script.
# ---------------------------------------------------------------
echo "[3/6] Generation de Lea.bat (runtime embedded)..."
LEA_BAT_OUT="$ASSEMBLY_DIR/Lea/Lea.bat"
python3 - "$INSTALLER_DIR/configure_embed.ps1" "$LEA_BAT_OUT" <<'PYEOF'
import sys, re
ps1_path, out_path = sys.argv[1], sys.argv[2]
text = open(ps1_path, encoding="utf-8").read()
# Extrait le here-string PowerShell : $NewLeaBat = @" ... "@
m = re.search(r'\$NewLeaBat\s*=\s*@"\r?\n(.*?)\r?\n"@', text, re.DOTALL)
if not m:
sys.exit("ERREUR : bloc $NewLeaBat introuvable dans configure_embed.ps1")
content = m.group(1)
# CRLF pour un .bat Windows
content = content.replace("\r\n", "\n").replace("\n", "\r\n")
if not content.endswith("\r\n"):
content += "\r\n"
open(out_path, "wb").write(content.encode("ascii"))
print(f" Lea.bat genere depuis configure_embed.ps1 ({len(content)} octets)")
PYEOF
# Installateur 1-clic non-IT (raccourci Bureau + Demarrage automatique,
# per-user, sans admin). Asset statique CRLF/ASCII copie tel quel dans Lea/.
INSTALLER_BAT_SRC="$INSTALLER_DIR/Installer-Lea.bat"
if [[ ! -f "$INSTALLER_BAT_SRC" ]]; then
echo -e "${RED} ERREUR : $INSTALLER_BAT_SRC introuvable${NC}"
exit 1
fi
cp "$INSTALLER_BAT_SRC" "$ASSEMBLY_DIR/Lea/Installer-Lea.bat"
echo " Installer-Lea.bat (installation 1-clic) ajoute"
# Notice utilisateur dediee a l'install autonome (remplace la LISEZMOI legacy
# du staging, qui decrit l'ancien flux install.bat + Python systeme).
LISEZMOI_SRC="$INSTALLER_DIR/LISEZMOI-autonome.txt"
if [[ -f "$LISEZMOI_SRC" ]]; then
cp "$LISEZMOI_SRC" "$ASSEMBLY_DIR/Lea/LISEZMOI.txt"
echo " LISEZMOI.txt (version install autonome) pose"
fi
echo ""
# ---------------------------------------------------------------
# 4. Patcher python312._pth (import site active) — idempotent.
# Necessaire pour que l'embed charge site-packages.
# ---------------------------------------------------------------
echo "[4/6] Patch python312._pth (import site)..."
PTH_FILE=$(find "$ASSEMBLY_DIR/Lea/python-embed" -name "python*._pth" | head -1)
if [[ -z "$PTH_FILE" ]]; then
echo -e "${RED} ERREUR : python*._pth introuvable dans python-embed/${NC}"
exit 1
fi
# Decommente '#import site' s'il est commente ; sinon laisse tel quel.
sed -i 's/^#import site/import site/' "$PTH_FILE"
if ! grep -q '^import site' "$PTH_FILE"; then
printf 'import site\r\n' >> "$PTH_FILE"
fi
echo " $(basename "$PTH_FILE") : import site actif"
echo ""
# ---------------------------------------------------------------
# 5. config.txt placeholder (CONFIGURE_ME) — cible de l'injection
# dashboard (app.py remplace 'Lea/config.txt').
# ---------------------------------------------------------------
echo "[5/6] Pose du config.txt placeholder..."
cp "$INSTALLER_DIR/../lea_package/config.txt" "$ASSEMBLY_DIR/Lea/config.txt"
if ! grep -q 'CONFIGURE_ME' "$ASSEMBLY_DIR/Lea/config.txt"; then
echo -e "${YELLOW} AVERTISSEMENT : config.txt ne contient pas CONFIGURE_ME (placeholder inattendu)${NC}"
fi
echo " Lea/config.txt (placeholder) pose"
echo ""
# ---------------------------------------------------------------
# 6. Validation de completude AVANT zip (un ZIP incomplet = install
# cassee chez le client non-IT).
# ---------------------------------------------------------------
echo "[6/6] Validation + creation du ZIP..."
REQUIRED=(
"Lea/run_agent_v1.py"
"Lea/agent_v1/config.py"
"Lea/agent_v1/main.py"
"Lea/lea_ui/server_client.py"
"Lea/Lea.bat"
"Lea/Installer-Lea.bat"
"Lea/config.txt"
"Lea/python-embed/python.exe"
"Lea/python-embed/pythonw.exe"
"Lea/python-embed/Lib/site-packages/mss"
"Lea/python-embed/Lib/site-packages/win32"
"Lea/python-embed/Lib/site-packages/socketio"
)
MISSING=()
for f in "${REQUIRED[@]}"; do
[[ -e "$ASSEMBLY_DIR/$f" ]] || MISSING+=("$f")
done
# install.bat NE DOIT PAS etre present
if [[ -e "$ASSEMBLY_DIR/Lea/install.bat" ]]; then
echo -e "${RED} ERREUR : install.bat present dans l'assemblage (doit etre absent).${NC}"
exit 1
fi
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo -e "${RED} ERREUR : assemblage incomplet. Manquants :${NC}"
printf ' - %s\n' "${MISSING[@]}"
exit 1
fi
echo " Completude verifiee (${#REQUIRED[@]} elements, install.bat absent)"
# Verif source A JOUR : le config.py embarque doit etre identique au repo
if ! diff -q "$PROJECT_ROOT/agent_v0/agent_v1/config.py" "$ASSEMBLY_DIR/Lea/agent_v1/config.py" >/dev/null; then
echo -e "${RED} ERREUR : agent_v1/config.py embarque DIFFERE de la source repo !${NC}"
echo " Le ZIP n'embarque pas la source a jour — build interrompu."
exit 1
fi
echo " Source a jour confirmee (agent_v1/config.py == repo)"
rm -f "$OUTPUT_ZIP"
( cd "$ASSEMBLY_DIR" && zip -q -r -X "$OUTPUT_ZIP" Lea )
ZIP_SIZE=$(du -h "$OUTPUT_ZIP" | cut -f1)
echo ""
echo -e "${GREEN}============================================================${NC}"
echo -e "${GREEN} ZIP complet produit !${NC}"
echo -e "${GREEN}============================================================${NC}"
echo ""
echo " Fichier : $OUTPUT_ZIP"
echo " Taille : $ZIP_SIZE"
echo ""
echo " Servi par le dashboard via web_dashboard/app.py (_LEA_ZIP_TEMPLATE)."
echo " L'utilisateur : dezippe -> double-clic Lea.bat (aucun Python systeme requis)."
echo ""

View File

@@ -0,0 +1,152 @@
@echo off
chcp 65001 >nul 2>&1
title Lea - Installation 1-clic
setlocal EnableDelayedExpansion
:: ============================================================
:: Installer-Lea.bat - Installation 1-clic per-user (sans admin)
:: ------------------------------------------------------------
:: - Copie le paquet Lea (y compris python-embed) vers
:: %LOCALAPPDATA%\Lea (emplacement stable per-user).
:: - Cree un raccourci sur le Bureau.
:: - Cree un raccourci dans le dossier Demarrage (lancement
:: automatique a chaque ouverture de session Windows).
:: - Lance Lea une premiere fois (pythonw, sans console).
::
:: Aucun droit administrateur requis. Aucun service Windows
:: (Lea est une application systray, doit tourner dans la
:: session utilisateur).
:: ============================================================
echo.
echo ============================================================
echo Lea - Installation
echo ============================================================
echo.
:: --- Emplacement source (dossier de ce script) -------------
set "SRC=%~dp0"
:: Retirer l'antislash final eventuel
if "%SRC:~-1%"=="\" set "SRC=%SRC:~0,-1%"
:: --- Emplacement cible per-user ----------------------------
set "DEST=%LOCALAPPDATA%\Lea"
echo Installation vers : %DEST%
echo (copie du runtime embarque, cela prend quelques secondes)
echo.
:: --- Verification du runtime embarque dans la source -------
if not exist "%SRC%\python-embed\pythonw.exe" (
echo ERREUR : python-embed\pythonw.exe introuvable dans le paquet.
echo Le paquet semble incomplet. Re-telechargez Lea depuis le tableau de bord.
echo.
pause
exit /b 1
)
:: --- Si Lea tourne deja depuis la cible, l'arreter ----------
if exist "%DEST%\lea_agent.lock" (
for /f "usebackq tokens=* delims=" %%i in ("%DEST%\lea_agent.lock") do (
taskkill /F /PID %%i >nul 2>&1
)
del /f /q "%DEST%\lea_agent.lock" >nul 2>&1
timeout /t 1 >nul
)
:: --- Copie du paquet vers la cible -------------------------
:: robocopy : robuste pour la grosse arborescence python-embed.
:: /E sous-dossiers (vides inclus), /NFL /NDL /NJH /NJS /NP silencieux.
:: Codes de sortie robocopy < 8 = succes ; >= 8 = echec.
if not exist "%DEST%" mkdir "%DEST%" >nul 2>&1
robocopy "%SRC%" "%DEST%" /E /NFL /NDL /NJH /NJS /NP >nul
if %ERRORLEVEL% GEQ 8 (
echo robocopy a echoue, tentative avec xcopy...
xcopy "%SRC%\*" "%DEST%\" /E /I /H /Y >nul
if errorlevel 1 (
echo.
echo ERREUR : la copie vers %DEST% a echoue.
echo Verifiez l'espace disque et les droits sur votre profil.
echo.
pause
exit /b 1
)
)
echo Copie terminee - OK
echo.
:: --- Ne pas laisser l'installeur se relancer en boucle -----
:: (on supprime la copie de l'installeur dans la cible : inutile une fois installe)
del /f /q "%DEST%\Installer-Lea.bat" >nul 2>&1
:: --- Detection d'une icone optionnelle ---------------------
:: Cherche un .ico dans le paquet installe (best-effort).
set "ICON="
for /f "delims=" %%f in ('dir /b /s "%DEST%\*.ico" 2^>nul') do (
if not defined ICON set "ICON=%%f"
)
:: --- Cibles des raccourcis ---------------------------------
set "TARGET=%DEST%\python-embed\pythonw.exe"
set "ARGS=run_agent_v1.py"
set "WORKDIR=%DEST%"
set "DESKTOP_LNK=%USERPROFILE%\Desktop\Lea.lnk"
set "STARTUP_LNK=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Lea.lnk"
:: --- Creation des raccourcis via PowerShell (WScript.Shell) -
echo Creation des raccourcis (Bureau + Demarrage automatique)...
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$ws = New-Object -ComObject WScript.Shell;" ^
"foreach ($p in @('%DESKTOP_LNK%','%STARTUP_LNK%')) {" ^
" $dir = Split-Path $p -Parent;" ^
" if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }" ^
" $s = $ws.CreateShortcut($p);" ^
" $s.TargetPath = '%TARGET%';" ^
" $s.Arguments = '%ARGS%';" ^
" $s.WorkingDirectory = '%WORKDIR%';" ^
" $s.Description = 'Lea - Assistante IA';" ^
" if ('%ICON%' -ne '' -and (Test-Path '%ICON%')) { $s.IconLocation = '%ICON%' }" ^
" $s.Save();" ^
"}"
if errorlevel 1 (
echo ATTENTION : la creation des raccourcis a partiellement echoue.
echo Vous pourrez tout de meme lancer Lea via %TARGET%.
) else (
echo Raccourcis crees - OK
)
echo.
:: --- Premier lancement de Lea (sans console) ---------------
echo Demarrage de Lea...
pushd "%DEST%"
start "" /b "%TARGET%" %ARGS%
popd
:: --- Verification rapide (via le lock PID) -----------------
timeout /t 3 >nul
set "LEA_ALIVE=0"
if exist "%DEST%\lea_agent.lock" (
for /f "usebackq tokens=* delims=" %%i in ("%DEST%\lea_agent.lock") do (
tasklist /FI "PID eq %%i" /NH 2>nul | findstr /I "pythonw" >nul && set "LEA_ALIVE=1"
)
)
echo.
echo ============================================================
if "%LEA_ALIVE%"=="1" (
echo Lea est installee et demarree !
) else (
echo Lea est installee.
)
echo ============================================================
echo.
echo - Lea apparait en bas a droite, dans la barre des taches
echo (petite icone ronde, a cote de l'horloge).
echo - Lea demarrera AUTOMATIQUEMENT a chaque ouverture de session.
echo - Un raccourci "Lea" a ete ajoute sur votre Bureau.
echo.
echo Vous pouvez fermer cette fenetre.
echo.
pause
endlocal
exit /b 0

View File

@@ -53,7 +53,7 @@ AIVANOV ne pourra etre tenu responsable d'un usage non conforme.
7. CONTACT
----------
Pour toute question ou demande d'acces/rectification/suppression
de donnees : dpo@aivanov.com
de donnees : dpo@aivanov.eu
============================================================
En cliquant sur "J'accepte", vous confirmez avoir pris connaissance

View File

@@ -0,0 +1,105 @@
============================================================
Lea - Votre assistante intelligente
============================================================
Bienvenue ! Lea est une assistante qui apprend vos taches
repetitives sur l'ordinateur pour pouvoir vous aider.
Cette version est 100% autonome : aucun Python a installer,
aucun droit administrateur necessaire.
INSTALLATION (une seule fois)
-----------------------------
1. Si Lea est dans un fichier ZIP, faites un clic droit
dessus puis "Extraire tout..." (ne lancez pas Lea
directement depuis le ZIP).
2. Ouvrez le dossier extrait et double-cliquez sur
"Installer-Lea.bat".
3. Patientez quelques secondes (copie du programme).
A la fin, le message "Lea est installee et demarree"
s'affiche.
C'est tout. Lea est installee dans votre profil utilisateur
et :
- un raccourci "Lea" est ajoute sur votre Bureau ;
- Lea demarrera AUTOMATIQUEMENT a chaque fois que vous
ouvrez votre session Windows.
Vous pouvez ensuite supprimer le dossier extrait et le ZIP :
Lea continue de fonctionner (elle a ete copiee a part).
LANCER LEA MANUELLEMENT
-----------------------
Si besoin, double-cliquez sur le raccourci "Lea" du Bureau.
Lea apparait en bas a droite de votre ecran, dans la barre
des taches (petite icone ronde, a cote de l'horloge).
Clic droit sur l'icone pour ouvrir le menu :
- "Apprenez-moi une tache" : Lea observe ce que vous faites
et memorise les etapes. Travaillez normalement, Lea
apprend en vous regardant.
- "C'est termine" : Arrete l'enregistrement quand vous
avez fini la tache. Si vous oubliez, Lea s'arrete
automatiquement apres 1 heure.
- "Discuter avec Lea" : Ouvre une fenetre de discussion
pour poser des questions.
- "ARRET D'URGENCE" : Arrete immediatement tout ce que
Lea est en train de faire.
- "Quitter Lea" : Ferme le programme.
INFORMATIONS IMPORTANTES
------------------------
Quand Lea enregistre vos actions, elle capture votre ecran,
vos clics et vos frappes clavier.
- Lea vous previent AVANT chaque enregistrement
- Les donnees sensibles (mots de passe, informations
medicales) sont automatiquement floutees
- L'enregistrement s'arrete automatiquement apres 1 heure
- Vous pouvez arreter a tout moment via le menu
Lea est un systeme base sur l'intelligence artificielle
(Article 50, Reglement europeen sur l'IA).
CONFIGURATION
-------------
Si vous devez modifier l'adresse du serveur, ouvrez le fichier
"config.txt" (dans le dossier d'installation de Lea) avec le
Bloc-notes et changez les valeurs.
Ne modifiez rien d'autre sans l'accord de votre administrateur.
EN CAS DE PROBLEME
-------------------
- Lea ne demarre pas : double-cliquez a nouveau sur le
raccourci "Lea" du Bureau, ou relancez "Installer-Lea.bat".
- Lea est deconnectee : Verifiez votre connexion
reseau. Le serveur est peut-etre en maintenance.
- Pour desinstaller : supprimez le dossier "Lea" dans
votre profil (dossier %LOCALAPPDATA%\Lea) ainsi que les
raccourcis "Lea" du Bureau et du Demarrage.
- En cas de doute, contactez votre administrateur.
============================================================

View File

@@ -23,7 +23,7 @@
; ============================================================
#define MyAppName "Lea"
#define MyAppVersion "1.0.0"
#define MyAppVersion "1.0.1"
#define MyAppPublisher "AIVANOV"
#define MyAppURL "https://lea.labs.laurinebazin.design"
#define MyAppExeName "Lea.bat"
@@ -89,24 +89,23 @@ Name: "french"; MessagesFile: "compiler:Languages\French.isl"
[Files]
; Package complet (code Python + .bat + requirements)
; Note : install.bat EST copie (execute par [Run] pour creer le venv Python)
; Note : install.bat est EXCLU du staging (runtime 100% embedded, plus de venv/pip)
; Note : config.txt n'est PAS copie depuis le staging (il est genere par [Code])
Source: "{#SourceDir}\*"; \
DestDir: "{app}"; \
Flags: ignoreversion recursesubdirs createallsubdirs; \
Excludes: "{#PythonEmbedDir}\*,config.txt,*.log,sessions\*,__pycache__\*"
; Python 3.12 embedded (optionnel, copie conditionnelle via check)
; Python 3.12 embedded (OBLIGATOIRE — runtime 100% autonome, aucune dependance Python systeme)
Source: "{#SourceDir}\{#PythonEmbedDir}\*"; \
DestDir: "{app}\python-embed"; \
Flags: ignoreversion recursesubdirs createallsubdirs skipifsourcedoesntexist; \
Components: pythonembed
Flags: ignoreversion recursesubdirs createallsubdirs
; Script de desinstallation custom (kill + export logs)
Source: "uninstall_lea.ps1"; DestDir: "{app}"; Flags: ignoreversion
; Script de configuration du runtime Python embedded (optionnel)
Source: "configure_embed.ps1"; DestDir: "{app}"; Flags: ignoreversion; Components: pythonembed
; Script de configuration du runtime Python embedded (toujours installe)
Source: "configure_embed.ps1"; DestDir: "{app}"; Flags: ignoreversion
; Licence CGU (affichee dans la page licence ET conservee dans {app})
Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion isreadme
@@ -115,37 +114,30 @@ Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion isreadme
Source: "config_template.txt"; DestDir: "{app}"; Flags: ignoreversion
[Components]
Name: "core"; Description: "Lea (obligatoire)"; Types: full compact custom; Flags: fixed
Name: "pythonembed"; Description: "Python 3.12 embedded (recommande si Python non installe sur le poste)"; Types: full
Name: "autostart"; Description: "Demarrer Lea automatiquement au demarrage de Windows"; Types: full
; Composant unique fixe : pas de choix utilisateur (runtime embedded toujours inclus).
; Inno masque la page Composants quand il n'y a aucun composant selectionnable.
Name: "core"; Description: "Lea"; Types: full compact custom; Flags: fixed
[Tasks]
Name: "autostart"; Description: "Demarrer Lea automatiquement au demarrage de Windows"; GroupDescription: "Options :"
Name: "desktopicon"; Description: "Creer un raccourci sur le bureau"; GroupDescription: "Raccourcis :"; Flags: unchecked
Name: "startmenuicon"; Description: "Creer un raccourci dans le menu Demarrer"; GroupDescription: "Raccourcis :"
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: startmenuicon
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon
; Raccourci autostart (shell:startup) — cree si composant autostart selectionne
; Raccourci autostart (shell:startup) — cree si tache autostart selectionnee
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; \
WorkingDir: "{app}"; Components: autostart
WorkingDir: "{app}"; Tasks: autostart
[Run]
; Apres copie : executer install.bat pour creer le venv et installer les dependances Python
; Skip si bundle embedded (dans ce cas, on utilise python-embed directement)
Filename: "{app}\install.bat"; \
WorkingDir: "{app}"; \
StatusMsg: "Installation des composants Python (1-2 minutes)..."; \
Flags: runhidden waituntilterminated; \
Components: not pythonembed
; Configuration Python embedded : creer un Lea.bat qui pointe sur python-embed
; Configuration du runtime embedded : reecrit Lea.bat pour pointer sur python-embed.
; TOUJOURS execute — runtime 100% autonome, aucune branche venv/pip/Python systeme.
Filename: "{cmd}"; \
Parameters: "/c copy /y ""{app}\Lea.bat"" ""{app}\Lea.bat.bak"" && powershell -NoProfile -ExecutionPolicy Bypass -File ""{app}\configure_embed.ps1"""; \
WorkingDir: "{app}"; \
StatusMsg: "Configuration du runtime Python embedded..."; \
Flags: runhidden waituntilterminated skipifsilent; \
Components: pythonembed
StatusMsg: "Configuration de Lea..."; \
Flags: runhidden waituntilterminated
; Lancer Lea a la fin de l'installation (optionnel)
Filename: "{app}\{#MyAppExeName}"; \
@@ -161,13 +153,20 @@ Filename: "powershell.exe"; \
[UninstallDelete]
Type: filesandordirs; Name: "{app}\.venv"
Type: filesandordirs; Name: "{app}\python-embed"
Type: filesandordirs; Name: "{app}\__pycache__"
Type: filesandordirs; Name: "{app}\agent_v1\__pycache__"
Type: filesandordirs; Name: "{app}\agent_v1\sessions"
Type: filesandordirs; Name: "{app}\agent_v1\logs"
Type: files; Name: "{app}\lea_agent.lock"
Type: files; Name: "{app}\config.txt"
Type: files; Name: "{app}\config.txt.bak.*"
Type: files; Name: "{app}\machine_id.txt"
Type: files; Name: "{app}\Lea.bat.bak"
Type: files; Name: "{app}\install.bat"
; Filet de securite : supprime tout residu genere au runtime (caches, *.pyc, logs)
; afin que le dossier applicatif soit entierement supprime (exigence desinstall propre).
Type: filesandordirs; Name: "{app}"
; ============================================================
; Code Pascal : pages custom + generation config.txt + helpers
@@ -176,7 +175,7 @@ Type: files; Name: "{app}\machine_id.txt"
const
SERVER_URL_DEFAULT = 'https://lea.labs.laurinebazin.design/api/v1';
SERVER_HOST_DEFAULT = 'lea.labs.laurinebazin.design';
DEFAULT_TOKEN = '86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab';
DEFAULT_TOKEN = 'o3_LHqV_7_Gc6OVPHndhsBbvG6HJ5PCgl8yIBhGUIz8';
var
EnrollmentPage: TInputQueryWizardPage;
@@ -243,9 +242,14 @@ begin
// Essaye d'utiliser le GUID genere par Windows (via PowerShell)
Guid := '';
if CreateGUIDString(Guid) then
Result := LowerCase(StringChange(StringChange(StringChange(Guid, '{', ''), '}', ''), '-', ''))
begin
StringChange(Guid, '{', '');
StringChange(Guid, '}', '');
StringChange(Guid, '-', '');
Result := LowerCase(Guid);
end
else
Result := IntToStr(GetTickCount);
Result := GetDateTimeString('yyyymmddhhnnss', #0, #0);
// Ajoute un hash du hostname pour stabilite
Hostname := GetComputerNameString();
@@ -404,8 +408,8 @@ begin
// Derive ServerHost depuis ServerUrl : https://host/api/v1 -> host
ServerHost := ServerUrl;
ServerHost := StringChange(ServerHost, 'https://', '');
ServerHost := StringChange(ServerHost, 'http://', '');
StringChange(ServerHost, 'https://', '');
StringChange(ServerHost, 'http://', '');
SlashPos := Pos('/', ServerHost);
if SlashPos > 0 then
ServerHost := Copy(ServerHost, 1, SlashPos - 1);

View File

@@ -103,6 +103,12 @@ rsync -a \
--exclude='.venv' \
--exclude='sessions/' \
--exclude='logs/' \
--exclude='test_lea_*' \
--exclude='_test_paused_toast.py' \
--exclude='tools/test_*' \
--exclude='install.bat' \
--exclude='*.bak' \
--exclude='config.txt.bak*' \
"$BASE_BUILD_DIR/" \
"$STAGING_DIR/"
@@ -128,15 +134,38 @@ echo ""
# 5. Python embedded (optionnel)
# ---------------------------------------------------------------
PYTHON_EMBED_SRC="${PYTHON_EMBED_DIR:-$SCRIPT_DIR/python-3.12-embed}"
if [[ -d "$PYTHON_EMBED_SRC" ]]; then
echo "[4/5] Copie de Python 3.12 embedded..."
rsync -a "$PYTHON_EMBED_SRC/" "$STAGING_DIR/python-3.12-embed/"
echo " Python embedded inclus"
else
echo -e "${YELLOW}[4/5] Python 3.12 embedded non trouve dans $PYTHON_EMBED_SRC${NC}"
echo " L'installeur sera produit SANS bundle Python."
echo " Pour bundler Python : voir README.md section 'Python embedded'"
if [[ ! -d "$PYTHON_EMBED_SRC" ]]; then
echo -e "${RED}[4/5] ERREUR : Python 3.12 embedded introuvable dans $PYTHON_EMBED_SRC${NC}"
echo " L'embed est OBLIGATOIRE (runtime 100% autonome, aucune dependance Python systeme)."
echo " Build interrompu."
exit 1
fi
echo "[4/5] Copie de Python 3.12 embedded..."
rsync -a "$PYTHON_EMBED_SRC/" "$STAGING_DIR/python-3.12-embed/"
# Validation de la completude de l'embed : un embed incomplet = install cassee chez le client.
# La liste doit rester alignee avec configure_embed.ps1 (verification runtime des imports).
EMBED="$STAGING_DIR/python-3.12-embed"
REQUIRED_EMBED=(
"python.exe" "pythonw.exe" "python312._pth"
"_tkinter.pyd" "tcl86t.dll" "tk86t.dll" "zlib1.dll"
"Lib/site-packages/socketio" "Lib/site-packages/tkinter"
"Lib/site-packages/mss" "Lib/site-packages/pynput"
"Lib/site-packages/pystray" "Lib/site-packages/plyer"
"Lib/site-packages/requests" "Lib/site-packages/PIL"
"Lib/site-packages/win32"
)
MISSING_EMBED=()
for f in "${REQUIRED_EMBED[@]}"; do
[[ -e "$EMBED/$f" ]] || MISSING_EMBED+=("$f")
done
if [[ ${#MISSING_EMBED[@]} -gt 0 ]]; then
echo -e "${RED} ERREUR : embed incomplet. Elements manquants :${NC}"
printf ' - %s\n' "${MISSING_EMBED[@]}"
echo " Build interrompu (le runtime doit etre complet et autonome)."
exit 1
fi
echo " Python embedded complet inclus (${#REQUIRED_EMBED[@]} elements verifies)"
echo ""
# ---------------------------------------------------------------

View File

@@ -40,25 +40,24 @@ if ($PthFile) {
}
# ---------------------------------------------------------------
# 2. Installer pip (bootstrap via get-pip.py)
# 2-3. Verification des dependances embarquees (runtime 100% autonome)
# L'embed DOIT contenir toutes les dependances runtime.
# AUCUN pip, AUCUN reseau : si une dependance manque -> echec explicite.
# ---------------------------------------------------------------
$GetPip = Join-Path $env:TEMP "get-pip.py"
Write-Host " Telechargement de get-pip.py..."
Invoke-WebRequest -Uri "https://bootstrap.pypa.io/get-pip.py" -OutFile $GetPip -UseBasicParsing
Write-Host " Installation de pip..."
& $PythonExe $GetPip --no-warn-script-location
Remove-Item $GetPip -Force
# ---------------------------------------------------------------
# 3. Installer les dependances
# ---------------------------------------------------------------
$Requirements = Join-Path $AppDir "requirements_agent.txt"
if (Test-Path $Requirements) {
Write-Host " Installation des dependances Python..."
& $PythonExe -m pip install --no-warn-script-location -r $Requirements
$RequiredModules = @('socketio','tkinter','mss','pynput','pystray','plyer','requests','PIL','win32api')
$Missing = @()
foreach ($m in $RequiredModules) {
& $PythonExe -c "import $m" 2>$null
if ($LASTEXITCODE -ne 0) { $Missing += $m }
}
if ($Missing.Count -gt 0) {
Write-Host " ERREUR : runtime Lea incomplet. Modules manquants : $($Missing -join ', ')"
Write-Host " L'embed doit etre livre complet (aucune installation reseau en POC)."
exit 1
}
Write-Host " Dependances embarquees verifiees ($($RequiredModules.Count) modules) - offline OK."
# ---------------------------------------------------------------
# 4. Reecrire Lea.bat pour utiliser python-embed
# ---------------------------------------------------------------

View File

@@ -9,5 +9,12 @@ psutil>=5.9.0 # Monitoring CPU/RAM
pystray>=0.19.5 # Icone systray
plyer>=2.1.0 # Notifications toast natives
# FeedbackBus / bulles d'action Lea (client socketio temps reel vers agent-chat :5004)
# Jeu valide en runtime sur la VM (chat + bulles fonctionnels)
python-socketio>=5.10.0 # client SocketIO (FeedbackBus)
python-engineio>=4.8.0 # transport engine.io
websocket-client>=1.9.0 # transport websocket client
simple-websocket>=1.1.0 # fallback websocket
# Windows specifique
pywin32>=306 ; sys_platform == 'win32'

View File

@@ -14,6 +14,8 @@ EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
Environment="PYTHONUNBUFFERED=1"
Environment="ENVIRONMENT=production"
Environment="RPA_SERVICE_NAME=rpa-vision-v3-api"
# Keep the upload API internal to the DGX; other LAN-facing services keep the shared bind host.
Environment="RPA_BIND_HOST=127.0.0.1"
# Service grounding persistant — socket + répertoire d'images partagés via /run/rpa/.
# Si le service rpa-grounding n'est pas démarré, le client retombe automatiquement
# sur le subprocess one-shot (cf. ui_tars_grounder.py).

View File

@@ -30,6 +30,11 @@ P0 / P1 / P2 / P3 (alignées sur convention handoffs)
| DETTE-012 | 2026-05-09 | 2026-05-23 | P3 | OPEN | Migration backend grounding vers vLLM (option mentionnée dans plan migration mais infra absente : pas d'install vLLM, pas de service systemd dédié). Choix Transformers direct retenu pour fix DETTE-006. Migration vLLM à instruire séparément si bénéfice mesuré post-démo Kerella. | docs/MIGRATION_VLM_PLAN_2026-05-09.md + investigation infra session 2026-05-09 |
| DETTE-013 | 2026-05-09 | 2026-05-23 | P2 | OPEN | Environnement de tests dev local cassé : pytest tests/unit/ déclenche sys.exit(1) via import api_stream sans RPA_API_TOKEN/RPA_AUTH_DISABLED définis (api_stream.py:135, fail-closed sécurité commit 93ef93e56). Combiné avec DETTE-011 (cv2 dans conftest), la batterie de tests unitaires complète n'est pas exécutable en dev local sans configuration environnement spécifique. À documenter (env vars requises) ou refactor (découpler tests purs des tests chargeant api_stream). | session 2026-05-09 (découvert pendant validation refactor bbox_parser) |
| DETTE-014 | 2026-05-09 | 2026-05-10 | P1 | OPEN | Module core/grounding/smart_resize.py commité ce matin (commit 0d7bcd18a) calé sur la référence transformers.qwen2_vl.image_processing_qwen2_vl (factor=28, max_pixels=1_003_520). Le checkpoint Qwen3-VL-8B-Instruct utilise en réalité Qwen2VLImageProcessorFast avec patch_size=16 (factor probable 32) et convention size.longest_edge/shortest_edge. À réaligner après investigation DETTE-010 demain. Module pur, testé à 100% sur la convention actuelle — la convention reste valide en référence, mais ne s'applique pas à ce checkpoint. | commit 0d7bcd18a + investigation DETTE-010 du 2026-05-09 |
| DETTE-015 | 2026-06-09 | 2026-06-23 | P1 | OPEN | Double stockage des workflows incohérent. La route API VWB `/api/workflows/` lit des fichiers JSON via `WorkflowDatabase("data/workflows")` (api/workflows.py:53, chemin **relatif au cwd**), alors qu'une DB SQLAlchemy propre coexiste (`visual_workflow_builder/backend/instance/workflows.db`, table `workflows` + migrations Alembic). Le worker DGX persiste les workflows appris dans `data/training/workflows/{machine_id}/` en JSON, mais ne les écrit pas dans la DB VWB : l'assimilation Léa fonctionne, mais le workflow appris reste hors source SQLAlchemy/VWB (`2026-06-12`, session M2). Deux sources de vérité non synchronisées → la divergence de `WorkingDirectory` dev (cwd=backend) vs DGX (cwd=racine) a causé le bug « 0 workflows servis » (P0-1, 2026-06-09). Contournement POC en place : symlink `data/workflows``backend/data/workflows` (sans sudo, réversible). Fragilités : dépendance au cwd, pas d'écriture atomique/validation schéma, 3e store legacy `data/training/workflows`, pont VWB/Léa existant mais non branché automatiquement post-finalize. Cible consolidation : unifier la persistance workflows sur la DB SQLAlchemy existante (source unique, transactions, review/édition VWB, fin des bugs de cwd), avec bascule progressive sous flag pour ne pas casser le POC DGX. | docs/PLAN_MIGRATION_WORKFLOWS_STORE_2026-06-09.md + RESULTAT-P0-DASHBOARD-CORRECTIONS (2026-06-09) + M2 assimilation DGX 2026-06-12 |
| DETTE-016 | 2026-06-10 | 2026-06-24 | P2 | ACCEPTED | Auth agents POC avec `RPA_API_TOKEN` global partagé et `machine_id` auto-déclaré : `_verify_token()` valide le Bearer global et `_guard_agent_registry_access()` vérifie que le `machine_id` déclaré est actif, mais ne prouve pas cryptographiquement que le client est bien ce poste. Risque accepté pour POC contrôlé LAN/non exposé internet ; à reconsidérer avant distribution multi-TIM élargie, exposition réseau non maîtrisée ou exigence de révocation non contournable par poste. WP-C token par poste Patch 1-3 reste local/inerte, Patch 4 runtime annulé pour le POC. | docs/coordination/inbox_codex/2026-06-10_1450_qwen-to-codex-claude-dom_DECISION-WPC-ABANDON-DETTE.md + docs/coordination/active/2026-06-10_1440_anti-doublon-wpc-verdict.md |
| DETTE-017 | 2026-06-12 | 2026-06-12 | P0 | OPEN | Auth Bearer **désactivée** (`RPA_AUTH_DISABLED=true`) sur streaming `5005` ET agent-chat `5004` du DGX, appliquée comme « fix » heartbeat B3 (rustine). Démontré inutile : les 3 tokens (DGX proc, DGX `.env.local`, Windows `.env`) sont identiques (SHA256 `43749362b1`, len 43) → l'auth peut être réactivée sans casser le heartbeat. Exposition `0.0.0.0:5004/5005` restreinte par iptables au seul poste `192.168.1.11` ; dashboard `5001` conserve son auth. **Exception temporaire validée par Dom (2026-06-12 09:35) pour test M2 local sur données factices.** ROLLBACK OBLIGATOIRE avant toute sortie clinique / données patient : `RPA_AUTH_DISABLED=false` dans `.env.local` DGX + `sudo systemctl restart rpa-streaming.service rpa-agent-chat.service` puis vérif (401 sans token / 200 avec / heartbeat maintenu). | docs/coordination/active/2026-06-12_0935_decision-dom-auth-off-exception-m2.md + alerte 2026-06-11_1535 |
| DETTE-018 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Garde-seuil inopérant sur le chemin grounding **legacy** : `_resolve_by_grounding` retourne `method="grounding_vlm"` (resolve_engine.py:1121, mode `RPA_GROUNDING_ENGINE` OFF), clé absente de `_RESOLUTION_MIN_SCORES` qui ne traite en **préfixe** que `memory_` (toutes les autres clés = match exact) → le Check-1 du validateur (seuil min de confiance) ne s'applique jamais à ce chemin. Le mode `qwen3vl_vllm` est lui correctement gardé (`method="grounding"`, clé exacte, seuil 0.60). Aligner le legacy (clé gardée ou renommage) tant que le mode legacy reste activable. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
| DETTE-019 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Confiance grounding **figée à `0.85` en dur** dans le `return` de `_resolve_by_grounding` (resolve_engine.py:1128-1130 : `matched_element.confidence` et `score`), pour les DEUX modes (legacy et qwen3vl). Le garde-seuil (0.60) reçoit donc toujours 0.85 quel que soit le grounding réel → le filtre ne discrimine jamais la vraie qualité de localisation. Propager une confiance réelle (signal modèle/cascade) pour rendre le seuil opérant. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
## Convention de référencement

View File

@@ -38,7 +38,11 @@ def load_env_file(env_path):
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
os.environ[key.strip()] = value.strip()
# setdefault : l'environnement déjà défini par le service (systemd
# Environment=/EnvironmentFile=) prime ; .env.local ne fournit que des
# valeurs par défaut. Évite d'écraser une variable volontairement
# surchargée côté service (ex. RPA_BIND_HOST=127.0.0.1).
os.environ.setdefault(key.strip(), value.strip())
return True
# Charger .env.local depuis le répertoire parent (racine du projet)
@@ -471,9 +475,10 @@ if __name__ == "__main__":
logger.info(f"Encryption password: {'***' if ENCRYPTION_PASSWORD != 'rpa_vision_v3_default_key' else 'DEFAULT (changer!)'}")
try:
import os as _os
uvicorn.run(
app,
host="0.0.0.0",
host=_os.environ.get("RPA_BIND_HOST", "127.0.0.1"),
port=8000,
log_level="info"
)

View File

@@ -119,8 +119,10 @@ def test_export_vwb_workflow_with_pause_step():
]
core = convert_vwb_to_core_workflow(workflow_data, steps_data)
assert core["learning_state"] == "COACHING"
assert len(core["nodes"]) == 3
assert len(core["edges"]) == 2
assert len(core["nodes"]) == 4
assert len(core["edges"]) == 3
assert core["edges"][-1]["action"]["type"] == "mouse_click"
assert core["nodes"][-1]["is_end"] is True
# L'edge sortant du node de pause doit avoir le bon type + message
pause_edges = [

View File

@@ -1289,3 +1289,158 @@ class TestAPIEndpoints:
assert len(workflows) == 1
assert workflows[0]["workflow_id"] == "wf_api_001"
assert workflows[0]["nodes"] == 2
class TestWorkerStatusTruthfulness:
"""Truthfulness du statut worker exposé par _get_worker_queue_status.
Distingue VEILLE (armé, lazy : worker neuf qui n'a jamais traité de
session, composants chargés à la 1re session) de DÉGRADÉ (init tentée
et en échec). Un worker en veille ne doit JAMAIS être étiqueté 'degraded'.
"""
# Même contrainte que TestAPIEndpoints : api_stream fail-closed à l'import
# si RPA_API_TOKEN absent.
_TEST_API_TOKEN = "test_token_for_worker_status_0123456789abcdef"
@pytest.fixture(autouse=True)
def _ensure_api_token(self, monkeypatch):
monkeypatch.setenv("RPA_API_TOKEN", self._TEST_API_TOKEN)
@pytest.fixture
def status_env(self, tmp_path, monkeypatch):
"""Isole les fichiers worker (health/queue/lock) sur tmp_path."""
from agent_v0.server_v1 import api_stream
health_file = tmp_path / "_worker_health.json"
queue_file = tmp_path / "_worker_queue.txt"
lock_file = tmp_path / "_replay_active.lock"
monkeypatch.setattr(api_stream, "WORKER_HEALTH_FILE", health_file)
monkeypatch.setattr(api_stream, "WORKER_QUEUE_FILE", queue_file)
monkeypatch.setattr(api_stream, "REPLAY_LOCK_FILE", lock_file)
return api_stream, health_file
@staticmethod
def _write_health(health_file, **overrides):
"""Écrit un health file frais (mtime récent => non stale)."""
payload = {
"pid": 1234,
"started_at": "2026-06-18T10:00:00",
"last_cycle": "2026-06-18T10:00:30",
"current_session": None,
"queue_length": 0,
"components": {
"screen_analyzer": False,
"clip_embedder": False,
"faiss_manager": False,
"state_embedding_builder": False,
},
"stats": {
"sessions_processed": 0,
"sessions_failed": 0,
"sessions_skipped": 0,
"total_screenshots_analyzed": 0,
},
"status": "healthy",
}
payload.update(overrides)
health_file.write_text(json.dumps(payload), encoding="utf-8")
def test_fresh_worker_is_idle_not_degraded(self, status_env):
"""Worker neuf : healthy, 0 session, tous composants false
=> statut 'idle' (en veille / armé), PAS 'degraded'."""
api_stream, health_file = status_env
self._write_health(health_file) # défaut = état neuf
status = api_stream._get_worker_queue_status()
assert status["running"] is True
assert status["status"] == "idle", status
assert status["armed"] is True
assert status["components_ready"] is False
# processing_ready reste False tant que les composants ne sont pas chargés
assert status["processing_ready"] is False
assert "veille" in status["status_hint"].lower()
def test_worker_init_failed_is_degraded(self, status_env):
"""Init tentée et en échec : run_worker force status='degraded'
(VLM + ScreenAnalyzer absent) => on conserve 'degraded'."""
api_stream, health_file = status_env
self._write_health(
health_file,
status="degraded", # forcé par run_worker._write_health
components={
"screen_analyzer": False,
"clip_embedder": True,
"faiss_manager": True,
"state_embedding_builder": False,
},
stats={
"sessions_processed": 0,
"sessions_failed": 1, # une session a tenté l'init et échoué
"sessions_skipped": 0,
"total_screenshots_analyzed": 0,
},
)
status = api_stream._get_worker_queue_status()
assert status["running"] is True
assert status["status"] == "degraded", status
assert status["armed"] is False
assert status["processing_ready"] is False
assert "dégradé" in status["status_hint"].lower()
def test_worker_partial_components_after_attempt_is_degraded(self, status_env):
"""Composants partiels après tentative de traitement (sessions_failed>0),
sans status forcé par le worker => 'degraded' (pas 'idle')."""
api_stream, health_file = status_env
self._write_health(
health_file,
status="healthy",
components={
"screen_analyzer": True,
"clip_embedder": True,
"faiss_manager": False, # un composant manquant
"state_embedding_builder": True,
},
stats={
"sessions_processed": 0,
"sessions_failed": 2,
"sessions_skipped": 0,
"total_screenshots_analyzed": 0,
},
)
status = api_stream._get_worker_queue_status()
assert status["status"] == "degraded", status
assert status["armed"] is False
def test_worker_ready_after_processing_is_healthy(self, status_env):
"""Worker ayant traité au moins une session, tous composants chargés
=> 'healthy' et processing_ready=True."""
api_stream, health_file = status_env
self._write_health(
health_file,
status="healthy",
components={
"screen_analyzer": True,
"clip_embedder": True,
"faiss_manager": True,
"state_embedding_builder": True,
},
stats={
"sessions_processed": 3,
"sessions_failed": 0,
"sessions_skipped": 0,
"total_screenshots_analyzed": 42,
},
)
status = api_stream._get_worker_queue_status()
assert status["status"] == "healthy", status
assert status["armed"] is False
assert status["components_ready"] is True
assert status["processing_ready"] is True

View File

@@ -296,9 +296,11 @@ def test_export_workflow_with_t2a_chain():
]
core = convert_vwb_to_core_workflow(workflow_data, steps_data)
edge_types = [e["action"]["type"] for e in core["edges"]]
assert len(core["edges"]) == len(steps_data)
assert "extract_text" in edge_types
assert "t2a_decision" in edge_types
assert "pause_for_human" in edge_types
assert edge_types[-1] == "mouse_click"
# Vérifier que le templating est bien transporté
t2a_edge = next(e for e in core["edges"] if e["action"]["type"] == "t2a_decision")
assert t2a_edge["action"]["parameters"]["input_template"] == "{{dpi}}"

View File

@@ -0,0 +1,44 @@
"""TDD — résolution de l'URL serveur d'enrôlement depuis system_config.json.
Câble l'éditeur adresses/ports du dashboard (`services.streaming`) vers le
`RPA_SERVER_URL` généré pour chaque agent Léa.
Priorité : config (`system_config.json`) > variable d'env > défaut.
Un host loopback/vide dans la config = « non configuré » → fallback env, pour
ne PAS régresser le déploiement actuel où l'URL vient de l'environnement.
"""
import os
# L'import du dashboard est fail-closed sur l'auth → escape dev/test documenté.
os.environ.setdefault("DASHBOARD_AUTH_DISABLED", "true")
from web_dashboard import app as dash # noqa: E402
def test_resolve_url_from_config_streaming_host(monkeypatch):
"""La config (host streaming édité dans l'UI) prime, même si l'env existe."""
monkeypatch.setattr(
dash, "load_system_config",
lambda: {"services": {"streaming": {"host": "192.168.1.178", "port": 5005}}},
)
monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.45:5005/api/v1")
assert dash._resolve_public_server_url() == "http://192.168.1.178:5005/api/v1"
def test_resolve_url_loopback_in_config_falls_back_to_env(monkeypatch):
"""host=localhost dans la config = non configuré → on garde l'env (pas de régression)."""
monkeypatch.setattr(
dash, "load_system_config",
lambda: {"services": {"streaming": {"host": "localhost", "port": 5005}}},
)
monkeypatch.delenv("RPA_PUBLIC_URL", raising=False)
monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.45:5005/api/v1")
assert dash._resolve_public_server_url() == "http://192.168.1.45:5005/api/v1"
def test_resolve_url_no_config_uses_env(monkeypatch):
"""Aucune section streaming → fallback env, normalisé en /api/v1."""
monkeypatch.setattr(dash, "load_system_config", lambda: {"services": {}})
monkeypatch.delenv("RPA_PUBLIC_URL", raising=False)
monkeypatch.setenv("RPA_SERVER_URL", "http://10.0.0.5:5005/api/v1")
assert dash._resolve_public_server_url() == "http://10.0.0.5:5005/api/v1"

View File

@@ -114,6 +114,37 @@ class TestDashboardRoutes:
'timeout': 30,
}]
def test_streaming_status_snapshot_aggregates_existing_endpoints(self, monkeypatch):
"""Le proxy legacy /status agrège les endpoints streaming reels."""
calls = []
def fake_fetch(endpoint, query_string=''):
calls.append((endpoint, query_string))
if endpoint == 'stats':
return {'active_sessions': 1, 'total_events': 7}
if endpoint == 'sessions':
return {'sessions': [{'session_id': 's1'}]}
if endpoint == 'processing/status':
return {'status': 'degraded', 'components_ready': False}
if endpoint == 'replays':
return {'replays': [{'replay_id': 'r1', 'active': True}]}
raise AssertionError(endpoint)
monkeypatch.setattr(dashboard_app, '_fetch_streaming_json', fake_fetch)
snapshot = dashboard_app._streaming_status_snapshot()
assert snapshot['active_sessions'] == 1
assert snapshot['sessions'] == [{'session_id': 's1'}]
assert snapshot['processing']['status'] == 'degraded'
assert snapshot['replay']['replay_id'] == 'r1'
assert calls == [
('stats', ''),
('sessions', ''),
('processing/status', ''),
('replays', ''),
]
def test_dashboard_submit_competence_verdict(self, client, monkeypatch):
"""Le dashboard journalise un verdict sans write-back YAML."""
import core.competences.verdicts as verdicts_module
@@ -212,6 +243,58 @@ class TestDashboardRoutes:
data = resp.get_json()
assert 'workflows' in data
def test_workflows_list_reads_vwb_db(self, client, monkeypatch, tmp_path):
"""Régression red-gate : /api/workflows reflète la base VWB v3, pas 0.
Avant correctif l'endpoint globait un store JSON vide et renvoyait
toujours total:0. On construit une DB VWB minimale (schéma canonique
workflows + steps) et on vérifie que l'endpoint expose le compte réel.
"""
import sqlite3
from pathlib import Path
db_path = tmp_path / "instance" / "workflows.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.execute(
"CREATE TABLE workflows (id VARCHAR(64) PRIMARY KEY, name VARCHAR(255), "
"description TEXT, created_at DATETIME, updated_at DATETIME, "
"is_active BOOLEAN, source VARCHAR(64), review_status VARCHAR(32))"
)
conn.execute(
"CREATE TABLE steps (id VARCHAR(64) PRIMARY KEY, workflow_id VARCHAR(64), "
"action_type VARCHAR(64))"
)
conn.execute(
"INSERT INTO workflows VALUES (?,?,?,?,?,?,?,?)",
("wf_aiva", "Urgence_aiva_demo", "demo", "2026-06-01", "2026-06-18",
1, "manual", ""),
)
conn.execute(
"INSERT INTO workflows VALUES (?,?,?,?,?,?,?,?)",
("wf_learned", "Learned_flow", "", "2026-06-02", "2026-06-17",
1, "learned_import", "pending"),
)
# 3 steps pour wf_aiva → nodes_count attendu = 3
for i in range(3):
conn.execute(
"INSERT INTO steps VALUES (?,?,?)", (f"s{i}", "wf_aiva", "click")
)
conn.commit()
conn.close()
monkeypatch.setattr(dashboard_app, "VWB_DB_PATH", Path(db_path))
resp = client.get('/api/workflows')
assert resp.status_code == 200
data = resp.get_json()
assert data['total'] == 2, f"attendu 2 workflows, obtenu {data['total']}"
names = {w['name'] for w in data['workflows']}
assert 'Urgence_aiva_demo' in names
aiva = next(w for w in data['workflows'] if w['name'] == 'Urgence_aiva_demo')
assert aiva['nodes_count'] == 3
assert aiva['source'] == 'manual'
def test_sessions_list(self, client):
"""L'API sessions retourne la liste."""
resp = client.get('/api/agent/sessions')

View File

@@ -105,6 +105,26 @@ def test_resolve_auto_cuda_when_under_total_cap(monkeypatch):
max_total_gb=6.0) == "cuda"
# ── mémoire unifiée / grosse carte (DGX GB10) : plafond inapplicable ─────────
def test_resolve_auto_cuda_on_unified_memory_ignores_total_cap(monkeypatch):
"""Mémoire unifiée GB10 : total=121, free=22 → used=99 > cap 6, MAIS total
> seuil grosse mémoire (24) → plafond ignoré, free 22 ≥ min 2 → CUDA.
Sans ce comportement, le DGX tomberait à tort sur CPU (régression observée
au bench GB10 2026-06-08)."""
monkeypatch.delenv("RPA_VISION_DEVICE", raising=False)
with _mock_cuda(available=True, free_gb=22.0, total_gb=121.0):
assert device_policy.resolve_device("auto", min_free_gb=2.0,
max_total_gb=6.0) == "cuda"
def test_resolve_auto_cpu_on_large_memory_when_free_too_low(monkeypatch):
"""Grosse mémoire mais free < min → CPU (free reste le garde-fou réel)."""
monkeypatch.delenv("RPA_VISION_DEVICE", raising=False)
with _mock_cuda(available=True, free_gb=1.0, total_gb=121.0):
assert device_policy.resolve_device("auto", min_free_gb=2.0) == "cpu"
# ── override env RPA_VISION_DEVICE ──────────────────────────────────────────
def test_env_override_cpu_forces_cpu_even_in_auto(monkeypatch):

View File

@@ -0,0 +1,133 @@
"""DETTE-019 — confiance grounding RÉELLE (dérivée) vs score figé 0.85.
Constat (confirmé par QG Qwen 2026-06-15) : le grounding VLM n'a PAS de confiance
modèle native (prompt = {"x","y"}, pas de logprob exploitable). La seule confiance
RÉELLE disponible est **sémantique/contextuelle** : le texte cible est-il bien à la
position trouvée ? On la dérive via `_validate_text_at_position` (même garde que le
pré-check aval). Approche validée par Dom (2026-06-15).
Contrat :
- le score n'est PLUS la constante 0.85 ; il VARIE selon la vérif sémantique ;
- texte confirmé à la position → score haut (≥ seuil 0.60, accepté) ;
- texte absent → score bas (< 0.60) → rejeté par `_validate_resolution_quality`
(`rejected_low_score_grounding`) ;
- `score == matched_element["confidence"]` (cohérence) ;
- `method="grounding"` reste gardée par `_RESOLUTION_MIN_SCORES`.
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _make_vllm_post(captured: list):
"""vLLM renvoie un point Qwen3-VL 0-1000 centré (500,500) → (0.5, 0.5)."""
def fake_post(url, json=None, timeout=None):
captured.append({"url": url, "payload": json})
resp = MagicMock()
if "/v1/chat/completions" in url:
resp.ok = True
resp.json.return_value = {
"choices": [{"message": {"content": '{"x": 500, "y": 500}'}}]
}
else:
resp.ok = False
resp.json.return_value = {"message": {"content": ""}}
return resp
return fake_post
def _resolve_with_text_validation(monkeypatch, tmp_path, is_valid: bool):
"""Lance _resolve_by_grounding (mode qwen3vl) en forçant le verdict OCR
sémantique (`_validate_text_at_position`) à `is_valid`."""
from PIL import Image
shot = tmp_path / "shot.png"
Image.new("RGB", (200, 120), (255, 255, 255)).save(shot)
monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm")
import requests
monkeypatch.setattr(requests, "post", _make_vllm_post([]))
from agent_v0.server_v1 import resolve_engine as re_module
# Forcer le signal sémantique (pas de vrai OCR en unit).
monkeypatch.setattr(
re_module, "_validate_text_at_position",
lambda *a, **k: (is_valid, "Synthèse" if is_valid else "", 1.0),
)
result = re_module._resolve_by_grounding(
screenshot_path=str(shot),
target_spec={"by_text": "Synthèse", "by_text_source": "ocr"},
screen_width=200,
screen_height=120,
)
return re_module, result
@pytest.mark.unit
def test_dette019_score_varie_selon_verif_semantique(monkeypatch, tmp_path):
"""Le score n'est plus une constante : texte confirmé ≠ texte absent."""
_, res_ok = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=True)
_, res_ko = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=False)
assert res_ok is not None and res_ko is not None
assert res_ok["score"] != res_ko["score"], (
f"score identique ({res_ok['score']}) → toujours figé, DETTE-019 non corrigée"
)
@pytest.mark.unit
def test_dette019_texte_present_score_accepte(monkeypatch, tmp_path):
"""Texte confirmé à la position → score ≥ seuil 0.60 (chemin nominal accepté)."""
re_module, res = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=True)
assert res is not None
assert res["score"] >= 0.60, f"score={res['score']} < 0.60 alors que texte confirmé"
out = re_module._validate_resolution_quality(res, 0.0, 0.0)
assert out.get("resolved") is True, "grounding confirmé rejeté à tort par le validateur"
@pytest.mark.unit
def test_dette019_texte_absent_score_bas_rejete(monkeypatch, tmp_path):
"""Texte absent à la position → score < 0.60 → rejeté par le validateur."""
re_module, res = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=False)
assert res is not None
assert res["score"] < 0.60, (
f"score={res['score']} ≥ 0.60 alors que texte ABSENT → garde-seuil inopérant (DETTE-019)"
)
out = re_module._validate_resolution_quality(res, 0.0, 0.0)
assert out["resolved"] is False
assert out["method"] == "rejected_low_score_grounding"
@pytest.mark.unit
def test_dette019_score_egal_confidence(monkeypatch, tmp_path):
"""Cohérence interne : score == matched_element.confidence."""
_, res = _resolve_with_text_validation(monkeypatch, tmp_path, is_valid=True)
assert res is not None
assert res["score"] == res["matched_element"]["confidence"]
@pytest.mark.unit
def test_dette019_sans_by_text_score_neutre_au_dessus_seuil(monkeypatch, tmp_path):
"""Sans texte vérifiable (grounding par vlm_description) → confiance neutre,
au-dessus du seuil (comportement non régressé, pas de faux rejet)."""
from PIL import Image
shot = tmp_path / "shot.png"
Image.new("RGB", (200, 120), (255, 255, 255)).save(shot)
monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm")
import requests
monkeypatch.setattr(requests, "post", _make_vllm_post([]))
from agent_v0.server_v1 import resolve_engine as re_module
res = re_module._resolve_by_grounding(
screenshot_path=str(shot),
target_spec={"vlm_description": "le bouton de validation"}, # pas de by_text
screen_width=200,
screen_height=120,
)
assert res is not None
assert res["score"] >= 0.60, f"score neutre={res['score']} < seuil → faux rejet sans by_text"

View File

@@ -0,0 +1,177 @@
"""Câblage resolve_engine ← Qwen3-VL-4B/vLLM (grounder POC validé 0.933, nuit 12→13/06).
Contrat (approche A, env-gated, défaut OFF) : quand RPA_GROUNDING_ENGINE=qwen3vl_vllm,
`_resolve_by_grounding` doit :
1. parser les coordonnées Qwen3-VL en 0-1000 (divisor=1000), PAS en pixels image
→ dissout DETTE-006 (résolution-indépendant) ;
2. poser think=false dans le payload vLLM (chat_template_kwargs.enable_thinking=False) ;
3. émettre une `method` GARDÉE par _RESOLUTION_MIN_SCORES (sinon Check-1 du
validateur est sauté → clic non-gardé).
Réf design : inbox_codex/2026-06-13_0210_claude-to-codex_DESIGN-CABLAGE-RESOLVE-ENGINE-QWEN3VL.md
Le 0.933 est une propriété de (modèle+moteur+prompt+parser+think), pas juste (modèle+moteur).
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _gated(method: str, table: dict) -> bool:
"""Reproduit la logique de matching de _validate_resolution_quality (:2751)."""
if method in table:
return True
return any(p.endswith("_") and method.startswith(p) for p in table)
def _make_vllm_post(captured: list):
"""Mock requests.post : vLLM renvoie un bbox Qwen3-VL 0-1000 centré (500,500)."""
def fake_post(url, json=None, timeout=None):
captured.append({"url": url, "payload": json})
resp = MagicMock()
if "/v1/chat/completions" in url:
resp.ok = True
# Qwen3-VL : coordonnées normalisées 0-1000. Centre = (500,500).
resp.json.return_value = {
"choices": [{"message": {"content": '{"bbox_2d": [490, 490, 510, 510]}'}}]
}
else: # Ollama fallback ne doit pas être atteint dans ce mode
resp.ok = False
resp.json.return_value = {"message": {"content": ""}}
return resp
return fake_post
@pytest.mark.unit
def test_qwen3vl_vllm_grounding_normalise_0_1000_et_method_gardee(monkeypatch, tmp_path):
from PIL import Image
# Image volontairement petite (200x120) : si le code divisait par les
# pixels image au lieu de 1000, le centre 500 → 500/200 = 2.5 (hors bornes,
# → None). C'est ce qui rend ce test RED sur le code actuel.
shot = tmp_path / "shot.png"
Image.new("RGB", (200, 120), (255, 255, 255)).save(shot)
monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm")
captured: list = []
import requests
monkeypatch.setattr(requests, "post", _make_vllm_post(captured))
from agent_v0.server_v1 import resolve_engine as re_module
result = re_module._resolve_by_grounding(
screenshot_path=str(shot),
target_spec={"by_text": "Synthèse"},
screen_width=200,
screen_height=120,
)
# (1) résolu et normalisé par /1000 → centre ~ (0.5, 0.5)
assert result is not None, "Résolution None : coords Qwen3-VL 0-1000 mal normalisées (DETTE-006)"
assert abs(result["x_pct"] - 0.5) < 0.02, f"x_pct={result['x_pct']} (attendu ~0.5 via /1000)"
assert abs(result["y_pct"] - 0.5) < 0.02, f"y_pct={result['y_pct']} (attendu ~0.5 via /1000)"
# (3) method gardée par le seuil
assert _gated(result["method"], re_module._RESOLUTION_MIN_SCORES), (
f"method {result['method']!r} non gardée → Check-1 du validateur sauté (clic non-gardé)"
)
@pytest.mark.unit
def test_qwen3vl_vllm_payload_think_false(monkeypatch, tmp_path):
from PIL import Image
shot = tmp_path / "shot.png"
Image.new("RGB", (200, 120), (255, 255, 255)).save(shot)
monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm")
captured: list = []
import requests
monkeypatch.setattr(requests, "post", _make_vllm_post(captured))
from agent_v0.server_v1 import resolve_engine as re_module
re_module._resolve_by_grounding(
screenshot_path=str(shot),
target_spec={"by_text": "Synthèse"},
screen_width=200,
screen_height=120,
)
vllm = [c for c in captured if "/v1/chat/completions" in c["url"]]
assert vllm, "Aucun appel vLLM capturé"
payload = vllm[0]["payload"]
# think=false : pour vLLM via chat_template_kwargs.enable_thinking=False
cek = payload.get("chat_template_kwargs", {})
assert cek.get("enable_thinking") is False, (
f"think non désactivé dans payload vLLM : chat_template_kwargs={cek} "
f"(Qwen3-VL penserait → grounding inutilisable, cf. bench)"
)
@pytest.mark.unit
def test_qwen3vl_vllm_prompt_demande_fractions_0_1(monkeypatch, tmp_path):
"""Fidélité au tuple validé (0.933) : le prompt qwen3vl demande un point de
clic en FRACTIONS 0-1 (format {"x","y"}), pas un 'bounding box' générique."""
from PIL import Image
shot = tmp_path / "shot.png"
Image.new("RGB", (200, 120), (255, 255, 255)).save(shot)
monkeypatch.setenv("RPA_GROUNDING_ENGINE", "qwen3vl_vllm")
captured: list = []
import requests
monkeypatch.setattr(requests, "post", _make_vllm_post(captured))
from agent_v0.server_v1 import resolve_engine as re_module
re_module._resolve_by_grounding(
screenshot_path=str(shot),
target_spec={"by_text": "Synthèse"},
screen_width=200,
screen_height=120,
)
vllm = [c for c in captured if "/v1/chat/completions" in c["url"]]
assert vllm, "Aucun appel vLLM capturé"
user_txt = ""
for m in vllm[0]["payload"]["messages"]:
c = m.get("content")
if isinstance(c, list):
user_txt += " ".join(p.get("text", "") for p in c if isinstance(p, dict))
elif isinstance(c, str):
user_txt += " " + c
assert "Synthèse" in user_txt, "cible non injectée dans le prompt"
low = user_txt.lower()
assert "fraction" in low or ("0.0" in user_txt and "1.0" in user_txt), (
f"prompt qwen3vl ne demande pas un point 0-1 (tuple validé non reproduit) : {user_txt!r}"
)
@pytest.mark.unit
def test_qwen3vl_vllm_method_grounding_rejetee_si_score_bas():
"""Le method qwen3vl doit activer le garde-seuil, pas sauter Check-1."""
from agent_v0.server_v1 import resolve_engine as re_module
out = re_module._validate_resolution_quality(
{
"resolved": True,
"method": "grounding",
"score": 0.10,
"x_pct": 0.50,
"y_pct": 0.50,
},
0.50,
0.50,
)
assert out is not None
assert out["resolved"] is False
assert out["method"] == "rejected_low_score_grounding"
assert out["original_method"] == "grounding"

View File

@@ -0,0 +1,106 @@
"""WP-C Patch 2 — génération d'un token par poste à l'enrôlement (TDD).
À chaque enrôlement (création OU réactivation), le registre génère un token
unique (`secrets.token_hex(32)`), persiste UNIQUEMENT son empreinte SHA-256 dans
`token_hash`, renseigne `token_issued_at`, et retourne le token clair une seule
fois dans le résultat de `enroll`. Le clair n'est jamais persisté ni journalisé.
Auth runtime inchangée : aucun branchement sur `api_stream._verify_token` (ce
sera l'objet de patchs WP-C ultérieurs). Voir PLAN-WPC-TDD-EXECUTABLE / DETTE-015.
"""
from __future__ import annotations
import hashlib
import logging
import sqlite3
import pytest
from agent_v0.server_v1.agent_registry import AgentRegistry
@pytest.fixture
def registry(tmp_path):
return AgentRegistry(db_path=tmp_path / "fleet.db")
def _sha256(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()
def _persisted_row(db_path, machine_id):
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
row = conn.execute(
"SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,)
).fetchone()
return dict(row) if row else None
finally:
conn.close()
def test_enroll_returns_clear_token(registry):
"""L'enrôlement retourne un token clair (hex, 64 caractères)."""
res = registry.enroll(machine_id="PC-1")
token = res["token"]
assert isinstance(token, str)
assert len(token) == 64
int(token, 16) # doit être hexadécimal valide
def test_token_unique_per_enroll(registry):
"""Deux enrôlements distincts produisent deux tokens différents (critère 1)."""
t1 = registry.enroll(machine_id="PC-1")["token"]
t2 = registry.enroll(machine_id="PC-2")["token"]
assert t1 != t2
def test_clear_token_not_persisted(registry, tmp_path):
"""Le token clair n'est jamais persisté en base (critère 3)."""
token = registry.enroll(machine_id="PC-1")["token"]
row = _persisted_row(tmp_path / "fleet.db", "PC-1")
for value in row.values():
assert value != token
def test_token_hash_persisted_as_sha256(registry, tmp_path):
"""token_hash persisté = SHA-256 du clair (critère 4)."""
token = registry.enroll(machine_id="PC-1")["token"]
row = _persisted_row(tmp_path / "fleet.db", "PC-1")
assert row["token_hash"] == _sha256(token)
def test_token_issued_at_set(registry, tmp_path):
"""token_issued_at est renseigné à l'enrôlement (critère 5)."""
registry.enroll(machine_id="PC-1")
row = _persisted_row(tmp_path / "fleet.db", "PC-1")
assert row["token_issued_at"] # non NULL / non vide
def test_get_never_exposes_clear_token(registry):
"""get() ne renvoie jamais le token clair (retourné une seule fois — critère 2)."""
token = registry.enroll(machine_id="PC-1")["token"]
agent = registry.get("PC-1")
assert "token" not in agent # pas de clé clair persistée
for value in agent.values():
assert value != token
def test_reactivation_rotates_token(registry, tmp_path):
"""La réactivation génère un nouveau token + nouveau hash (critère 1)."""
t1 = registry.enroll(machine_id="PC-1")["token"]
h1 = _persisted_row(tmp_path / "fleet.db", "PC-1")["token_hash"]
registry.uninstall(machine_id="PC-1", reason="user_uninstall")
t2 = registry.enroll(machine_id="PC-1")["token"]
h2 = _persisted_row(tmp_path / "fleet.db", "PC-1")["token_hash"]
assert t2 != t1
assert h2 != h1
assert h2 == _sha256(t2)
def test_clear_token_never_logged(registry, caplog):
"""Le token clair n'apparaît jamais dans les logs (critère 6)."""
with caplog.at_level(logging.DEBUG):
token = registry.enroll(machine_id="PC-1")["token"]
assert token not in caplog.text

View File

@@ -0,0 +1,92 @@
"""WP-C Patch 1 — migration additive idempotente des colonnes « token par poste ».
Ajoute `token_hash` et `token_issued_at` à la table `enrolled_agents`, sans
casser les bases existantes ni perdre de données. Comportement runtime inchangé :
les colonnes restent inertes tant que l'auth par poste n'est pas branchée
(patchs WP-C ultérieurs). Voir DETTE-015 / PLAN-WPC-TDD-EXECUTABLE.
"""
from __future__ import annotations
import sqlite3
from agent_v0.server_v1.agent_registry import AgentRegistry
def _columns(db_path) -> set[str]:
conn = sqlite3.connect(str(db_path))
try:
rows = conn.execute("PRAGMA table_info(enrolled_agents)").fetchall()
return {r[1] for r in rows}
finally:
conn.close()
def test_token_columns_present_after_init(tmp_path):
"""Une base neuve doit contenir les colonnes token dès l'init."""
db = tmp_path / "fleet.db"
AgentRegistry(db_path=db)
cols = _columns(db)
assert "token_hash" in cols
assert "token_issued_at" in cols
def test_token_columns_idempotent(tmp_path):
"""Ré-initialiser plusieurs fois ne doit jamais lever (migration idempotente)."""
db = tmp_path / "fleet.db"
AgentRegistry(db_path=db)
AgentRegistry(db_path=db)
AgentRegistry(db_path=db)
cols = _columns(db)
assert "token_hash" in cols
assert "token_issued_at" in cols
def test_migration_preserves_existing_rows(tmp_path):
"""Une base ancienne (sans les colonnes) est migrée sans perte de données."""
db = tmp_path / "fleet.db"
# Ancien schéma, sans les colonnes token, avec une ligne existante.
conn = sqlite3.connect(str(db))
conn.execute(
"""
CREATE TABLE enrolled_agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id TEXT NOT NULL UNIQUE,
user_name TEXT,
user_email TEXT,
user_id TEXT,
hostname TEXT,
os_info TEXT,
version TEXT,
status TEXT NOT NULL DEFAULT 'active',
enrolled_at TEXT NOT NULL,
last_seen_at TEXT,
uninstalled_at TEXT,
uninstall_reason TEXT
)
"""
)
conn.execute(
"INSERT INTO enrolled_agents (machine_id, status, enrolled_at) "
"VALUES ('PC-LEGACY', 'active', '2026-01-01T00:00:00+00:00')"
)
conn.commit()
conn.close()
# Le démarrage du registry doit migrer la base existante.
AgentRegistry(db_path=db)
cols = _columns(db)
assert "token_hash" in cols
assert "token_issued_at" in cols
conn = sqlite3.connect(str(db))
try:
row = conn.execute(
"SELECT machine_id, token_hash FROM enrolled_agents "
"WHERE machine_id = 'PC-LEGACY'"
).fetchone()
finally:
conn.close()
assert row is not None
assert row[0] == "PC-LEGACY"
assert row[1] is None # colonne ajoutée, NULL par défaut

View File

@@ -0,0 +1,63 @@
"""WP-C Patch 3 — vérification d'un token poste (TDD).
`AgentRegistry.verify_token(token)` retourne le `machine_id` de l'agent **actif**
dont l'empreinte SHA-256 correspond, ou `None`. Comparaison à temps constant
(`hmac.compare_digest`). Un agent désinstallé/révoqué est refusé, et la rotation
(réenrôlement) invalide l'ancien token.
Méthode INERTE : non branchée sur l'auth runtime (ce sera le Patch 4, derrière
flag). Voir PLAN-WPC-TDD-EXECUTABLE.
"""
from __future__ import annotations
import pytest
from agent_v0.server_v1.agent_registry import AgentRegistry
@pytest.fixture
def registry(tmp_path):
return AgentRegistry(db_path=tmp_path / "fleet.db")
def test_verify_token_accepts_valid(registry):
"""Un token issu d'un enrôlement actif est reconnu (→ machine_id)."""
token = registry.enroll(machine_id="PC-1")["token"]
assert registry.verify_token(token) == "PC-1"
def test_verify_token_rejects_unknown(registry):
"""Un token inconnu est refusé (→ None)."""
registry.enroll(machine_id="PC-1")
assert registry.verify_token("deadbeef" * 8) is None
def test_verify_token_rejects_empty(registry):
"""Token vide ou None → None (pas d'exception)."""
registry.enroll(machine_id="PC-1")
assert registry.verify_token("") is None
assert registry.verify_token(None) is None
def test_verify_token_rejects_after_uninstall(registry):
"""Après désinstallation, le token n'est plus accepté (agent non actif)."""
token = registry.enroll(machine_id="PC-1")["token"]
registry.uninstall(machine_id="PC-1", reason="user_uninstall")
assert registry.verify_token(token) is None
def test_verify_token_rotation_invalidates_old(registry):
"""La réactivation génère un nouveau token ; l'ancien est invalidé."""
t1 = registry.enroll(machine_id="PC-1")["token"]
registry.uninstall(machine_id="PC-1", reason="user_uninstall")
t2 = registry.enroll(machine_id="PC-1")["token"]
assert registry.verify_token(t2) == "PC-1"
assert registry.verify_token(t1) is None
def test_verify_token_distinguishes_agents(registry):
"""Chaque token actif ne reconnaît que son propre poste."""
t1 = registry.enroll(machine_id="PC-1")["token"]
t2 = registry.enroll(machine_id="PC-2")["token"]
assert registry.verify_token(t1) == "PC-1"
assert registry.verify_token(t2) == "PC-2"

View File

@@ -330,7 +330,8 @@ def import_learned_workflow(workflow_id: str):
# Extraire et sauvegarder le screenshot d'ancre si présent
anchor_b64 = params.pop("_anchor_image_base64", None)
params.pop("_anchor_bbox", None)
# NE PAS supprimer _anchor_bbox : on le conserve dans params pour que le frontend puisse lire x_pct/y_pct
# et afficher la zone ciblée, au lieu de le jeter et de créer une bbox factice.
if anchor_b64:
try:
from services.anchor_image_service import (
@@ -344,6 +345,8 @@ def import_learned_workflow(workflow_id: str):
anchor_b64 = anchor_b64.split(',', 1)[1]
img_data = b64mod.b64decode(anchor_b64)
img = Image.open(BytesIO(img_data))
# Fallback sécurisé pour le service de crop si _anchor_bbox n'a pas le format attendu,
# mais les données x_pct/y_pct restent intactes dans params pour le frontend.
bbox = {
"x": 0, "y": 0,
"width": img.width, "height": img.height

View File

@@ -28,6 +28,109 @@ load_dotenv() # fallback .env dans cwd (n'écrase pas les vars déjà définies
# Initialize Flask app
app = Flask(__name__)
# ============================================================
# HTTP Basic Auth LAN (cohérent avec le dashboard 5001)
# ============================================================
# Le VWB (backend 5002) était exposé au LAN SANS authentification. On ajoute
# un middleware before_request qui exige un header Authorization: Basic <b64>
# pour toute requête NON-loopback (LAN), avec les MÊMES credentials que le
# dashboard : DASHBOARD_USER / DASHBOARD_PASSWORD (dans .env.local).
#
# GARDE-FOU CRITIQUE — exemption loopback :
# Le dashboard (agent_chat/app.py `_fetch_vwb_workflows`) et les healthchecks
# appellent ce backend en boucle locale (http://localhost:5002 → 127.0.0.1).
# Exiger l'auth en loopback CASSERAIT l'intégration dashboard↔VWB. On exempte
# donc 127.0.0.1 / ::1 (et ::ffff:127.0.0.1) de toute auth.
#
# Différence assumée avec le dashboard (fail-closed) : ici on NE crashe PAS si
# DASHBOARD_PASSWORD est absent. On log un warning et on laisse passer le LAN
# (mode POC dev/dégradé). En clinique, DASHBOARD_PASSWORD est défini dans
# .env.local (chargé ci-dessus, lignes 24-26) → l'auth LAN est effective.
import base64 as _base64
import hmac as _hmac
_VWB_AUTH_USER = os.getenv("DASHBOARD_USER", "lea").strip()
_VWB_AUTH_PASSWORD = os.getenv("DASHBOARD_PASSWORD", "").strip()
# Désactivation explicite (dev/tests, parité avec le dashboard).
_VWB_AUTH_DISABLED = os.getenv("DASHBOARD_AUTH_DISABLED", "").lower() in (
"1", "true", "yes",
)
# Adresses considérées comme loopback (server-to-server, jamais challengées).
_VWB_LOOPBACK_ADDRS = {"127.0.0.1", "::1", "::ffff:127.0.0.1"}
# Paths publics (pas d'auth) — healthchecks systemd / NPM / smokes.
_VWB_PUBLIC_PATHS = {"/health", "/api/health"}
if not _VWB_AUTH_PASSWORD and not _VWB_AUTH_DISABLED:
logging.getLogger("vwb.auth").warning(
"[SECURITE] DASHBOARD_PASSWORD non defini : l'auth Basic LAN du VWB "
"(5002) est INACTIVE (le LAN passe sans credentials). Definir "
"DASHBOARD_PASSWORD dans .env.local pour l'activer (cible clinique)."
)
def _vwb_auth_ok(header_value: str) -> bool:
"""Valide le header Authorization Basic. Comparaison constant-time.
Logique identique au dashboard (`web_dashboard/app.py::_dashboard_auth_ok`).
"""
if not header_value or not header_value.lower().startswith("basic "):
return False
try:
decoded = _base64.b64decode(header_value[6:].strip()).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return False
if ":" not in decoded:
return False
user, _, password = decoded.partition(":")
user_ok = _hmac.compare_digest(user, _VWB_AUTH_USER)
pwd_ok = _hmac.compare_digest(password, _VWB_AUTH_PASSWORD)
return user_ok and pwd_ok
@app.before_request
def _vwb_basic_auth_middleware():
"""Middleware d'auth HTTP Basic LAN sur le backend VWB (port 5002).
- Bypass total si DASHBOARD_AUTH_DISABLED=true (dev/tests).
- Bypass total si DASHBOARD_PASSWORD absent (mode POC degrade, warning emis
au demarrage) — on ne casse pas le service faute de secret.
- Loopback (127.0.0.1 / ::1) : JAMAIS challenge (proxy dashboard, healthcheck).
- Preflight CORS (OPTIONS) : laisse passer (le navigateur n'envoie pas
l'en-tete Authorization au preflight).
- Paths publics (_VWB_PUBLIC_PATHS) : healthchecks externes.
- Sinon (requete LAN) : header Authorization: Basic <b64> obligatoire, sinon 401.
"""
from flask import request, Response
# Dev / tests / mode degrade sans secret : bypass total
if _VWB_AUTH_DISABLED or not _VWB_AUTH_PASSWORD:
return None
# Preflight CORS : pas d'auth (le navigateur n'envoie pas les credentials)
if request.method == "OPTIONS":
return None
# Exemption loopback (server-to-server : dashboard, healthcheck)
if (request.remote_addr or "") in _VWB_LOOPBACK_ADDRS:
return None
# Paths publics (healthchecks externes)
if (request.path or "/") in _VWB_PUBLIC_PATHS:
return None
if _vwb_auth_ok(request.headers.get("Authorization", "")):
return None
# Pas authentifie — challenge 401 avec WWW-Authenticate
return Response(
'{"error": "authentication required"}',
status=401,
mimetype="application/json",
headers={"WWW-Authenticate": 'Basic realm="RPA Vision V3 VWB"'},
)
# ============================================================
# Logging — fichier rotatif + console (idempotent)
# ============================================================
@@ -443,9 +546,10 @@ if __name__ == '__main__':
# Désactivation du mode debug pour stabiliser le laboratoire
debug = False
import os as _os
socketio.run(
app,
host='0.0.0.0',
host=_os.environ.get('RPA_BIND_HOST', '127.0.0.1'),
port=port,
debug=False,
use_reloader=False,

View File

@@ -166,11 +166,30 @@ def convert_learned_to_vwb_steps(
)
continue
# Image d'ancre du parent : à poser sur le 1er substep cliquable.
# Les actions simples l'obtiennent déjà (branche `else` plus bas) ;
# les compound ne la propageaient pas → substeps anchor_id NULL.
compound_anchor_b64 = (
target.get("anchor_image_base64")
or target.get("screenshot")
or (target.get("context_hints") or {}).get("anchor_image_base64")
)
compound_anchor_attached = False
for sub_idx, sub in enumerate(sub_steps):
sub_type = sub.get("type", "unknown")
sub_vwb_type, sub_params = _convert_compound_substep(
sub_type, sub, target
)
# Poser l'ancre du parent sur le 1er substep cliquable uniquement
# (éviter de la dupliquer sur les N substeps).
if (
compound_anchor_b64
and not compound_anchor_attached
and sub_vwb_type in ("click_anchor", "double_click_anchor", "right_click_anchor")
):
sub_params["_anchor_image_base64"] = compound_anchor_b64
compound_anchor_attached = True
label = _build_step_label(sub_vwb_type, sub_params, from_name, to_name)
steps.append({
"action_type": sub_vwb_type,
@@ -226,6 +245,7 @@ def convert_learned_to_vwb_steps(
anchor_b64 = (
target.get("anchor_image_base64")
or target.get("screenshot")
or (target.get("context_hints") or {}).get("anchor_image_base64")
or action_params.get("anchor_image_base64")
)
if anchor_b64:
@@ -479,21 +499,24 @@ def convert_vwb_to_core_workflow(
now = datetime.now().isoformat()
wf_id = workflow_data.get("id", f"wf_{uuid.uuid4().hex[:12]}")
# Créer les nodes : un node par étape (chaque étape = un état écran)
# Les actions sont portées par les edges. N étapes VWB doivent donc donner
# N edges core, et N+1 états écran (avant chaque action + terminal).
nodes = []
edges = []
for idx, step in enumerate(steps_data):
node_count = len(steps_data) + 1 if steps_data else 0
for idx in range(node_count):
step = steps_data[idx] if idx < len(steps_data) else {}
node_id = f"node_{idx:03d}"
action_type = step.get("action_type", "click_anchor")
params = step.get("parameters", {})
label = step.get("label", action_type)
label = step.get("label", action_type) if idx < len(steps_data) else "Fin du workflow"
# Créer le node (template minimal)
node = {
"node_id": node_id,
"name": label,
"description": f"Étape {idx + 1} : {label}",
"description": f"Étape {idx + 1} : {label}" if idx < len(steps_data) else "État terminal",
"template": {
"window": {
"title_pattern": params.get("window_title"),
@@ -517,7 +540,7 @@ def convert_vwb_to_core_workflow(
},
},
"is_entry": idx == 0,
"is_end": idx == len(steps_data) - 1,
"is_end": idx == node_count - 1,
"variants": [],
"primary_variant_id": None,
"max_variants": 5,
@@ -528,12 +551,13 @@ def convert_vwb_to_core_workflow(
"metadata": {
"vwb_step_id": step.get("id", ""),
"visual_type": _action_type_to_visual(action_type),
"terminal": idx >= len(steps_data),
},
}
nodes.append(node)
# Créer l'edge vers le node suivant (sauf pour le dernier)
if idx < len(steps_data) - 1:
# Créer un edge/action pour chaque step VWB, y compris la dernière.
if idx < len(steps_data):
next_node_id = f"node_{idx + 1:03d}"
# Convertir l'action VWB → action core

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Test TDD — propagation de l'image d'ancre aux substeps *compound* (SP-1 / U-B).
Bug : `convert_learned_to_vwb_steps` ne propage `anchor_image_base64` qu'aux
actions simples (branche L226-233). Les substeps des actions *compound*
(majoritaires côté Léa) passent par `_convert_compound_substep` qui ne lit
jamais l'ancre → `anchor_id NULL` → "Ancre requise" sans image dans le VWB.
Cible du fix : poser l'image d'ancre du parent sur le 1er substep cliquable.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from services.learned_workflow_bridge import convert_learned_to_vwb_steps
_CLICK_TYPES = ("click_anchor", "double_click_anchor", "right_click_anchor")
def _compound_workflow_with_anchor(b64: str) -> dict:
"""Workflow core minimal : un edge compound dont la cible porte une image
d'ancre dans `context_hints`, avec un substep cliquable suivi d'une saisie."""
return {
"workflow_id": "wf_test_compound_anchor",
"name": "Test compound anchor",
"entry_nodes": ["n1"],
"nodes": [
{"node_id": "n1", "name": "Start"},
{"node_id": "n2", "name": "End"},
],
"edges": [
{
"edge_id": "e1",
"from_node": "n1",
"to_node": "n2",
"action": {
"type": "compound",
"target": {
"by_text": "Fichier",
"context_hints": {"anchor_image_base64": b64},
},
"parameters": {
"steps": [
{"type": "mouse_click", "pos": [0.5, 0.5], "button": "left"},
{"type": "text_input", "text": "bonjour"},
]
},
},
}
],
}
def test_compound_substep_inherits_anchor_image():
"""Le 1er substep cliquable d'un compound doit hériter de l'image d'ancre du parent."""
b64 = "ZmFrZV9hbmNob3I=" # "fake_anchor" encodé base64
_meta, steps, _warnings = convert_learned_to_vwb_steps(
_compound_workflow_with_anchor(b64)
)
clickable = [s for s in steps if s["action_type"] in _CLICK_TYPES]
assert clickable, "le compound aurait dû produire au moins un step cliquable"
assert clickable[0]["parameters"].get("_anchor_image_base64") == b64, (
"le 1er substep cliquable doit hériter de l'image d'ancre du parent "
"(target.context_hints.anchor_image_base64)"
)

View File

@@ -0,0 +1,195 @@
"""
Tests de l'auth HTTP Basic LAN du backend VWB (port 5002).
Le VWB etait expose au LAN SANS authentification. Le middleware
`_vwb_basic_auth_middleware` ajoute un challenge 401 sur toute requete
NON-loopback, avec les MEMES credentials que le dashboard
(DASHBOARD_USER / DASHBOARD_PASSWORD).
Controles cles :
- Loopback (127.0.0.1) sans credentials -> 200 (proxy dashboard / healthcheck).
- LAN (REMOTE_ADDR non loopback) sans credentials -> 401 + WWW-Authenticate.
- LAN avec mauvais mot de passe -> 401.
- LAN avec bons credentials -> passage (pas de 401).
- /health public meme en LAN.
- DASHBOARD_AUTH_DISABLED=true -> bypass total.
- DASHBOARD_PASSWORD absent -> auth inactive (mode POC degrade, pas de crash).
"""
from __future__ import annotations
import base64
import importlib
import os
import sys
from pathlib import Path
import pytest
# Le backend VWB s'importe en tant que module top-level `app`
# (cf. tests/conftest.py : `from app import app, db`). On ajoute le repertoire
# backend au path pour pouvoir le recharger avec les variables d'env voulues.
_BACKEND_DIR = Path(__file__).resolve().parent.parent
if str(_BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(_BACKEND_DIR))
# Adresse LAN simulee (non loopback)
_LAN_ADDR = "192.168.1.50"
_LAN_ENV = {"REMOTE_ADDR": _LAN_ADDR}
def _basic_auth_header(user: str, password: str) -> str:
token = base64.b64encode(f"{user}:{password}".encode()).decode()
return f"Basic {token}"
def _reload_app():
"""Recharge le module `app` pour relire les constantes d'auth depuis l'env."""
if "app" in sys.modules:
return importlib.reload(sys.modules["app"])
return importlib.import_module("app")
@pytest.fixture
def auth_enabled_client(monkeypatch):
"""Client VWB avec auth LAN active (DASHBOARD_USER/PASSWORD definis)."""
monkeypatch.setenv("DASHBOARD_USER", "lea")
monkeypatch.setenv("DASHBOARD_PASSWORD", "secret-test-pwd")
monkeypatch.delenv("DASHBOARD_AUTH_DISABLED", raising=False)
mod = _reload_app()
mod.app.config["TESTING"] = True
mod.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with mod.app.test_client() as c:
with mod.app.app_context():
mod.db.create_all()
yield c
mod.db.drop_all()
@pytest.fixture
def auth_disabled_client(monkeypatch):
"""Client VWB avec auth desactivee (DASHBOARD_AUTH_DISABLED=true)."""
monkeypatch.setenv("DASHBOARD_AUTH_DISABLED", "true")
monkeypatch.setenv("DASHBOARD_PASSWORD", "secret-test-pwd")
mod = _reload_app()
mod.app.config["TESTING"] = True
mod.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with mod.app.test_client() as c:
with mod.app.app_context():
mod.db.create_all()
yield c
mod.db.drop_all()
@pytest.fixture
def no_password_client(monkeypatch):
"""Client VWB sans DASHBOARD_PASSWORD (mode POC degrade : auth inactive)."""
monkeypatch.delenv("DASHBOARD_PASSWORD", raising=False)
monkeypatch.delenv("DASHBOARD_AUTH_DISABLED", raising=False)
mod = _reload_app()
mod.app.config["TESTING"] = True
mod.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with mod.app.test_client() as c:
with mod.app.app_context():
mod.db.create_all()
yield c
mod.db.drop_all()
class TestVwbBasicAuth:
"""Auth HTTP Basic LAN sur le backend VWB (5002)."""
def test_loopback_no_creds_passes(self, auth_enabled_client):
"""Requete loopback (127.0.0.1) sans creds -> PAS de 401.
Garde-fou critique : le dashboard proxifie en loopback. La requete
ne doit jamais etre challengee (200, ou autre code applicatif != 401).
"""
resp = auth_enabled_client.get("/api/v3/session/state")
assert resp.status_code != 401, (
f"Loopback ne doit jamais etre challenge (got {resp.status_code})"
)
def test_lan_no_creds_returns_401(self, auth_enabled_client):
"""Requete LAN (non loopback) sans creds -> 401 + WWW-Authenticate."""
resp = auth_enabled_client.get(
"/api/v3/session/state", environ_base=_LAN_ENV
)
assert resp.status_code == 401
assert "WWW-Authenticate" in resp.headers
assert "Basic" in resp.headers["WWW-Authenticate"]
def test_lan_wrong_password_returns_401(self, auth_enabled_client):
"""Requete LAN avec mauvais mot de passe -> 401."""
resp = auth_enabled_client.get(
"/api/v3/session/state",
environ_base=_LAN_ENV,
headers={"Authorization": _basic_auth_header("lea", "wrong")},
)
assert resp.status_code == 401
def test_lan_wrong_user_returns_401(self, auth_enabled_client):
"""Requete LAN avec mauvais utilisateur -> 401."""
resp = auth_enabled_client.get(
"/api/v3/session/state",
environ_base=_LAN_ENV,
headers={"Authorization": _basic_auth_header("intruder", "secret-test-pwd")},
)
assert resp.status_code == 401
def test_lan_valid_credentials_pass(self, auth_enabled_client):
"""Requete LAN avec bons creds -> PAS de 401 (auth franchie)."""
resp = auth_enabled_client.get(
"/api/v3/session/state",
environ_base=_LAN_ENV,
headers={"Authorization": _basic_auth_header("lea", "secret-test-pwd")},
)
assert resp.status_code != 401, (
f"Bons creds doivent franchir l'auth (got {resp.status_code})"
)
def test_lan_malformed_header_returns_401(self, auth_enabled_client):
"""Requete LAN avec header mal forme (Bearer) -> 401."""
resp = auth_enabled_client.get(
"/api/v3/session/state",
environ_base=_LAN_ENV,
headers={"Authorization": "Bearer tototoken"},
)
assert resp.status_code == 401
def test_lan_health_is_public(self, auth_enabled_client):
"""/health reste public meme en LAN (healthcheck externe)."""
resp = auth_enabled_client.get("/health", environ_base=_LAN_ENV)
assert resp.status_code == 200
def test_lan_options_preflight_not_blocked(self, auth_enabled_client):
"""Preflight CORS (OPTIONS) en LAN -> pas de 401 (CORS preserve)."""
resp = auth_enabled_client.open(
"/api/v3/session/state", method="OPTIONS", environ_base=_LAN_ENV
)
assert resp.status_code != 401
def test_auth_disabled_bypass_lan(self, auth_disabled_client):
"""DASHBOARD_AUTH_DISABLED=true -> LAN passe sans creds."""
resp = auth_disabled_client.get(
"/api/v3/session/state", environ_base=_LAN_ENV
)
assert resp.status_code != 401
def test_no_password_degraded_lan_passes(self, no_password_client):
"""DASHBOARD_PASSWORD absent -> mode POC degrade : LAN passe (pas de crash)."""
resp = no_password_client.get(
"/api/v3/session/state", environ_base=_LAN_ENV
)
assert resp.status_code != 401
@pytest.fixture(autouse=True)
def _restore_module(monkeypatch):
"""Restaure le module `app` en mode auth desactivee apres chaque test,
pour ne pas contaminer les autres tests VWB (qui importent `app`)."""
yield
monkeypatch.setenv("DASHBOARD_AUTH_DISABLED", "true")
monkeypatch.delenv("DASHBOARD_PASSWORD", raising=False)
monkeypatch.delenv("DASHBOARD_USER", raising=False)
if "app" in sys.modules:
importlib.reload(sys.modules["app"])

View File

@@ -7,6 +7,7 @@
import React from 'react';
import { CoachingSuggestion } from '../../hooks/useCoachingWebSocket';
import { getApiOrigin } from '../../services/apiClient';
interface CoachingSuggestionCardProps {
suggestion: CoachingSuggestion;
@@ -106,7 +107,7 @@ const CoachingSuggestionCard: React.FC<CoachingSuggestionCardProps> = ({
{suggestion.screenshotPath && (
<div className="suggestion-screenshot">
<img
src={`http://localhost:5001${suggestion.screenshotPath}`}
src={`${getApiOrigin()}${suggestion.screenshotPath}`}
alt="Target element"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';

View File

@@ -23,6 +23,7 @@ import CoachingSuggestionCard from './CoachingSuggestionCard';
import CoachingDecisionButtons from './CoachingDecisionButtons';
import CoachingStatsDisplay from './CoachingStatsDisplay';
import CorrectionEditor from './CorrectionEditor';
import { getApiOrigin } from '../../services/apiClient';
import './CoachingPanel.css';
interface CoachingPanelProps {
@@ -143,7 +144,7 @@ export const CoachingPanel: React.FC<CoachingPanelProps> = ({
const startSession = useCallback(
async (wfId: string) => {
try {
const response = await fetch(`${serverUrl || 'http://localhost:5001'}/api/executions/coaching`, {
const response = await fetch(`${serverUrl || getApiOrigin()}/api/executions/coaching`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: wfId }),

View File

@@ -62,6 +62,7 @@ import {
Variable,
} from '../../types';
import { VWBEvidence } from '../../types/evidence';
import { getApiHost } from '../../services/apiClient';
interface VWBExecutorExtensionProps {
workflow: Workflow;
@@ -229,11 +230,8 @@ const VWBExecutorExtension: React.FC<VWBExecutorExtensionProps> = ({
setFeedbackLoading(true);
try {
// Déterminer l'URL de l'API
const hostname = window.location.hostname;
const apiBase = (hostname === 'localhost' || hostname === '127.0.0.1')
? 'http://localhost:5001/api'
: `http://${hostname}:5000/api`;
// URL de l'API résolue dynamiquement (compatible IP DGX / accès distant).
const apiBase = getApiHost();
const response = await fetch(`${apiBase}/workflows/${workflow.id}/feedback`, {
method: 'POST',

View File

@@ -62,6 +62,7 @@ import { captureLibraryService, SavedCapture } from '../../services/captureLibra
import { VisualSelection, BoundingBox } from '../../types';
import { screenCaptureService } from '../../services/screenCaptureService';
import { uploadAnchorImage } from '../../services/anchorImageService';
import { getApiHost } from '../../services/apiClient';
interface VisualSelectorProps {
isOpen: boolean;
@@ -137,7 +138,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
useEffect(() => {
const loadMonitors = async () => {
try {
const response = await fetch('http://localhost:5001/api/real-demo/capture/status');
const response = await fetch(`${getApiHost()}/real-demo/capture/status`);
const data = await response.json();
if (data.success && data.monitors) {
setMonitors(data.monitors);
@@ -301,7 +302,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
await new Promise(resolve => setTimeout(resolve, delayMs));
// Utiliser l'API de capture réelle avec le moniteur sélectionné
const response = await fetch('http://localhost:5001/api/real-demo/capture', {
const response = await fetch(`${getApiHost()}/real-demo/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -8,7 +8,7 @@
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { apiClient, ApiError, ConnectionState } from '../services/apiClient';
import { apiClient, ApiError, ConnectionState, getApiHost } from '../services/apiClient';
import { WorkflowApiData } from '../types';
// Types pour les états de requête
@@ -215,7 +215,7 @@ export function useConnectionState() {
// Vérification DIRECTE au montage (SANS passer par apiClient singleton)
const checkOnMount = async () => {
try {
const response = await fetch('http://localhost:5001/api/health', {
const response = await fetch(`${getApiHost()}/health`, {
headers: { 'Accept': 'application/json' },
});

View File

@@ -10,6 +10,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { getApiOrigin } from '../services/apiClient';
// Types for COACHING mode
export type CoachingDecision = 'accept' | 'reject' | 'correct' | 'manual' | 'skip';
@@ -106,7 +107,7 @@ const convertStats = (backendStats: Record<string, any>): CoachingStats => {
export function useCoachingWebSocket(
options: UseCoachingWebSocketOptions = {}
): UseCoachingWebSocketReturn {
const { serverUrl = 'http://localhost:5001', autoConnect = true } = options;
const { serverUrl = getApiOrigin(), autoConnect = true } = options;
const [isConnected, setIsConnected] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);

View File

@@ -11,7 +11,7 @@
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiClient, ConnectionState } from '../services/apiClient';
import { apiClient, ConnectionState, getApiHost } from '../services/apiClient';
interface ConnectionStatusState {
/** État actuel de la connexion */
@@ -130,7 +130,7 @@ export function useConnectionStatus(options: UseConnectionStatusOptions = {}): C
// Vérification DIRECTE au démarrage
const checkOnMount = async () => {
try {
const response = await fetch('http://localhost:5001/api/health', {
const response = await fetch(`${getApiHost()}/health`, {
headers: { 'Accept': 'application/json' },
});

View File

@@ -5,6 +5,7 @@
*/
import { useState, useCallback, useEffect } from 'react';
import { getApiHost } from '../services/apiClient';
// Types
export interface Correction {
@@ -68,7 +69,8 @@ interface UseCorrectionPacksReturn {
selectPack: (pack: CorrectionPack | null) => void;
}
const API_BASE = 'http://localhost:5001/api';
// Base URL de l'API résolue dynamiquement (compatible IP DGX / accès distant).
const API_BASE = getApiHost();
export function useCorrectionPacks(): UseCorrectionPacksReturn {
const [packs, setPacks] = useState<CorrectionPack[]>([]);

View File

@@ -9,6 +9,16 @@
import { BoundingBox } from '../types';
// Origine de l'API core (port 8000) résolue dynamiquement à partir de l'hôte courant.
// NOTE: le port 8000 (API core upload) est conservé tel quel ; seul l'hôte devient
// dynamique pour rester compatible avec un accès distant (IP DGX).
const getCoreApiOrigin = (): string => {
if (typeof window !== 'undefined') {
return `http://${window.location.hostname}:8000`;
}
return (process.env.REACT_APP_CORE_API_ORIGIN || '').replace(/\/$/, '');
};
interface VisualMetadata {
element_type: string;
relative_position?: string;
@@ -63,7 +73,7 @@ class VisualCaptureService {
private cache: Map<string, any>;
private cacheTimeout: number;
constructor(baseUrl: string = 'http://localhost:8000') {
constructor(baseUrl: string = getCoreApiOrigin()) {
this.baseUrl = baseUrl;
this.timeout = 30000; // 30 secondes
this.cache = new Map();
@@ -527,4 +537,4 @@ class VisualCaptureService {
// Instance singleton du service
export const visualCaptureService = new VisualCaptureService();
export default VisualCaptureService;
export default VisualCaptureService;

View File

@@ -8,8 +8,28 @@
*/
import { BoundingBox } from '../types';
import { getApiOrigin } from './apiClient';
const API_BASE = 'http://localhost:5001';
// Origine de l'API résolue dynamiquement (compatible IP DGX / accès distant).
// Calculée à l'appel pour refléter window.location au runtime.
const apiBase = (): string => getApiOrigin();
/**
* Normalise une URL d'ancre potentiellement importée d'un autre poste.
*
* Les workflows importés peuvent contenir des URLs absolues codées sur une
* ancienne origine applicative. On réécrit
* uniquement le chemin /api/anchor-images vers l'origine API courante, sans
* muter le workflow source (transformation à l'usage uniquement).
*/
const normalizeAnchorUrl = (url: string): string => {
const marker = '/api/anchor-images';
const idx = url.indexOf(marker);
if (idx === -1) {
return url;
}
return `${apiBase()}${url.slice(idx)}`;
};
export interface AnchorImageUploadResult {
success: boolean;
@@ -67,7 +87,7 @@ export async function uploadAnchorImage(
anchorId
});
const response = await fetch(`${API_BASE}/api/anchor-images`, {
const response = await fetch(`${apiBase()}/api/anchor-images`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -106,7 +126,7 @@ export async function uploadAnchorImage(
* @returns URL complète de la miniature
*/
export function getThumbnailUrl(anchorId: string): string {
return `${API_BASE}/api/anchor-images/${anchorId}/thumbnail`;
return `${apiBase()}/api/anchor-images/${anchorId}/thumbnail`;
}
/**
@@ -116,7 +136,7 @@ export function getThumbnailUrl(anchorId: string): string {
* @returns URL complète de l'image originale
*/
export function getOriginalUrl(anchorId: string): string {
return `${API_BASE}/api/anchor-images/${anchorId}/original`;
return `${apiBase()}/api/anchor-images/${anchorId}/original`;
}
/**
@@ -126,7 +146,7 @@ export function getOriginalUrl(anchorId: string): string {
* @returns Métadonnées de l'ancre
*/
export async function getAnchorMetadata(anchorId: string): Promise<AnchorMetadata> {
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}/metadata`);
const response = await fetch(`${apiBase()}/api/anchor-images/${anchorId}/metadata`);
if (!response.ok) {
throw new Error(`Ancre '${anchorId}' non trouvée`);
@@ -143,7 +163,7 @@ export async function getAnchorMetadata(anchorId: string): Promise<AnchorMetadat
* @returns true si supprimé avec succès
*/
export async function deleteAnchorImage(anchorId: string): Promise<boolean> {
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}`, {
const response = await fetch(`${apiBase()}/api/anchor-images/${anchorId}`, {
method: 'DELETE',
});
@@ -166,7 +186,7 @@ export async function listAnchorImages(
offset: number = 0
): Promise<{ anchors: AnchorMetadata[]; total: number }> {
const response = await fetch(
`${API_BASE}/api/anchor-images?limit=${limit}&offset=${offset}`
`${apiBase()}/api/anchor-images?limit=${limit}&offset=${offset}`
);
if (!response.ok) {
@@ -186,7 +206,7 @@ export async function listAnchorImages(
* @returns Statistiques de stockage
*/
export async function getStorageStats(): Promise<StorageStats> {
const response = await fetch(`${API_BASE}/api/anchor-images/stats`);
const response = await fetch(`${apiBase()}/api/anchor-images/stats`);
if (!response.ok) {
throw new Error('Erreur lors de la récupération des statistiques');
@@ -205,7 +225,7 @@ export async function getStorageStats(): Promise<StorageStats> {
export async function anchorExists(anchorId: string): Promise<boolean> {
try {
const response = await fetch(
`${API_BASE}/api/anchor-images/${anchorId}/metadata`,
`${apiBase()}/api/anchor-images/${anchorId}/metadata`,
{ method: 'HEAD' }
);
return response.ok;
@@ -229,17 +249,18 @@ export function getPreviewImageUrl(anchor: {
}): string | null {
// Priorité 1: URL de miniature serveur
if (anchor.thumbnail_url) {
// Si l'URL est relative, ajouter le préfixe API
// URL absolue: normaliser une éventuelle origine périmée (workflow importé).
// URL relative: préfixer avec l'origine API courante.
return anchor.thumbnail_url.startsWith('http')
? anchor.thumbnail_url
: `${API_BASE}${anchor.thumbnail_url}`;
? normalizeAnchorUrl(anchor.thumbnail_url)
: `${apiBase()}${anchor.thumbnail_url}`;
}
// Priorité 2: URL d'image originale serveur
if (anchor.reference_image_url) {
return anchor.reference_image_url.startsWith('http')
? anchor.reference_image_url
: `${API_BASE}${anchor.reference_image_url}`;
? normalizeAnchorUrl(anchor.reference_image_url)
: `${apiBase()}${anchor.reference_image_url}`;
}
// Priorité 3: Construire l'URL depuis anchor_id si présent

View File

@@ -46,20 +46,33 @@ type ConnectionState = 'online' | 'offline' | 'checking';
// Callbacks pour les changements d'état
type ConnectionStateCallback = (state: ConnectionState) => void;
// Détection automatique de l'hôte pour support multi-machines
// Si on accède via une IP (ex: 192.168.1.40), utiliser cette IP pour l'API
// Sinon utiliser localhost
const getApiHost = (): string => {
// Détection automatique de l'hôte pour support multi-machines.
// Si on accède via une IP ou un nom DNS DGX, utiliser cet hôte pour l'API.
//
// IMPORTANT: le backend Flask (dashboard/API VWB) écoute sur le port 5001.
// On résout dynamiquement l'origine à partir de window.location.hostname pour
// rester compatible avec un accès distant (IP DGX) sans URL codée en dur.
/**
* Origine de l'API (sans suffixe /api), résolue dynamiquement.
* Exemple: http://192.168.1.45:5001 ou http://dgx-site:5001
*/
export const getApiOrigin = (): string => {
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
// Si c'est localhost ou 127.0.0.1, garder localhost
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'http://localhost:5001/api';
}
// Sinon utiliser le même hostname (IP) avec le port 5000
return `http://${hostname}:5000/api`;
// En accès distant comme en local, le backend Flask reste sur le port 5001
return `http://${hostname}:5001`;
}
return 'http://localhost:5001/api';
return (process.env.REACT_APP_API_ORIGIN || '').replace(/\/$/, '');
};
/**
* URL de base de l'API (avec suffixe /api), résolue dynamiquement.
* Exemple: http://192.168.1.45:5001/api ou http://dgx-site:5001/api
*/
export const getApiHost = (): string => {
const origin = getApiOrigin();
return origin ? `${origin}/api` : '/api';
};
// Configuration par défaut

View File

@@ -46,6 +46,9 @@ import {
getStaticCatalogStats,
} from '../data/staticCatalog';
// Origine de l'API résolue dynamiquement (compatible IP DGX / accès distant).
import { getApiOrigin } from './apiClient';
// Configuration du service catalogue
interface CatalogServiceConfig {
urls: string[];
@@ -173,18 +176,17 @@ class CatalogService {
const currentOrigin = window.location.origin;
candidateUrls.push(currentOrigin);
// 4. Localhost standard (développement) - Port 5001 en priorité
if (!candidateUrls.includes('http://localhost:5001')) {
candidateUrls.push('http://localhost:5001');
}
if (!candidateUrls.includes('http://localhost:5001')) {
candidateUrls.push('http://localhost:5001');
// 4. Origine API courante (port 5001), résolue dynamiquement.
// En accès navigateur -> http://<hostname>:5001
const apiOrigin = getApiOrigin();
if (!candidateUrls.includes(apiOrigin)) {
candidateUrls.push(apiOrigin);
}
// 5. IP locale détectée (cross-machine)
try {
const localIp = this.detectLocalIp();
if (localIp && localIp !== '127.0.0.1') {
if (localIp && !localIp.startsWith('127.')) {
candidateUrls.push(`http://${localIp}:5000`);
candidateUrls.push(`http://${localIp}:5004`);
}
@@ -973,4 +975,4 @@ export type {
CatalogActionCategory,
};
export default CatalogService;
export default CatalogService;

View File

@@ -4,13 +4,15 @@
*/
import { VWBEvidence, EvidenceFilters, EvidenceExportOptions, EvidenceStats, EvidenceUtils } from '../types/evidence';
import { getApiOrigin } from './apiClient';
export class EvidenceService {
private baseUrl: string;
private cache: Map<string, VWBEvidence[]> = new Map();
private cacheTimeout: number = 5 * 60 * 1000; // 5 minutes
constructor(baseUrl: string = 'http://localhost:5001') {
// baseUrl résolu dynamiquement par défaut (origine API courante, compatible IP DGX)
constructor(baseUrl: string = getApiOrigin()) {
this.baseUrl = baseUrl;
}

View File

@@ -6,8 +6,11 @@
* en utilisant le service RealScreenCaptureService du backend.
*/
import { getApiHost } from './apiClient';
// Configuration du service
const BACKEND_BASE_URL = 'http://localhost:5001/api';
// Base URL de l'API résolue dynamiquement (compatible IP DGX / accès distant).
const BACKEND_BASE_URL = getApiHost();
const REQUEST_TIMEOUT = 20000; // 20 secondes pour la capture avec détection
// Types pour les réponses API

View File

@@ -7,9 +7,11 @@
*/
import { BoundingBox, VisualSelection } from '../types';
import { getApiHost } from './apiClient';
// Configuration du service
const BACKEND_BASE_URL = 'http://localhost:5001/api';
// Base URL de l'API résolue dynamiquement (compatible IP DGX / accès distant).
const BACKEND_BASE_URL = getApiHost();
const REQUEST_TIMEOUT = 15000; // 15 secondes pour la capture d'écran
// Types pour les réponses API

View File

@@ -189,6 +189,20 @@ SESSIONS_PATH = DATA_PATH / "sessions"
WORKFLOWS_PATH = DATA_PATH / "workflows"
LOGS_PATH = BASE_PATH / "logs"
# Source canonique des workflows (décision produit D3) : la base VWB v3
# (SQLAlchemy/SQLite) que Léa lit déjà au runtime. Chemin absolu robuste (PAS la
# DB fantôme vide à la racine du repo `instance/workflows.db`, schéma obsolète,
# ni l'ancien store JSON `data/training/workflows/` créé vide sur DGX).
# Surchargeable via RPA_VWB_DB_PATH pour les déploiements atypiques.
def _resolve_vwb_db_path() -> Path:
override = os.getenv("RPA_VWB_DB_PATH", "").strip()
if override:
return Path(override).expanduser()
return BASE_PATH / "visual_workflow_builder" / "backend" / "instance" / "workflows.db"
VWB_DB_PATH = _resolve_vwb_db_path()
# StorageManager
storage = StorageManager(base_path=str(DATA_PATH))
@@ -261,7 +275,9 @@ def system_status():
"""Statut du système."""
try:
sessions_count = len(list(SESSIONS_PATH.glob('*'))) if SESSIONS_PATH.exists() else 0
workflows_count = len(list(WORKFLOWS_PATH.glob('*.json'))) if WORKFLOWS_PATH.exists() else 0
# Source canonique D3 : base VWB v3 (même comptage que /api/workflows),
# pas l'ancien store JSON `data/training/workflows/` créé vide sur DGX.
workflows_count = len(_load_workflows_from_vwb_db())
dependencies_ok = True
try:
@@ -296,8 +312,26 @@ def system_performance():
faiss_metadata_path = DATA_PATH / "faiss_index" / "main.metadata"
if faiss_index_path.exists() and faiss_metadata_path.exists():
fm = FAISSManager.load(faiss_index_path, faiss_metadata_path)
faiss_stats = fm.get_stats()
try:
fm = FAISSManager.load(faiss_index_path, faiss_metadata_path)
faiss_stats = fm.get_stats()
faiss_stats.setdefault("status", "active")
faiss_stats.setdefault("metadata_status", "valid")
except Exception as e:
faiss_stats = _read_raw_faiss_index_stats(faiss_index_path)
faiss_stats.update({
"status": "metadata_invalid",
"metadata_status": "invalid",
"metadata_error": str(e),
"action_required": "re-sign-or-rebuild-faiss-metadata",
})
elif faiss_index_path.exists():
faiss_stats = _read_raw_faiss_index_stats(faiss_index_path)
faiss_stats.update({
"status": "metadata_missing",
"metadata_status": "missing",
"action_required": "rebuild-faiss-metadata",
})
else:
faiss_stats = {"total_vectors": 0, "status": "index_not_found"}
except Exception as e:
@@ -319,6 +353,26 @@ def system_performance():
return jsonify({'error': str(e)}), 500
def _read_raw_faiss_index_stats(index_path: Path) -> dict:
"""Read non-authoritative FAISS stats when signed metadata cannot load."""
try:
import faiss
index = faiss.read_index(str(index_path))
return {
"raw_index_available": True,
"total_vectors": int(getattr(index, "ntotal", 0)),
"dimensions": int(getattr(index, "d", 0)),
"is_trained": bool(getattr(index, "is_trained", False)),
"index_type": type(index).__name__.replace("Index", "") or "FAISS",
}
except Exception as e:
return {
"raw_index_available": False,
"total_vectors": 0,
"error": f"Index FAISS illisible: {e}",
}
@app.route('/api/system/faiss/test', methods=['POST'])
def test_faiss_index():
"""Teste l'index FAISS avec une recherche aléatoire."""
@@ -785,36 +839,83 @@ def rename_session_workflow(session_id):
# API Workflows
# =============================================================================
def _load_workflows_from_vwb_db() -> list:
"""Charge les workflows depuis la base VWB v3 (source canonique D3).
Lit directement le SQLite que Léa interroge au runtime (cf.
`agent_chat/app.py` → `GET /api/v3/session/state`). On compte les `steps`
par workflow pour `nodes_count` (pas de notion d'`edges` en DAG linéaire :
`edges_count` = max(steps-1, 0)). Robuste à l'absence de la DB ou des
colonnes `source`/`review_status` (DB ancienne) : retourne [] sans planter.
"""
import sqlite3
if not VWB_DB_PATH.exists():
return []
workflows = []
conn = sqlite3.connect(str(VWB_DB_PATH))
try:
conn.row_factory = sqlite3.Row
# Colonnes disponibles (la DB fantôme/ancienne n'a pas source/review_status)
cols = {row[1] for row in conn.execute("PRAGMA table_info(workflows)")}
has_source = 'source' in cols
has_review = 'review_status' in cols
select_cols = ['id', 'name', 'description', 'created_at', 'updated_at']
if has_source:
select_cols.append('source')
if has_review:
select_cols.append('review_status')
# Nombre de steps par workflow (= nodes du DAG)
step_counts = {
row[0]: row[1]
for row in conn.execute(
"SELECT workflow_id, COUNT(*) FROM steps GROUP BY workflow_id"
)
}
rows = conn.execute(
f"SELECT {', '.join(select_cols)} FROM workflows ORDER BY updated_at DESC"
).fetchall()
for row in rows:
wf_id = row['id']
nodes_count = step_counts.get(wf_id, 0)
workflows.append({
'workflow_id': wf_id,
'name': row['name'] or wf_id,
'description': row['description'] or '',
'nodes_count': nodes_count,
'edges_count': max(nodes_count - 1, 0),
'learning_state': 'OBSERVATION',
'created_at': str(row['created_at'] or ''),
'updated_at': str(row['updated_at'] or ''),
'execution_count': 0,
'source': row['source'] if has_source else 'manual',
'review_status': row['review_status'] if has_review else '',
'file_path': f"vwb_db://{wf_id}",
})
finally:
conn.close()
return workflows
@app.route('/api/workflows')
def list_workflows():
"""Liste tous les workflows."""
"""Liste tous les workflows depuis la base VWB v3 (source canonique D3).
Avant ce correctif, l'endpoint globait `data/training/workflows/*.json`
(ancien store JSON, créé vide sur DGX) et renvoyait toujours `total: 0`,
rendant la surface « ce que Léa sait » faussement vide. On lit désormais la
même base SQLite que Léa au runtime.
"""
try:
workflows = []
hide_unnamed = request.args.get('hide_unnamed', 'true').lower() == 'true'
if not WORKFLOWS_PATH.exists():
WORKFLOWS_PATH.mkdir(parents=True, exist_ok=True)
return jsonify({'workflows': [], 'total': 0, 'hidden_unnamed': 0})
for wf_file in WORKFLOWS_PATH.glob('*.json'):
try:
with open(wf_file, 'r') as f:
wf_data = json.load(f)
workflows.append({
'workflow_id': wf_data.get('workflow_id', wf_file.stem),
'name': wf_data.get('name', wf_file.stem),
'description': wf_data.get('description', ''),
'nodes_count': len(wf_data.get('nodes', [])),
'edges_count': len(wf_data.get('edges', [])),
'learning_state': wf_data.get('learning_state', 'OBSERVATION'),
'created_at': wf_data.get('created_at', ''),
'updated_at': wf_data.get('updated_at', ''),
'execution_count': wf_data.get('execution_count', 0),
'file_path': str(wf_file)
})
except Exception as e:
print(f"Erreur lecture workflow {wf_file}: {e}")
workflows = _load_workflows_from_vwb_db()
# Filtrer les workflows "Unnamed" si demandé
if hide_unnamed:
@@ -1970,19 +2071,83 @@ def import_config():
# API Streaming - Proxy vers le serveur de streaming (port 5005)
# =============================================================================
STREAMING_BASE_URL = 'http://localhost:5005/api/v1/traces/stream'
def _normalize_streaming_base_url(raw_url: str) -> str:
"""Normalise l'URL du serveur streaming vers le préfixe API attendu."""
base = (raw_url or 'http://localhost:5005').strip().rstrip('/')
if base.endswith('/api/v1/traces/stream'):
return base
if base.endswith('/api/v1/traces'):
return f'{base}/stream'
return f'{base}/api/v1/traces/stream'
STREAMING_BASE_URL = _normalize_streaming_base_url(
os.getenv('RPA_STREAMING_URL')
or os.getenv('STREAMING_BASE_URL')
or 'http://localhost:5005'
)
def _streaming_headers():
headers = {'Accept': 'application/json'}
token = os.getenv('RPA_API_TOKEN', '').strip()
if token:
headers['Authorization'] = f'Bearer {token}'
return headers
def _fetch_streaming_json(endpoint, query_string=''):
import urllib.request
endpoint = endpoint.strip('/')
url = f'{STREAMING_BASE_URL}/{endpoint}'
if query_string:
url = f'{url}?{query_string}'
req = urllib.request.Request(url, headers=_streaming_headers())
with urllib.request.urlopen(req, timeout=5) as response:
return json.loads(response.read().decode())
def _streaming_status_snapshot():
"""Compat legacy dashboard: agrège les endpoints streaming qui existent."""
snapshot = _fetch_streaming_json('stats')
try:
sessions = _fetch_streaming_json('sessions')
snapshot['sessions'] = sessions.get('sessions', sessions if isinstance(sessions, list) else [])
except Exception as exc:
snapshot['sessions_error'] = str(exc)
try:
snapshot['processing'] = _fetch_streaming_json('processing/status')
except Exception as exc:
snapshot['processing_error'] = str(exc)
try:
replays = _fetch_streaming_json('replays')
replay_items = replays.get('replays', []) if isinstance(replays, dict) else []
snapshot['replay'] = next((r for r in replay_items if r.get('active')), None)
snapshot['replays'] = replay_items
except Exception as exc:
snapshot['replay_error'] = str(exc)
return snapshot
@app.route('/api/streaming/<path:endpoint>')
def proxy_streaming(endpoint):
"""Proxy vers le serveur de streaming pour éviter les problèmes CORS."""
import urllib.request
import urllib.error
try:
url = f'{STREAMING_BASE_URL}/{endpoint}'
req = urllib.request.Request(url, headers={'Accept': 'application/json'})
with urllib.request.urlopen(req, timeout=5) as response:
data = json.loads(response.read().decode())
clean_endpoint = endpoint.strip('/')
if clean_endpoint == 'status':
data = _streaming_status_snapshot()
else:
query_string = request.query_string.decode('utf-8')
data = _fetch_streaming_json(clean_endpoint, query_string)
return jsonify(data)
return jsonify(data)
except urllib.error.HTTPError as e:
try:
payload = json.loads(e.read().decode())
except Exception:
payload = {'error': e.reason}
return jsonify(payload), e.code
except urllib.error.URLError as e:
return jsonify({'error': f'Serveur streaming inaccessible: {e}'}), 502
except Exception as e:
@@ -2054,8 +2219,33 @@ def proxy_fleet(endpoint):
# Fleet — Téléchargement du ZIP installeur pré-configuré
# =============================================================================
# Chemin du ZIP template Léa
_LEA_ZIP_TEMPLATE = BASE_PATH / "deploy" / "Lea_v1.0.0.zip"
# Chemin du ZIP template Léa.
# ZIP COMPLET autoportant (runtime Python embedded inclus + source à jour) :
# l'utilisateur dézippe puis double-clique Lea.bat, sans Python système ni UAC.
# Construit par deploy/build_package_full.sh. Le placeholder Lea/config.txt
# (CONFIGURE_ME) est remplacé à la volée par download_agent_package().
# Fallback historique vers l'ancien ZIP léger (sources seules, suppose Python
# système) uniquement s'il existe et que le complet est absent — pour ne pas
# casser un environnement où le complet n'a pas encore été buildé.
_LEA_ZIP_TEMPLATE_FULL = BASE_PATH / "deploy" / "build" / "Lea_full_v1.0.1.zip"
_LEA_ZIP_TEMPLATE_LEGACY = BASE_PATH / "deploy" / "Lea_v1.0.0.zip"
def _resolve_lea_zip_template():
"""Résout le ZIP à servir, à la volée (le complet peut être buildé
après le démarrage du dashboard). Préfère le ZIP complet autoportant ;
retombe sur l'ancien ZIP léger uniquement s'il existe.
Retourne None si aucun template n'est présent.
"""
if _LEA_ZIP_TEMPLATE_FULL.exists():
return _LEA_ZIP_TEMPLATE_FULL
if _LEA_ZIP_TEMPLATE_LEGACY.exists():
return _LEA_ZIP_TEMPLATE_LEGACY
return None
# Compat : référence statique conservée pour le code/log historique.
_LEA_ZIP_TEMPLATE = _resolve_lea_zip_template() or _LEA_ZIP_TEMPLATE_FULL
# URL publique du serveur (env ou fallback)
_RPA_PUBLIC_URL = os.getenv(
@@ -2083,6 +2273,33 @@ def _extract_host(url: str) -> str:
_RPA_PUBLIC_HOST = _extract_host(_RPA_PUBLIC_URL)
def _resolve_public_server_url() -> str:
"""URL publique du serveur Léa pour l'enrôlement (config.txt des agents).
Priorité :
1. system_config.json → services.streaming.{host,port} (édité dans le dashboard)
2. variables d'env RPA_PUBLIC_URL / RPA_SERVER_URL
3. défaut historique
Un host vide ou loopback dans la config = « non configuré » : on retombe sur
l'env pour ne pas régresser un déploiement qui pilote l'URL par l'environnement.
Toujours normalisée pour se terminer par /api/v1.
"""
_NON_ROUTABLE = {"", "localhost", "127.0.0.1", "0.0.0.0", "configure_me"}
try:
streaming = (load_system_config().get("services") or {}).get("streaming") or {}
host = str(streaming.get("host") or "").strip()
if host.lower() not in _NON_ROUTABLE:
port = streaming.get("port") or 5005
return _normalize_server_url(f"http://{host}:{port}")
except Exception as exc: # config illisible → ne jamais casser l'enrôlement
api_logger.warning(f"Résolution URL via system_config échouée: {exc}")
env_url = os.getenv("RPA_PUBLIC_URL") or os.getenv("RPA_SERVER_URL")
if env_url:
return _normalize_server_url(env_url)
return _normalize_server_url("https://lea.labs.laurinebazin.design")
def _fetch_fleet_agent(machine_id: str):
"""Récupère un agent depuis le serveur streaming (5005).
@@ -2119,7 +2336,7 @@ def _build_custom_config(machine_id: str, user_name: str, token: str) -> str:
Le host est extrait proprement via urlparse (sans schema/port/path).
"""
now = datetime.now().strftime("%Y-%m-%d %H:%M")
server_url = _normalize_server_url(_RPA_PUBLIC_URL)
server_url = _resolve_public_server_url()
return f"""\
# ============================================================
@@ -2164,17 +2381,23 @@ def download_agent_package(machine_id):
"""Génère et sert un ZIP Léa pré-configuré pour ce machine_id.
- Vérifie que le machine_id est enregistré et actif dans la fleet.
- Lit le ZIP template (deploy/Lea_v1.0.0.zip).
- Remplace config.txt par une version personnalisée.
- Lit le ZIP template complet autoportant (deploy/build/Lea_full_v*.zip),
avec fallback sur l'ancien ZIP léger (deploy/Lea_v1.0.0.zip) s'il est seul.
- Remplace Lea/config.txt par une version personnalisée.
- Renvoie le ZIP modifié en téléchargement (tout en mémoire).
"""
# Sécurité : l'auth Basic est déjà gérée par before_request
# 1. Vérifier que le ZIP template existe
if not _LEA_ZIP_TEMPLATE.exists():
# 1. Résoudre + vérifier que le ZIP template existe (à la volée)
zip_template = _resolve_lea_zip_template()
if zip_template is None:
return jsonify({
'error': 'ZIP template introuvable',
'detail': f'{_LEA_ZIP_TEMPLATE} absent — exécuter deploy/build_package.sh',
'detail': (
f'Ni {_LEA_ZIP_TEMPLATE_FULL} ni {_LEA_ZIP_TEMPLATE_LEGACY} '
'présents — exécuter deploy/build_package_full.sh (ZIP complet '
'autoportant) ou deploy/build_package.sh (ZIP léger).'
),
}), 500
# 2. Vérifier que le machine_id est enregistré
@@ -2195,7 +2418,7 @@ def download_agent_package(machine_id):
# 5. Créer le ZIP personnalisé en mémoire
output_buffer = io.BytesIO()
try:
with zipfile.ZipFile(_LEA_ZIP_TEMPLATE, 'r') as src_zip:
with zipfile.ZipFile(zip_template, 'r') as src_zip:
with zipfile.ZipFile(output_buffer, 'w', zipfile.ZIP_DEFLATED) as dst_zip:
for item in src_zip.infolist():
if item.filename == 'Lea/config.txt':
@@ -2207,7 +2430,7 @@ def download_agent_package(machine_id):
except zipfile.BadZipFile:
return jsonify({
'error': 'ZIP template corrompu',
'detail': f'{_LEA_ZIP_TEMPLATE} n\'est pas un ZIP valide.',
'detail': f'{zip_template} n\'est pas un ZIP valide.',
}), 500
output_buffer.seek(0)
@@ -2273,10 +2496,17 @@ def process_mining_discover():
load_jsonl_session,
PM4PY_AVAILABLE,
)
except ImportError:
except ImportError as exc:
missing = getattr(exc, "name", None) or str(exc)
return jsonify({
'error': "Module d'analyse non disponible",
'detail': "Le module core.analytics.process_mining_bridge est introuvable.",
'detail': (
"Dépendance analytics manquante pendant l'import du bridge "
f"({missing}). Installer le bundle process-mining dans le venv DGX "
"avant de générer la cartographie."
),
'missing_dependency': missing,
'action_required': "install-process-mining-dependencies",
}), 503
if not PM4PY_AVAILABLE:
@@ -2866,9 +3096,10 @@ if __name__ == '__main__':
print("=" * 50)
try:
import os as _os
socketio.run(
app,
host='0.0.0.0',
host=_os.environ.get('RPA_BIND_HOST', '127.0.0.1'),
port=5001,
debug=False,
allow_unsafe_werkzeug=True

View File

@@ -1392,7 +1392,13 @@
// Status indicator
const faissStatusEl = document.getElementById('faissStatus');
if (faiss.error) {
if (faiss.status === 'metadata_invalid') {
faissStatusEl.textContent = '⚠️';
faissStatusEl.title = 'Index brut présent, métadonnées invalides';
} else if (faiss.status === 'metadata_missing') {
faissStatusEl.textContent = '⚠️';
faissStatusEl.title = 'Index brut présent, métadonnées absentes';
} else if (faiss.error) {
faissStatusEl.textContent = '❌';
faissStatusEl.title = faiss.error;
} else if (faiss.status === 'index_not_found') {
@@ -1419,6 +1425,8 @@
if (faiss.nlist) details.push(`nlist: ${faiss.nlist}`);
if (faiss.nprobe) details.push(`nprobe: ${faiss.nprobe}`);
if (faiss.metadata_count) details.push(`Métadonnées: ${faiss.metadata_count}`);
if (faiss.metadata_status) details.push(`Metadata: ${faiss.metadata_status}`);
if (faiss.metadata_error) details.push(`Metadata error: ${faiss.metadata_error}`);
document.getElementById('faissDetails').textContent = details.length > 0 ?
details.join(' • ') : (faiss.error || faiss.status || 'Aucune info disponible');
@@ -1434,6 +1442,12 @@
if (faiss.status === 'index_not_found') {
recommendations.push('📝 Traitez des sessions pour créer l\'index FAISS');
}
if (faiss.status === 'metadata_invalid') {
recommendations.push('⚠️ Index brut présent mais métadonnées invalides : re-signer ou régénérer les métadonnées FAISS');
}
if (faiss.status === 'metadata_missing') {
recommendations.push('⚠️ Index brut présent sans métadonnées : reconstruire les métadonnées FAISS');
}
if (recommendations.length > 0) {
recoEl.innerHTML = recommendations.join('<br>');
recoEl.style.display = 'block';
@@ -2276,7 +2290,7 @@
: '<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:rgba(100,116,139,0.15);color:#64748b;">révoqué</span>';
const downloadBtn = isActive
? `<a href="/api/fleet/download/${encodeURIComponent(agent.machine_id)}" class="btn btn-primary btn-small" style="text-decoration:none;font-size:11px;" title="Télécharger l'installeur pré-configuré">📥</a>`
? `<a href="/api/fleet/download/${encodeURIComponent(agent.machine_id)}" class="btn btn-primary btn-small" style="text-decoration:none;font-size:11px;" title="Télécharger Léa (installation autonome, sans Python — dézipper puis double-cliquer Lea.bat)">📥</a>`
: `<span style="font-size:16px;color:#475569;cursor:not-allowed;opacity:0.4;" title="Agent révoqué — installeur indisponible">📥</span>`;
return `<tr style="border-bottom:1px solid #334155;">
@@ -2800,7 +2814,10 @@
// Utiliser le proxy du dashboard pour éviter les problèmes CORS
const STREAMING_BASE = '/api/streaming';
const VWB_IMPORT_URL = 'http://localhost:5002/api/workflows/import-core';
// Construire VWB_IMPORT_URL dynamiquement à partir de l'origine actuelle (ex: http://192.168.1.45:5001 -> http://192.168.1.45:5002)
// pour éviter le hardcoded localhost et permettre les tests depuis la VM/poste via l'IP du banc.
const VWB_BASE = window.location.origin.replace(/:\d+$/, ':5002');
const VWB_IMPORT_URL = `${VWB_BASE}/api/workflows/import-core`;
async function refreshStreaming() {
await Promise.all([
@@ -2815,10 +2832,29 @@
const detailsEl = document.getElementById('streamServerDetails');
try {
const data = await fetchJSON(`${STREAMING_BASE}/stats`);
const [data, processing] = await Promise.all([
fetchJSON(`${STREAMING_BASE}/stats`),
fetchJSON(`${STREAMING_BASE}/processing/status`).catch(e => ({error: e.message}))
]);
statusEl.innerHTML = '<span style="color:#22c55e;">✅</span>';
statusEl.title = 'Serveur streaming en ligne';
const processingReady = processing && processing.processing_ready === true;
// « En veille » (armé/lazy) ≠ « dégradé » : un worker neuf sans
// session a tous ses composants à false par design (chargement à la
// 1re session), ce n'est PAS une panne. Seul status==='degraded'
// (init tentée et en échec) est une vraie alerte.
const processingArmed = processing && (processing.armed === true || processing.status === 'idle');
const processingDegraded = processing && !processing.error && processing.status === 'degraded';
const statusHint = (processing && processing.status_hint) || '';
statusEl.innerHTML = processingDegraded
? '<span style="color:#f59e0b;">⚠️</span>'
: processingArmed
? '<span style="color:#3b82f6;">⏸️</span>'
: '<span style="color:#22c55e;">✅</span>';
statusEl.title = processingDegraded
? `Streaming en ligne, worker apprentissage dégradé${statusHint ? ' — ' + statusHint : ''}`
: processingArmed
? `Streaming en ligne, worker en veille${statusHint ? ' — ' + statusHint : ''}`
: 'Serveur streaming en ligne';
document.getElementById('streamActiveSessions').textContent = data.active_sessions || 0;
document.getElementById('streamTotalEvents').textContent = data.total_events || 0;
@@ -2834,6 +2870,31 @@
if (data.events_per_second !== undefined) rows.push({label: 'Événements/sec', value: (data.events_per_second || 0).toFixed(2)});
if (data.memory_usage_mb !== undefined) rows.push({label: 'Mémoire utilisée', value: Math.round(data.memory_usage_mb) + ' MB'});
if (data.server_version) rows.push({label: 'Version serveur', value: data.server_version});
if (processing && !processing.error) {
const status = processing.status || 'unknown';
const workerIcon = processingReady ? '✅' : (processingArmed ? '⏸️' : '⚠️');
rows.push({
label: 'Worker apprentissage',
value: `${workerIcon} ${status}`
});
rows.push({
label: 'Composants intelligence',
value: processing.components_ready
? 'prêts'
: (processingArmed ? 'en veille (chargés à la 1re session)' : 'non prêts')
});
if (statusHint) {
rows.push({label: 'Détail worker', value: statusHint});
}
if (processing.queue_length !== undefined) {
rows.push({label: 'Queue apprentissage', value: processing.queue_length});
}
if (processing.last_cycle) {
rows.push({label: 'Dernier cycle worker', value: new Date(processing.last_cycle).toLocaleString('fr-FR')});
}
} else if (processing && processing.error) {
rows.push({label: 'Worker apprentissage', value: `${processing.error}`});
}
if (rows.length === 0) {
// Afficher les données brutes si les clés attendues ne sont pas présentes