Compare commits
26 Commits
6d34b3cb68
...
poc-dgx
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cabc6cb7e | ||
|
|
d686c3ac22 | ||
|
|
e212f4141c | ||
|
|
33ddb51c3c | ||
|
|
1d6efdb1b7 | ||
|
|
cf81ce4c7b | ||
|
|
ec1fb81054 | ||
|
|
6d5ef51c60 | ||
|
|
d0c794d923 | ||
|
|
9605cc9d95 | ||
|
|
667575c3ad | ||
|
|
787dbfb0eb | ||
|
|
86b5ec18c6 | ||
|
|
b8b963059e | ||
|
|
2b1743c206 | ||
|
|
48879fb849 | ||
|
|
c12fd8e1c1 | ||
|
|
cbd3d40e39 | ||
|
|
33c1e2e0d1 | ||
|
|
c0e4c382be | ||
|
|
5c5ce747b0 | ||
|
|
b20d17882e | ||
|
|
9fb2c7bfee | ||
|
|
f7f6926410 | ||
|
|
09f65cecbe | ||
|
|
0ee54157e5 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
249
deploy/build_package_full.sh
Executable 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 ""
|
||||
152
deploy/installer/Installer-Lea.bat
Normal file
152
deploy/installer/Installer-Lea.bat
Normal 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
|
||||
@@ -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
|
||||
|
||||
105
deploy/installer/LISEZMOI-autonome.txt
Normal file
105
deploy/installer/LISEZMOI-autonome.txt
Normal 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.
|
||||
|
||||
============================================================
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
44
tests/test_dashboard_server_url.py
Normal file
44
tests/test_dashboard_server_url.py
Normal 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"
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
133
tests/unit/test_resolve_engine_dette019_confidence.py
Normal file
133
tests/unit/test_resolve_engine_dette019_confidence.py
Normal 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"
|
||||
177
tests/unit/test_resolve_engine_qwen3vl_vllm_cabling.py
Normal file
177
tests/unit/test_resolve_engine_qwen3vl_vllm_cabling.py
Normal 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"
|
||||
106
tests/unit/test_wpc_enroll_token.py
Normal file
106
tests/unit/test_wpc_enroll_token.py
Normal 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
|
||||
92
tests/unit/test_wpc_migration.py
Normal file
92
tests/unit/test_wpc_migration.py
Normal 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
|
||||
63
tests/unit/test_wpc_verify.py
Normal file
63
tests/unit/test_wpc_verify.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
195
visual_workflow_builder/backend/tests/test_vwb_basic_auth.py
Normal file
195
visual_workflow_builder/backend/tests/test_vwb_basic_auth.py
Normal 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"])
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user